package handlers import ( "errors" "fmt" "net/http" "path/filepath" "strings" "time" "git.threesix.ai/jordan/persona-community-2/pkg/app" "git.threesix.ai/jordan/persona-community-2/pkg/auth" "git.threesix.ai/jordan/persona-community-2/pkg/httperror" "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse" "git.threesix.ai/jordan/persona-community-2/pkg/logging" "git.threesix.ai/jordan/persona-community-2/pkg/storage" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // maxUploadSize is the maximum allowed file size for uploads (500MB). const maxUploadSize = 500 << 20 // allowedMediaTypes is the allowlist of MIME types permitted for upload. var allowedMediaTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, "image/svg+xml": true, "video/mp4": true, "video/webm": true, "video/quicktime": true, "audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "audio/webm": true, "application/pdf": true, } // Media handles media upload and library operations. type Media struct { store storage.Store repo port.MediaRepository logger *logging.Logger } // NewMedia creates a new media handler. func NewMedia(store storage.Store, repo port.MediaRepository, logger *logging.Logger) *Media { return &Media{store: store, repo: repo, logger: logger.WithComponent("MediaHandler")} } // Routes returns the media subrouter. func (h *Media) Routes() http.Handler { r := chi.NewRouter() r.Post("/upload/init", app.Wrap(h.InitUpload)) r.Post("/upload/complete", app.Wrap(h.CompleteUpload)) r.Get("/", app.Wrap(h.List)) r.Get("/{id}", app.Wrap(h.GetOne)) r.Get("/{id}/url", app.Wrap(h.RefreshURL)) r.Delete("/{id}", app.Wrap(h.Delete)) return r } // sanitizeFilename removes path separators and dangerous characters from filenames. func sanitizeFilename(name string) string { // Remove any directory components name = filepath.Base(name) // Replace any remaining path separators (e.g., from URL encoding) name = strings.ReplaceAll(name, "/", "_") name = strings.ReplaceAll(name, "\\", "_") name = strings.ReplaceAll(name, "..", "_") // Remove null bytes name = strings.ReplaceAll(name, "\x00", "") if name == "" || name == "." { name = "unnamed" } return name } // initUploadRequest is the request body for POST /media/upload/init. type initUploadRequest struct { Filename string `json:"filename" validate:"required"` ContentType string `json:"contentType" validate:"required"` Size int64 `json:"size"` } // InitUpload returns a presigned URL for direct client-to-storage upload. // The metadata record is created in CompleteUpload after the file is actually stored. func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error { var req initUploadRequest if err := app.BindAndValidate(r, &req); err != nil { return err } // Validate MIME type against allowlist if !allowedMediaTypes[req.ContentType] { return httperror.BadRequest("unsupported file type: " + req.ContentType) } // Validate file size if provided if req.Size > maxUploadSize { return httperror.BadRequest(fmt.Sprintf("file too large: %d bytes (max %d)", req.Size, maxUploadSize)) } user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } // Sanitize filename to prevent path traversal safeName := sanitizeFilename(req.Filename) // Build object path: media/{userID}/{uuid}/{filename} objectPath := fmt.Sprintf("media/%s/%s/%s", user.ID, uuid.New().String(), safeName) presigned, err := h.store.UploadPresigned(r.Context(), objectPath, req.ContentType) if err != nil { h.logger.Error("failed to create presigned upload", "error", err) return httperror.Internal("failed to create upload URL") } httpresponse.OK(w, r, map[string]any{ "uploadURL": presigned.URL, "objectPath": objectPath, "filename": safeName, "headers": presigned.Headers, "method": presigned.Method, "expires": presigned.Expires, }) return nil } // completeUploadRequest is the request body for POST /media/upload/complete. type completeUploadRequest struct { ObjectPath string `json:"objectPath" validate:"required"` Filename string `json:"filename"` ContentType string `json:"contentType"` Size int64 `json:"size"` } // CompleteUpload confirms an upload is done, creates the metadata record, and returns the final URL. func (h *Media) CompleteUpload(w http.ResponseWriter, r *http.Request) error { var req completeUploadRequest if err := app.BindAndValidate(r, &req); err != nil { return err } // Verify the object path belongs to the authenticated user user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } expectedPrefix := fmt.Sprintf("media/%s/", user.ID) if !strings.HasPrefix(req.ObjectPath, expectedPrefix) { return httperror.Forbidden("cannot complete upload for another user's media") } url, err := h.store.GetURL(r.Context(), req.ObjectPath) if err != nil { h.logger.Error("failed to get object URL", "error", err, "path", req.ObjectPath) return httperror.Internal("failed to confirm upload") } // Create the metadata record now that the file is in storage. now := time.Now() filename := sanitizeFilename(req.Filename) if filename == "unnamed" { // Extract filename from the object path (last segment) parts := strings.Split(req.ObjectPath, "/") if len(parts) > 0 { filename = parts[len(parts)-1] } } mediaObj := &domain.MediaObject{ ID: domain.MediaObjectID("med_" + uuid.New().String()), UserID: domain.UserID(user.ID), Path: req.ObjectPath, Filename: filename, ContentType: req.ContentType, Size: req.Size, CreatedAt: now, UpdatedAt: now, } if err := h.repo.Create(r.Context(), mediaObj); err != nil { h.logger.Error("failed to create media record", "error", err) return httperror.Internal("failed to create upload record") } httpresponse.OK(w, r, map[string]any{ "id": string(mediaObj.ID), "url": url, "path": req.ObjectPath, }) return nil } // List returns the user's media objects with pagination. func (h *Media) List(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } opts := port.ListMediaOptions{ ContentTypePrefix: r.URL.Query().Get("type"), Limit: intQueryParam(r, "limit", 50), Offset: intQueryParam(r, "offset", 0), } objects, total, err := h.repo.ListByUser(r.Context(), domain.UserID(user.ID), opts) if err != nil { h.logger.Error("failed to list media", "error", err) return httperror.Internal("failed to list media") } // Enrich each object with a fresh signed URL type mediaItem struct { ID string `json:"id"` Path string `json:"path"` URL string `json:"url"` Filename string `json:"filename"` ContentType string `json:"contentType"` Size int64 `json:"size"` CreatedAt time.Time `json:"createdAt"` } items := make([]mediaItem, 0, len(objects)) for _, obj := range objects { url, urlErr := h.store.GetURL(r.Context(), obj.Path) if urlErr != nil { h.logger.Warn("failed to get URL for media object", "path", obj.Path, "error", urlErr) continue } items = append(items, mediaItem{ ID: string(obj.ID), Path: obj.Path, URL: url, Filename: obj.Filename, ContentType: obj.ContentType, Size: obj.Size, CreatedAt: obj.CreatedAt, }) } httpresponse.OK(w, r, map[string]any{ "items": items, "total": total, "count": len(items), }) return nil } // GetOne returns a single media object with a fresh URL. func (h *Media) GetOne(w http.ResponseWriter, r *http.Request) error { id := chi.URLParam(r, "id") if id == "" { return httperror.BadRequest("media ID is required") } obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id)) if err != nil { if errors.Is(err, domain.ErrNotFound) { return httperror.NotFound("media object not found") } return httperror.Internal("failed to get media object") } // Verify ownership user := auth.GetUser(r.Context()) if user == nil || domain.UserID(user.ID) != obj.UserID { return httperror.Forbidden("access denied") } url, err := h.store.GetURL(r.Context(), obj.Path) if err != nil { h.logger.Error("failed to get URL", "error", err, "path", obj.Path) return httperror.Internal("failed to get media URL") } httpresponse.OK(w, r, map[string]any{ "id": string(obj.ID), "path": obj.Path, "url": url, "filename": obj.Filename, "contentType": obj.ContentType, "size": obj.Size, "createdAt": obj.CreatedAt, }) return nil } // RefreshURL returns a fresh signed URL for a media object. func (h *Media) RefreshURL(w http.ResponseWriter, r *http.Request) error { id := chi.URLParam(r, "id") if id == "" { return httperror.BadRequest("media ID is required") } obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id)) if err != nil { if errors.Is(err, domain.ErrNotFound) { return httperror.NotFound("media object not found") } return httperror.Internal("failed to get media object") } // Verify ownership user := auth.GetUser(r.Context()) if user == nil || domain.UserID(user.ID) != obj.UserID { return httperror.Forbidden("access denied") } url, err := h.store.GetURL(r.Context(), obj.Path) if err != nil { h.logger.Error("failed to refresh URL", "error", err, "path", obj.Path) return httperror.Internal("failed to refresh media URL") } httpresponse.OK(w, r, map[string]any{ "id": string(obj.ID), "url": url, }) return nil } // Delete soft-deletes a media object. func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error { id := chi.URLParam(r, "id") if id == "" { return httperror.BadRequest("media ID is required") } obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id)) if err != nil { if errors.Is(err, domain.ErrNotFound) { return httperror.NotFound("media object not found") } return httperror.Internal("failed to get media object") } // Verify ownership user := auth.GetUser(r.Context()) if user == nil || domain.UserID(user.ID) != obj.UserID { return httperror.Forbidden("cannot delete another user's media") } if err := h.repo.SoftDelete(r.Context(), domain.MediaObjectID(id)); err != nil { h.logger.Error("failed to delete media", "error", err, "id", id) return httperror.Internal("failed to delete media") } httpresponse.OK(w, r, map[string]any{"deleted": id}) return nil } // intQueryParam parses an integer query parameter with a default value. func intQueryParam(r *http.Request, key string, defaultVal int) int { val := r.URL.Query().Get(key) if val == "" { return defaultVal } var n int if _, err := fmt.Sscanf(val, "%d", &n); err != nil || n < 0 { return defaultVal } return n }