- Add MediaHandler with token-based proxy endpoints for serving media: - GET /api/media/document/:id - GET /api/media/document-image/:id - GET /api/media/completion-image/:id - Add MediaURL fields to response DTOs for documents and task completions - Add FindImageByID and FindCompletionImageByID repository methods - Preload Completions.Images in all task queries for proper media URLs - Remove public /uploads static file serving for security - Verify residence access before serving any media files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
187 lines
5.0 KiB
Go
187 lines
5.0 KiB
Go
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)
|
|
}
|