package galleries import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/storage" "github.com/gin-gonic/gin" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) const signedURLDuration = time.Hour type Handler struct { repository *Repository media *media.Repository storage storage.Storage auth *auth.Service } func NewHandler(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service) *Handler { return &Handler{ repository: repository, media: mediaRepository, storage: objectStorage, auth: authService, } } func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) { router.GET("/api/galleries", require, h.List) router.POST("/api/galleries", require, h.Create) router.GET("/api/galleries/:id", require, h.Get) router.PATCH("/api/galleries/:id", require, h.Update) router.DELETE("/api/galleries/:id", require, h.Delete) router.POST("/api/galleries/:id/publish", require, h.Publish) router.POST("/api/galleries/:id/unpublish", require, h.Unpublish) router.GET("/api/galleries/:id/preview", require, h.Preview) } func (h *Handler) RegisterPublicRoutes(router gin.IRouter) { router.GET("/api/public/galleries/:slug", h.Public) router.POST("/api/public/galleries/:slug/authenticate", h.AuthenticatePublic) router.POST("/api/public/galleries/:slug/media/:mediaId/favorite", h.Favorite) router.DELETE("/api/public/galleries/:slug/media/:mediaId/favorite", h.Unfavorite) } type createRequest struct { Title string `json:"title"` ClientName string `json:"clientName"` Description string `json:"description"` } type updateRequest struct { Title *string `json:"title"` ClientName *string `json:"clientName"` Description *string `json:"description"` Password *string `json:"password"` ClearPassword bool `json:"clearPassword"` DownloadsEnabled *bool `json:"downloadsEnabled"` FavoritesEnabled *bool `json:"favoritesEnabled"` DownloadAllEnabled *bool `json:"downloadAllEnabled"` WatermarkEnabled *bool `json:"watermarkEnabled"` ExpiresAt *string `json:"expiresAt"` CoverMediaID *string `json:"coverMediaId"` ThemeConfig json.RawMessage `json:"themeConfig"` BrandingConfig json.RawMessage `json:"brandingConfig"` } type publicPasswordRequest struct { Password string `json:"password"` } // List godoc // @Summary List owned galleries // @Tags galleries // @Produce json // @Security studioSession // @Success 200 {object} map[string]interface{} // @Failure 401 {object} map[string]string // @Router /api/galleries [get] func (h *Handler) List(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID) if err != nil { writeError(c, http.StatusInternalServerError, "could not load galleries") return } for index := range summaries { if summaries[index].CoverMediaID == "" { continue } coverID, err := uuid.Parse(summaries[index].CoverMediaID) if err != nil { continue } cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID) if err != nil { continue } summaries[index].CoverURL, _ = h.mediaURL(c.Request.Context(), cover, false) } writeJSON(c, http.StatusOK, map[string]any{"galleries": summaries}) } // Create godoc // @Summary Create a gallery // @Tags galleries // @Accept json // @Produce json // @Security studioSession // @Param request body createRequest true "Gallery details" // @Success 201 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Router /api/galleries [post] func (h *Handler) Create(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } var request createRequest if !decodeJSON(c, &request) { return } request.Title = strings.TrimSpace(request.Title) request.ClientName = strings.TrimSpace(request.ClientName) if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 { writeError(c, http.StatusBadRequest, "title and client name are required") return } record, err := h.repository.Create(c.Request.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description)) if err != nil { writeError(c, http.StatusInternalServerError, "could not create gallery") return } writeJSON(c, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)}) } // Get godoc // @Summary Get an owned gallery // @Tags galleries // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Success 200 {object} map[string]interface{} // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/galleries/{id} [get] func (h *Handler) Get(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } record, err := h.recordForUser(c, user.ID) if err != nil { writeGalleryError(c, err) return } items, err := h.media.ListByGallery(c.Request.Context(), record.ID) if err != nil { writeError(c, http.StatusInternalServerError, "could not load gallery media") return } views, err := h.mediaViews(c.Request.Context(), items, "", true) if err != nil { writeError(c, http.StatusInternalServerError, "could not sign media URLs") return } writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) } // Preview godoc // @Summary Preview a gallery as a client // @Tags galleries // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Success 200 {object} map[string]interface{} // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/galleries/{id}/preview [get] func (h *Handler) Preview(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } record, err := h.recordForUser(c, user.ID) if err != nil { writeGalleryError(c, err) return } gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "") if err != nil { writeError(c, http.StatusInternalServerError, "could not build gallery preview") return } gallery.Preview = true writeJSON(c, http.StatusOK, map[string]any{"gallery": gallery}) } // Update godoc // @Summary Update a gallery // @Tags galleries // @Accept json // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Param request body map[string]interface{} true "Gallery changes" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/galleries/{id} [patch] func (h *Handler) Update(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } current, err := h.recordForUser(c, user.ID) if err != nil { writeGalleryError(c, err) return } var request updateRequest if !decodeJSON(c, &request) { return } input := UpdateInput{ Title: current.Title, ClientName: current.ClientName, Description: current.Description, DownloadsEnabled: current.DownloadsEnabled, FavoritesEnabled: current.FavoritesEnabled, DownloadAllEnabled: current.DownloadAllEnabled, WatermarkEnabled: current.WatermarkEnabled, ExpiresAt: stringPointer(current.ExpiresAt), CoverMediaID: stringPointer(current.CoverMediaID), ThemeConfig: current.ThemeConfig, BrandingConfig: current.BrandingConfig, } if current.PasswordHash != "" { input.PasswordHash = ¤t.PasswordHash } if request.Title != nil { input.Title = strings.TrimSpace(*request.Title) } if request.ClientName != nil { input.ClientName = strings.TrimSpace(*request.ClientName) } if request.Description != nil { input.Description = strings.TrimSpace(*request.Description) } if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 { writeError(c, http.StatusBadRequest, "title and client name are required") return } if request.Password != nil { if strings.TrimSpace(*request.Password) == "" { input.ClearPassword = true } else if len(*request.Password) < 4 { writeError(c, http.StatusBadRequest, "gallery password must be at least 4 characters") return } else { hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost) if err != nil { writeError(c, http.StatusInternalServerError, "could not secure gallery password") return } hashed := string(hash) input.PasswordHash = &hashed input.ClearPassword = false } } if request.ClearPassword { input.ClearPassword = true input.PasswordHash = nil } if request.DownloadsEnabled != nil { input.DownloadsEnabled = *request.DownloadsEnabled } if request.FavoritesEnabled != nil { input.FavoritesEnabled = *request.FavoritesEnabled } if request.DownloadAllEnabled != nil { input.DownloadAllEnabled = *request.DownloadAllEnabled } if request.WatermarkEnabled != nil { input.WatermarkEnabled = *request.WatermarkEnabled } if request.ExpiresAt != nil { value := strings.TrimSpace(*request.ExpiresAt) if value != "" { if _, err := time.Parse(time.RFC3339, value); err != nil { writeError(c, http.StatusBadRequest, "expiry must be an ISO timestamp") return } } input.ExpiresAt = &value } if request.CoverMediaID != nil { value := strings.TrimSpace(*request.CoverMediaID) if value != "" { coverID, err := uuid.Parse(value) if err != nil { writeError(c, http.StatusBadRequest, "invalid cover media id") return } cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID) if err != nil || cover.GalleryID != current.ID { writeError(c, http.StatusBadRequest, "cover media does not belong to this gallery") return } } input.CoverMediaID = &value } if len(request.ThemeConfig) > 0 { if !json.Valid(request.ThemeConfig) { writeError(c, http.StatusBadRequest, "theme config must be valid JSON") return } input.ThemeConfig = request.ThemeConfig } if len(request.BrandingConfig) > 0 { if !json.Valid(request.BrandingConfig) { writeError(c, http.StatusBadRequest, "branding config must be valid JSON") return } input.BrandingConfig = request.BrandingConfig } record, err := h.repository.Update(c.Request.Context(), user.ID, current.ID, input) if err != nil { writeError(c, http.StatusInternalServerError, "could not update gallery") return } items, err := h.media.ListByGallery(c.Request.Context(), record.ID) if err != nil { writeError(c, http.StatusInternalServerError, "could not load gallery media") return } views, err := h.mediaViews(c.Request.Context(), items, "", true) if err != nil { writeError(c, http.StatusInternalServerError, "could not sign media URLs") return } writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) } // Publish godoc // @Summary Publish a gallery // @Tags galleries // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Success 200 {object} map[string]interface{} // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/galleries/{id}/publish [post] func (h *Handler) Publish(c *gin.Context) { h.setStatus(c, StatusPublished) } // Unpublish godoc // @Summary Unpublish a gallery // @Tags galleries // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Success 200 {object} map[string]interface{} // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/galleries/{id}/unpublish [post] func (h *Handler) Unpublish(c *gin.Context) { h.setStatus(c, StatusDraft) } func (h *Handler) setStatus(c *gin.Context, status string) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } record, err := h.recordForUser(c, user.ID) if err != nil { writeGalleryError(c, err) return } record, err = h.repository.SetStatus(c.Request.Context(), user.ID, record.ID, status) if err != nil { writeError(c, http.StatusInternalServerError, "could not update gallery status") return } writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)}) } // Delete godoc // @Summary Delete a gallery // @Tags galleries // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/galleries/{id} [delete] func (h *Handler) Delete(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } id, err := pathUUID(c, "id") if err != nil { writeGalleryError(c, err) return } if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil { writeGalleryError(c, err) return } c.Status(http.StatusNoContent) } // Public godoc // @Summary Get a published public gallery // @Tags public galleries // @Produce json // @Param slug path string true "Gallery slug" // @Success 200 {object} map[string]interface{} // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/public/galleries/{slug} [get] func (h *Handler) Public(c *gin.Context) { slug := strings.TrimSpace(c.Param("slug")) record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug) if err != nil || record.IsExpired() { writeError(c, http.StatusNotFound, "gallery not found") return } if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) { gallery := h.lockedPayload(record) writeJSON(c, http.StatusOK, gallery) return } visitorID := "" if record.FavoritesEnabled { visitorID = h.auth.EnsureVisitor(c) } gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID) if err != nil { writeError(c, http.StatusInternalServerError, "could not load gallery") return } writeJSON(c, http.StatusOK, gallery) } // AuthenticatePublic godoc // @Summary Authenticate to a password-protected gallery // @Tags public galleries // @Accept json // @Produce json // @Param slug path string true "Gallery slug" // @Param request body publicPasswordRequest true "Gallery password" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/public/galleries/{slug}/authenticate [post] func (h *Handler) AuthenticatePublic(c *gin.Context) { slug := strings.TrimSpace(c.Param("slug")) record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug) if err != nil || record.IsExpired() { writeError(c, http.StatusNotFound, "gallery not found") return } var request publicPasswordRequest if !decodeJSON(c, &request) { return } if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil { writeError(c, http.StatusUnauthorized, "incorrect gallery password") return } h.auth.GrantGalleryAccess(c, record.Slug) visitorID := "" if record.FavoritesEnabled { visitorID = h.auth.EnsureVisitor(c) } gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID) if err != nil { writeError(c, http.StatusInternalServerError, "could not load gallery") return } writeJSON(c, http.StatusOK, gallery) } // Favorite godoc // @Summary Favorite a gallery media item // @Tags public galleries // @Produce json // @Param slug path string true "Gallery slug" // @Param mediaId path string true "Media UUID" // @Success 200 {object} map[string]bool // @Failure 400 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [post] func (h *Handler) Favorite(c *gin.Context) { h.setFavorite(c, true) } // Unfavorite godoc // @Summary Remove a favorite from a gallery media item // @Tags public galleries // @Produce json // @Param slug path string true "Gallery slug" // @Param mediaId path string true "Media UUID" // @Success 200 {object} map[string]bool // @Failure 400 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [delete] func (h *Handler) Unfavorite(c *gin.Context) { h.setFavorite(c, false) } func (h *Handler) setFavorite(c *gin.Context, favorited bool) { record, err := h.publicRecordForRequest(c) if err != nil { writeError(c, http.StatusNotFound, "gallery not found") return } if !record.FavoritesEnabled { writeError(c, http.StatusForbidden, "favorites are disabled") return } mediaID, err := pathUUID(c, "mediaId") if err != nil { writeError(c, http.StatusBadRequest, "invalid media id") return } item, err := h.media.GetByID(c.Request.Context(), mediaID) if err != nil || item.GalleryID != record.ID { writeError(c, http.StatusNotFound, "media not found") return } visitorID := h.auth.EnsureVisitor(c) if err := h.media.SetFavorite(c.Request.Context(), record.ID, mediaID, visitorID, favorited); err != nil { writeError(c, http.StatusInternalServerError, "could not update favorite") return } writeJSON(c, http.StatusOK, map[string]bool{"favorited": favorited}) } func (h *Handler) publicRecordForRequest(c *gin.Context) (GalleryRecord, error) { record, err := h.repository.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug"))) if err != nil || record.IsExpired() { return GalleryRecord{}, ErrNotFound } if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) { return GalleryRecord{}, ErrNotFound } return record, nil } func (h *Handler) publicPayload(ctx context.Context, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) { items, err := h.media.ListByGallery(ctx, record.ID) if err != nil { return Public{}, err } views, err := h.mediaViews(ctx, items, visitorID, includeOriginal) if err != nil { return Public{}, err } gallery := Public{ Slug: record.Slug, Title: record.Title, ClientName: record.ClientName, Description: record.Description, ThemeConfig: record.ThemeConfig, BrandingConfig: record.BrandingConfig, DownloadsEnabled: record.DownloadsEnabled, FavoritesEnabled: record.FavoritesEnabled, DownloadAllEnabled: record.DownloadAllEnabled, WatermarkEnabled: record.WatermarkEnabled, ExpiresAt: record.ExpiresAt, Media: views, } for index := range items { if record.CoverMediaID != "" && items[index].ID.String() == record.CoverMediaID { gallery.Cover = &views[index] break } } if gallery.Cover == nil { for index := range items { if media.IsImage(items[index]) && views[index].PreviewURL != "" { gallery.Cover = &views[index] break } } } return gallery, nil } func (h *Handler) lockedPayload(record GalleryRecord) map[string]any { return map[string]any{ "slug": record.Slug, "title": record.Title, "clientName": record.ClientName, "description": record.Description, "themeConfig": record.ThemeConfig, "brandingConfig": record.BrandingConfig, "requiresPassword": true, "media": []media.Public{}, } } func (h *Handler) recordForUser(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) { id, err := pathUUID(c, "id") if err != nil { return GalleryRecord{}, err } return h.repository.GetForUser(c.Request.Context(), userID, id) } func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail { if items == nil { items = []media.Public{} } return Detail{ ID: record.ID.String(), Slug: record.Slug, Title: record.Title, ClientName: record.ClientName, Description: record.Description, Status: record.Status, DownloadsEnabled: record.DownloadsEnabled, FavoritesEnabled: record.FavoritesEnabled, DownloadAllEnabled: record.DownloadAllEnabled, WatermarkEnabled: record.WatermarkEnabled, ExpiresAt: record.ExpiresAt, CoverMediaID: record.CoverMediaID, ThemeConfig: record.ThemeConfig, BrandingConfig: record.BrandingConfig, CreatedAt: record.CreatedAt, PublishedAt: record.PublishedAt, Media: items, } } func (h *Handler) mediaViews(ctx context.Context, items []media.Record, visitorID string, includeOriginal bool) ([]media.Public, error) { views := make([]media.Public, 0, len(items)) for _, item := range items { view := media.Public{ ID: item.ID, OriginalFilename: item.OriginalFilename, MimeType: item.MimeType, FileSize: item.FileSize, ProcessingStatus: item.ProcessingStatus, Width: item.Width, Height: item.Height, DurationSeconds: item.DurationSeconds, SortOrder: item.SortOrder, } if item.ProcessingStatus == media.StatusReady { var err error view.ThumbnailURL, err = h.mediaVariantURL(ctx, item, item.ThumbnailKey) if err != nil { return nil, err } view.PreviewURL, err = h.mediaURL(ctx, item, false) if err != nil { return nil, err } if includeOriginal { view.OriginalURL, err = h.mediaURL(ctx, item, true) if err != nil { return nil, err } } } if visitorID != "" { var err error view.Favorited, err = h.media.IsFavorited(ctx, item.GalleryID, item.ID, visitorID) if err != nil { return nil, err } } views = append(views, view) } return views, nil } func (h *Handler) mediaURL(ctx context.Context, item media.Record, original bool) (string, error) { if item.ExternalURL != "" { return item.ExternalURL, nil } key := item.PreviewKey if original { key = item.StorageKey } return h.mediaVariantURL(ctx, item, key) } func (h *Handler) mediaVariantURL(ctx context.Context, item media.Record, key string) (string, error) { if item.ExternalURL != "" { return item.ExternalURL, nil } if key == "" { return "", fmt.Errorf("media object is not ready") } return h.storage.CreateDownloadURL(ctx, key, signedURLDuration) } func newSlug(title string) string { var builder strings.Builder lastWasSeparator := true for _, character := range strings.ToLower(title) { if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') { builder.WriteRune(character) lastWasSeparator = false continue } if builder.Len() > 0 && !lastWasSeparator { builder.WriteByte('-') lastWasSeparator = true } } slug := strings.Trim(builder.String(), "-") if slug == "" { slug = "gallery" } suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8] return slug + "-" + suffix } func pathUUID(c *gin.Context, name string) (uuid.UUID, error) { id, err := uuid.Parse(c.Param(name)) if err != nil { return uuid.Nil, fmt.Errorf("invalid %s", name) } return id, nil } func stringPointer(value string) *string { if value == "" { return nil } copy := value return © } func decodeJSON(c *gin.Context, target any) bool { if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json") return false } decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { writeError(c, http.StatusBadRequest, "invalid JSON body") return false } return true } func writeGalleryError(c *gin.Context, err error) { if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") { writeError(c, http.StatusNotFound, "gallery not found") return } writeError(c, http.StatusInternalServerError, "could not load gallery") } func writeError(c *gin.Context, status int, message string) { writeJSON(c, status, map[string]string{"error": message}) } func writeJSON(c *gin.Context, status int, value any) { c.JSON(status, value) }