Add secure media access control with authenticated proxy endpoints
- 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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package responses
|
package responses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
@@ -20,6 +21,7 @@ type DocumentUserResponse struct {
|
|||||||
type DocumentImageResponse struct {
|
type DocumentImageResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
|
MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/document-image/{id}
|
||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ type DocumentResponse struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DocumentType models.DocumentType `json:"document_type"`
|
DocumentType models.DocumentType `json:"document_type"`
|
||||||
FileURL string `json:"file_url"`
|
FileURL string `json:"file_url"`
|
||||||
|
MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/document/{id}
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
FileSize *int64 `json:"file_size"`
|
FileSize *int64 `json:"file_size"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
@@ -78,6 +81,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
|||||||
Description: d.Description,
|
Description: d.Description,
|
||||||
DocumentType: d.DocumentType,
|
DocumentType: d.DocumentType,
|
||||||
FileURL: d.FileURL,
|
FileURL: d.FileURL,
|
||||||
|
MediaURL: fmt.Sprintf("/api/media/document/%d", d.ID), // Authenticated endpoint
|
||||||
FileName: d.FileName,
|
FileName: d.FileName,
|
||||||
FileSize: d.FileSize,
|
FileSize: d.FileSize,
|
||||||
MimeType: d.MimeType,
|
MimeType: d.MimeType,
|
||||||
@@ -98,11 +102,12 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
|||||||
resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy)
|
resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert images
|
// Convert images with authenticated media URLs
|
||||||
for _, img := range d.Images {
|
for _, img := range d.Images {
|
||||||
resp.Images = append(resp.Images, DocumentImageResponse{
|
resp.Images = append(resp.Images, DocumentImageResponse{
|
||||||
ID: img.ID,
|
ID: img.ID,
|
||||||
ImageURL: img.ImageURL,
|
ImageURL: img.ImageURL,
|
||||||
|
MediaURL: fmt.Sprintf("/api/media/document-image/%d", img.ID), // Authenticated endpoint
|
||||||
Caption: img.Caption,
|
Caption: img.Caption,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type TaskUserResponse struct {
|
|||||||
type TaskCompletionImageResponse struct {
|
type TaskCompletionImageResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
|
MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/completion-image/{id}
|
||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,11 +214,12 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
|
|||||||
if c.CompletedBy.ID != 0 {
|
if c.CompletedBy.ID != 0 {
|
||||||
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
|
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
|
||||||
}
|
}
|
||||||
// Convert images
|
// Convert images with authenticated media URLs
|
||||||
for _, img := range c.Images {
|
for _, img := range c.Images {
|
||||||
resp.Images = append(resp.Images, TaskCompletionImageResponse{
|
resp.Images = append(resp.Images, TaskCompletionImageResponse{
|
||||||
ID: img.ID,
|
ID: img.ID,
|
||||||
ImageURL: img.ImageURL,
|
ImageURL: img.ImageURL,
|
||||||
|
MediaURL: fmt.Sprintf("/api/media/completion-image/%d", img.ID), // Authenticated endpoint
|
||||||
Caption: img.Caption,
|
Caption: img.Caption,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
186
internal/handlers/media_handler.go
Normal file
186
internal/handlers/media_handler.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -143,3 +143,13 @@ func (r *DocumentRepository) DeleteDocumentImage(id uint) error {
|
|||||||
func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error {
|
func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error {
|
||||||
return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentImage{}).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
|
||||||
|
}
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ func (r *ResidenceRepository) GetTasksForReport(residenceID uint) ([]models.Task
|
|||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
Preload("Status").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
|
Preload("Completions.Images").
|
||||||
|
Preload("Completions.CompletedBy").
|
||||||
Where("residence_id = ?", residenceID).
|
Where("residence_id = ?", residenceID).
|
||||||
Order("due_date ASC NULLS LAST, created_at DESC").
|
Order("due_date ASC NULLS LAST, created_at DESC").
|
||||||
Find(&tasks).Error
|
Find(&tasks).Error
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
|
|||||||
Preload("Status").
|
Preload("Status").
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
|
Preload("Completions.Images").
|
||||||
|
Preload("Completions.CompletedBy").
|
||||||
First(&task, id).Error
|
First(&task, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -48,6 +50,8 @@ func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error
|
|||||||
Preload("Status").
|
Preload("Status").
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
|
Preload("Completions.Images").
|
||||||
|
Preload("Completions.CompletedBy").
|
||||||
Where("residence_id = ?", residenceID).
|
Where("residence_id = ?", residenceID).
|
||||||
Order("due_date ASC NULLS LAST, created_at DESC").
|
Order("due_date ASC NULLS LAST, created_at DESC").
|
||||||
Find(&tasks).Error
|
Find(&tasks).Error
|
||||||
@@ -65,6 +69,8 @@ func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.
|
|||||||
Preload("Status").
|
Preload("Status").
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
|
Preload("Completions.Images").
|
||||||
|
Preload("Completions.CompletedBy").
|
||||||
Where("residence_id IN ?", residenceIDs).
|
Where("residence_id IN ?", residenceIDs).
|
||||||
Order("due_date ASC NULLS LAST, created_at DESC").
|
Order("due_date ASC NULLS LAST, created_at DESC").
|
||||||
Find(&tasks).Error
|
Find(&tasks).Error
|
||||||
@@ -135,6 +141,8 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
|
|||||||
Preload("Status").
|
Preload("Status").
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
|
Preload("Completions.Images").
|
||||||
|
Preload("Completions.CompletedBy").
|
||||||
Where("residence_id = ? AND is_archived = ?", residenceID, false).
|
Where("residence_id = ? AND is_archived = ?", residenceID, false).
|
||||||
Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC").
|
Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC").
|
||||||
Find(&tasks).Error
|
Find(&tasks).Error
|
||||||
@@ -259,6 +267,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
|||||||
Preload("Status").
|
Preload("Status").
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
|
Preload("Completions.Images").
|
||||||
|
Preload("Completions.CompletedBy").
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
|
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
|
||||||
Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC").
|
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
|
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
|
// TaskStatistics represents aggregated task statistics
|
||||||
type TaskStatistics struct {
|
type TaskStatistics struct {
|
||||||
TotalTasks int
|
TotalTasks int
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
// Health check endpoint (no auth required)
|
// Health check endpoint (no auth required)
|
||||||
r.GET("/api/health/", healthCheck)
|
r.GET("/api/health/", healthCheck)
|
||||||
|
|
||||||
// Serve static files from uploads directory
|
// NOTE: Public static file serving removed for security.
|
||||||
if cfg.Storage.UploadDir != "" {
|
// All uploaded media is now served through authenticated proxy endpoints:
|
||||||
r.Static("/uploads", cfg.Storage.UploadDir)
|
// - 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
|
// Initialize repositories
|
||||||
userRepo := repositories.NewUserRepository(deps.DB)
|
userRepo := repositories.NewUserRepository(deps.DB)
|
||||||
@@ -104,8 +106,10 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
|
|
||||||
// Initialize upload handler (if storage service is available)
|
// Initialize upload handler (if storage service is available)
|
||||||
var uploadHandler *handlers.UploadHandler
|
var uploadHandler *handlers.UploadHandler
|
||||||
|
var mediaHandler *handlers.MediaHandler
|
||||||
if deps.StorageService != nil {
|
if deps.StorageService != nil {
|
||||||
uploadHandler = handlers.NewUploadHandler(deps.StorageService)
|
uploadHandler = handlers.NewUploadHandler(deps.StorageService)
|
||||||
|
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up admin routes (separate auth system)
|
// Set up admin routes (separate auth system)
|
||||||
@@ -141,6 +145,11 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
if uploadHandler != nil {
|
if uploadHandler != nil {
|
||||||
setupUploadRoutes(protected, uploadHandler)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user