persona-community-2/services/persona-api/internal/api/handlers/media.go
2026-02-23 10:54:06 +00:00

373 lines
11 KiB
Go

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
}