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