Files
wijtransferen/backend/internal/media/handler.go
T
2026-08-22 17:27:55 +02:00

427 lines
13 KiB
Go

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)
}