diff --git a/internal/dto/responses/document.go b/internal/dto/responses/document.go index 261147c..fa38ca3 100644 --- a/internal/dto/responses/document.go +++ b/internal/dto/responses/document.go @@ -1,6 +1,7 @@ package responses import ( + "fmt" "time" "github.com/shopspring/decimal" @@ -20,6 +21,7 @@ type DocumentUserResponse struct { type DocumentImageResponse struct { ID uint `json:"id"` ImageURL string `json:"image_url"` + MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/document-image/{id} Caption string `json:"caption"` } @@ -34,6 +36,7 @@ type DocumentResponse struct { Description string `json:"description"` DocumentType models.DocumentType `json:"document_type"` FileURL string `json:"file_url"` + MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/document/{id} FileName string `json:"file_name"` FileSize *int64 `json:"file_size"` MimeType string `json:"mime_type"` @@ -78,6 +81,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse { Description: d.Description, DocumentType: d.DocumentType, FileURL: d.FileURL, + MediaURL: fmt.Sprintf("/api/media/document/%d", d.ID), // Authenticated endpoint FileName: d.FileName, FileSize: d.FileSize, MimeType: d.MimeType, @@ -98,11 +102,12 @@ func NewDocumentResponse(d *models.Document) DocumentResponse { resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy) } - // Convert images + // Convert images with authenticated media URLs for _, img := range d.Images { resp.Images = append(resp.Images, DocumentImageResponse{ ID: img.ID, ImageURL: img.ImageURL, + MediaURL: fmt.Sprintf("/api/media/document-image/%d", img.ID), // Authenticated endpoint Caption: img.Caption, }) } diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index 0a77dad..e6383c0 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -58,6 +58,7 @@ type TaskUserResponse struct { type TaskCompletionImageResponse struct { ID uint `json:"id"` ImageURL string `json:"image_url"` + MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/completion-image/{id} Caption string `json:"caption"` } @@ -213,11 +214,12 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse if c.CompletedBy.ID != 0 { resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy) } - // Convert images + // Convert images with authenticated media URLs for _, img := range c.Images { resp.Images = append(resp.Images, TaskCompletionImageResponse{ ID: img.ID, ImageURL: img.ImageURL, + MediaURL: fmt.Sprintf("/api/media/completion-image/%d", img.ID), // Authenticated endpoint Caption: img.Caption, }) } diff --git a/internal/handlers/media_handler.go b/internal/handlers/media_handler.go new file mode 100644 index 0000000..2bd2ea4 --- /dev/null +++ b/internal/handlers/media_handler.go @@ -0,0 +1,186 @@ +package handlers + +import ( + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/treytartt/casera-api/internal/middleware" + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/repositories" + "github.com/treytartt/casera-api/internal/services" +) + +// MediaHandler handles authenticated media serving +type MediaHandler struct { + documentRepo *repositories.DocumentRepository + taskRepo *repositories.TaskRepository + residenceRepo *repositories.ResidenceRepository + storageSvc *services.StorageService +} + +// NewMediaHandler creates a new media handler +func NewMediaHandler( + documentRepo *repositories.DocumentRepository, + taskRepo *repositories.TaskRepository, + residenceRepo *repositories.ResidenceRepository, + storageSvc *services.StorageService, +) *MediaHandler { + return &MediaHandler{ + documentRepo: documentRepo, + taskRepo: taskRepo, + residenceRepo: residenceRepo, + storageSvc: storageSvc, + } +} + +// ServeDocument serves a document file with access control +// GET /api/media/document/:id +func (h *MediaHandler) ServeDocument(c *gin.Context) { + user := c.MustGet(middleware.AuthUserKey).(*models.User) + + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + // Get document + doc, err := h.documentRepo.FindByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + // Check access to residence + hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID) + if err != nil || !hasAccess { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + // Serve the file + filePath := h.resolveFilePath(doc.FileURL) + if filePath == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + // Set caching headers (private, 1 hour) + c.Header("Cache-Control", "private, max-age=3600") + c.File(filePath) +} + +// ServeDocumentImage serves a document image with access control +// GET /api/media/document-image/:id +func (h *MediaHandler) ServeDocumentImage(c *gin.Context) { + user := c.MustGet(middleware.AuthUserKey).(*models.User) + + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + // Get document image + img, err := h.documentRepo.FindImageByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"}) + return + } + + // Get parent document to check residence access + doc, err := h.documentRepo.FindByID(img.DocumentID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Parent document not found"}) + return + } + + // Check access to residence + hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID) + if err != nil || !hasAccess { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + // Serve the file + filePath := h.resolveFilePath(img.ImageURL) + if filePath == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + c.Header("Cache-Control", "private, max-age=3600") + c.File(filePath) +} + +// ServeCompletionImage serves a task completion image with access control +// GET /api/media/completion-image/:id +func (h *MediaHandler) ServeCompletionImage(c *gin.Context) { + user := c.MustGet(middleware.AuthUserKey).(*models.User) + + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + // Get completion image + img, err := h.taskRepo.FindCompletionImageByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"}) + return + } + + // Get the completion to get the task + completion, err := h.taskRepo.FindCompletionByID(img.CompletionID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"}) + return + } + + // Get task to check residence access + task, err := h.taskRepo.FindByID(completion.TaskID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) + return + } + + // Check access to residence + hasAccess, err := h.residenceRepo.HasAccess(task.ResidenceID, user.ID) + if err != nil || !hasAccess { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + // Serve the file + filePath := h.resolveFilePath(img.ImageURL) + if filePath == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + c.Header("Cache-Control", "private, max-age=3600") + c.File(filePath) +} + +// resolveFilePath converts a stored URL to an actual file path +func (h *MediaHandler) resolveFilePath(storedURL string) string { + if storedURL == "" { + return "" + } + + uploadDir := h.storageSvc.GetUploadDir() + + // Handle legacy /uploads/... URLs + if strings.HasPrefix(storedURL, "/uploads/") { + relativePath := strings.TrimPrefix(storedURL, "/uploads/") + return filepath.Join(uploadDir, relativePath) + } + + // Handle relative paths (new format) + return filepath.Join(uploadDir, storedURL) +} diff --git a/internal/repositories/document_repo.go b/internal/repositories/document_repo.go index 8be4d8c..9c1e529 100644 --- a/internal/repositories/document_repo.go +++ b/internal/repositories/document_repo.go @@ -143,3 +143,13 @@ func (r *DocumentRepository) DeleteDocumentImage(id uint) error { func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error { return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentImage{}).Error } + +// FindImageByID finds a document image by ID +func (r *DocumentRepository) FindImageByID(id uint) (*models.DocumentImage, error) { + var image models.DocumentImage + err := r.db.First(&image, id).Error + if err != nil { + return nil, err + } + return &image, nil +} diff --git a/internal/repositories/residence_repo.go b/internal/repositories/residence_repo.go index e997121..ffce5ad 100644 --- a/internal/repositories/residence_repo.go +++ b/internal/repositories/residence_repo.go @@ -317,6 +317,8 @@ func (r *ResidenceRepository) GetTasksForReport(residenceID uint) ([]models.Task Preload("Priority"). Preload("Status"). Preload("Completions"). + Preload("Completions.Images"). + Preload("Completions.CompletedBy"). Where("residence_id = ?", residenceID). Order("due_date ASC NULLS LAST, created_at DESC"). Find(&tasks).Error diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 37bd453..cd2b50c 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -31,6 +31,8 @@ func (r *TaskRepository) FindByID(id uint) (*models.Task, error) { Preload("Status"). Preload("Frequency"). Preload("Completions"). + Preload("Completions.Images"). + Preload("Completions.CompletedBy"). First(&task, id).Error if err != nil { return nil, err @@ -48,6 +50,8 @@ func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error Preload("Status"). Preload("Frequency"). Preload("Completions"). + Preload("Completions.Images"). + Preload("Completions.CompletedBy"). Where("residence_id = ?", residenceID). Order("due_date ASC NULLS LAST, created_at DESC"). Find(&tasks).Error @@ -65,6 +69,8 @@ func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models. Preload("Status"). Preload("Frequency"). Preload("Completions"). + Preload("Completions.Images"). + Preload("Completions.CompletedBy"). Where("residence_id IN ?", residenceIDs). Order("due_date ASC NULLS LAST, created_at DESC"). Find(&tasks).Error @@ -135,6 +141,8 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo Preload("Status"). Preload("Frequency"). Preload("Completions"). + Preload("Completions.Images"). + Preload("Completions.CompletedBy"). Where("residence_id = ? AND is_archived = ?", residenceID, false). Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC"). Find(&tasks).Error @@ -259,6 +267,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, Preload("Status"). Preload("Frequency"). Preload("Completions"). + Preload("Completions.Images"). + Preload("Completions.CompletedBy"). Preload("Residence"). Where("residence_id IN ? AND is_archived = ?", residenceIDs, false). Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC"). @@ -484,6 +494,16 @@ func (r *TaskRepository) DeleteCompletionImage(id uint) error { return r.db.Delete(&models.TaskCompletionImage{}, id).Error } +// FindCompletionImageByID finds a completion image by ID +func (r *TaskRepository) FindCompletionImageByID(id uint) (*models.TaskCompletionImage, error) { + var image models.TaskCompletionImage + err := r.db.First(&image, id).Error + if err != nil { + return nil, err + } + return &image, nil +} + // TaskStatistics represents aggregated task statistics type TaskStatistics struct { TotalTasks int diff --git a/internal/router/router.go b/internal/router/router.go index ec5a09f..34b66a0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -54,10 +54,12 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Health check endpoint (no auth required) r.GET("/api/health/", healthCheck) - // Serve static files from uploads directory - if cfg.Storage.UploadDir != "" { - r.Static("/uploads", cfg.Storage.UploadDir) - } + // NOTE: Public static file serving removed for security. + // All uploaded media is now served through authenticated proxy endpoints: + // - GET /api/media/document/:id + // - GET /api/media/document-image/:id + // - GET /api/media/completion-image/:id + // These endpoints verify the user has access to the residence before serving files. // Initialize repositories userRepo := repositories.NewUserRepository(deps.DB) @@ -104,8 +106,10 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Initialize upload handler (if storage service is available) var uploadHandler *handlers.UploadHandler + var mediaHandler *handlers.MediaHandler if deps.StorageService != nil { uploadHandler = handlers.NewUploadHandler(deps.StorageService) + mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService) } // Set up admin routes (separate auth system) @@ -141,6 +145,11 @@ func SetupRouter(deps *Dependencies) *gin.Engine { if uploadHandler != nil { setupUploadRoutes(protected, uploadHandler) } + + // Media routes (authenticated media serving) + if mediaHandler != nil { + setupMediaRoutes(protected, mediaHandler) + } } } @@ -354,3 +363,13 @@ func setupUploadRoutes(api *gin.RouterGroup, uploadHandler *handlers.UploadHandl uploads.DELETE("/", uploadHandler.DeleteFile) } } + +// setupMediaRoutes configures authenticated media serving routes +func setupMediaRoutes(api *gin.RouterGroup, mediaHandler *handlers.MediaHandler) { + media := api.Group("/media") + { + media.GET("/document/:id", mediaHandler.ServeDocument) + media.GET("/document-image/:id", mediaHandler.ServeDocumentImage) + media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage) + } +}