373 lines
11 KiB
Go
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
|
|
}
|