package media import ( "context" "encoding/json" "fmt" "mime" "net/http" "path/filepath" "strings" "time" "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/storage" "github.com/gin-gonic/gin" "github.com/google/uuid" ) const ( maxUploadSize = int64(10 << 30) uploadURLDuration = 24 * time.Hour ) type ProcessorQueue interface { Enqueue(uuid.UUID) } type Handler struct { repository *Repository storage storage.Storage processor ProcessorQueue auth *auth.Service } func NewHandler(repository *Repository, objectStorage storage.Storage, processor ProcessorQueue, authService *auth.Service) *Handler { return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService} } func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) { router.GET("/api/galleries/:id/media", require, h.List) router.POST("/api/galleries/:id/uploads", require, h.CreateUpload) router.POST("/api/uploads/:id/complete", require, h.CompleteUpload) router.PATCH("/api/media/:id", require, h.Update) router.POST("/api/media/:id/download", require, h.Download) router.DELETE("/api/uploads/:id", require, h.Delete) router.DELETE("/api/media/:id", require, h.Delete) } type uploadRequest struct { Filename string `json:"filename"` MimeType string `json:"mimeType"` FileSize int64 `json:"fileSize"` } type updateRequest struct { SortOrder *int `json:"sortOrder"` } // List godoc // @Summary List gallery media // @Tags media // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @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}/media [get] func (h *Handler) List(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } galleryID, err := parseID(c.Param("id")) if err != nil { writeError(c, http.StatusBadRequest, "invalid gallery id") return } belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID) if err != nil || !belongs { writeError(c, http.StatusNotFound, "gallery not found") return } items, err := h.repository.ListByGallery(c.Request.Context(), galleryID) if err != nil { writeError(c, http.StatusInternalServerError, "could not load media") return } views := make([]Public, 0, len(items)) for _, item := range items { view, err := h.view(c.Request.Context(), item, true) if err != nil { writeError(c, http.StatusInternalServerError, "could not sign media URLs") return } views = append(views, view) } writeJSON(c, http.StatusOK, map[string]any{"media": views}) } // CreateUpload godoc // @Summary Create a presigned media upload // @Tags media // @Accept json // @Produce json // @Security studioSession // @Param id path string true "Gallery UUID" // @Param request body uploadRequest true "Upload metadata" // @Success 201 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/galleries/{id}/uploads [post] func (h *Handler) CreateUpload(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } galleryID, err := parseID(c.Param("id")) if err != nil { writeError(c, http.StatusBadRequest, "invalid gallery id") return } belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID) if err != nil || !belongs { writeError(c, http.StatusNotFound, "gallery not found") return } var request uploadRequest if !decodeJSON(c, &request) { return } filename := safeFilename(request.Filename) mimeType := strings.ToLower(strings.TrimSpace(request.MimeType)) if mimeType == "" { mimeType = mime.TypeByExtension(filepath.Ext(filename)) } if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) { writeError(c, http.StatusBadRequest, "unsupported media file") return } if request.FileSize <= 0 || request.FileSize > maxUploadSize { writeError(c, http.StatusBadRequest, "file size must be between 1 byte and 10 GB") return } mediaID := uuid.New() storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename) item, err := h.repository.Create(c.Request.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey) if err != nil { writeError(c, http.StatusInternalServerError, "could not create upload") return } uploadURL, err := h.storage.CreateUploadURL(c.Request.Context(), storageKey, mimeType, uploadURLDuration) if err != nil { _, _ = h.repository.Delete(c.Request.Context(), user.ID, mediaID) writeError(c, http.StatusInternalServerError, "could not create upload URL") return } writeJSON(c, http.StatusCreated, map[string]any{ "uploadId": mediaID.String(), "uploadUrl": uploadURL, "media": publicFromRecord(item), }) } // CompleteUpload godoc // @Summary Complete a media upload // @Tags media // @Produce json // @Security studioSession // @Param id path string true "Media UUID" // @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/uploads/{id}/complete [post] func (h *Handler) CompleteUpload(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } mediaID, err := parseID(c.Param("id")) if err != nil { writeError(c, http.StatusBadRequest, "invalid media id") return } item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID) if err != nil { writeError(c, http.StatusNotFound, "media not found") return } info, err := h.storage.Stat(c.Request.Context(), item.StorageKey) if err != nil { writeError(c, http.StatusBadRequest, "uploaded object is not available yet") return } item, err = h.repository.Complete(c.Request.Context(), user.ID, mediaID, info.Size) if err != nil { writeError(c, http.StatusInternalServerError, "could not complete upload") return } h.processor.Enqueue(item.ID) writeJSON(c, http.StatusOK, map[string]any{"media": publicFromRecord(item)}) } // Update godoc // @Summary Update media ordering // @Tags media // @Accept json // @Produce json // @Security studioSession // @Param id path string true "Media UUID" // @Param request body updateRequest true "Media 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/media/{id} [patch] func (h *Handler) Update(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } mediaID, err := parseID(c.Param("id")) if err != nil { writeError(c, http.StatusBadRequest, "invalid media id") return } var request updateRequest if !decodeJSON(c, &request) { return } if request.SortOrder == nil || *request.SortOrder < 0 { writeError(c, http.StatusBadRequest, "sort order must be zero or greater") return } if err := h.repository.UpdateSortOrder(c.Request.Context(), user.ID, mediaID, *request.SortOrder); err != nil { writeError(c, http.StatusNotFound, "media not found") return } item, _ := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID) view, err := h.view(c.Request.Context(), item, true) if err != nil { writeError(c, http.StatusInternalServerError, "could not sign media URL") return } writeJSON(c, http.StatusOK, map[string]any{"media": view}) } // Delete godoc // @Summary Delete media or cancel an upload // @Tags media // @Produce json // @Security studioSession // @Param id path string true "Media UUID" // @Success 204 // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/uploads/{id} [delete] // @Router /api/media/{id} [delete] func (h *Handler) Delete(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } mediaID, err := parseID(c.Param("id")) if err != nil { writeError(c, http.StatusBadRequest, "invalid media id") return } item, err := h.repository.Delete(c.Request.Context(), user.ID, mediaID) if err != nil { writeError(c, http.StatusNotFound, "media not found") return } for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} { if key != "" && key != item.ExternalURL { _ = h.storage.Delete(c.Request.Context(), key) } } c.Status(http.StatusNoContent) } // Download godoc // @Summary Create a signed original media download URL // @Tags media // @Produce json // @Security studioSession // @Param id path string true "Media UUID" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/media/{id}/download [post] func (h *Handler) Download(c *gin.Context) { user, ok := auth.UserFromContext(c) if !ok { writeError(c, http.StatusUnauthorized, "authentication required") return } mediaID, err := parseID(c.Param("id")) if err != nil { writeError(c, http.StatusBadRequest, "invalid media id") return } item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID) if err != nil || item.ProcessingStatus != StatusReady { writeError(c, http.StatusNotFound, "media not found") return } url := item.ExternalURL if url == "" { url, err = h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour) if err != nil { writeError(c, http.StatusInternalServerError, "could not create download") return } } _ = h.repository.RecordDownload(c.Request.Context(), item.GalleryID, &item.ID, user.ID.String()) writeJSON(c, http.StatusOK, map[string]string{"url": url}) } func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) { view := publicFromRecord(item) if item.ProcessingStatus != StatusReady { return view, nil } if item.ExternalURL != "" { view.ThumbnailURL = item.ExternalURL view.PreviewURL = item.ExternalURL if includeOriginal { view.OriginalURL = item.ExternalURL } return view, nil } previewKey := item.PreviewKey if previewKey == "" { previewKey = item.StorageKey } thumbnailKey := item.ThumbnailKey if thumbnailKey == "" { thumbnailKey = previewKey } var err error view.ThumbnailURL, err = h.storage.CreateDownloadURL(ctx, thumbnailKey, time.Hour) if err != nil { return Public{}, err } view.PreviewURL, err = h.storage.CreateDownloadURL(ctx, previewKey, time.Hour) if err != nil { return Public{}, err } if includeOriginal { view.OriginalURL, err = h.storage.CreateDownloadURL(ctx, item.StorageKey, time.Hour) if err != nil { return Public{}, err } } return view, nil } func publicFromRecord(item Record) Public { return 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, } } func allowedMimeType(value string) bool { switch value { case "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "video/mp4", "video/quicktime", "video/webm": return true default: return false } } func safeFilename(value string) string { value = strings.ReplaceAll(value, "\\", "/") value = filepath.Base(value) if value == "." || value == ".." { return "" } return strings.TrimSpace(value) } func parseID(value string) (uuid.UUID, error) { return uuid.Parse(value) } 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 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) }