Files
honeyDueAPI/docs/Secure Media Access Control.md
Trey t 3419b66097 Add landing page, redesign emails, and return updated task on completion
- Integrate landing page into Go app (served at root /)
- Add STATIC_DIR config for static file serving
- Redesign all email templates with modern dark theme styling
- Add app icon to email headers
- Return updated task with kanban_column in completion response
- Update task DTO to include kanban column for client-side state updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:33:17 -06:00

10 KiB

# Secure Media Access Control Plan

Problem Statement

Users upload private images and documents (task completion photos, warranties, documents) that may contain sensitive information. Currently, these files are publicly accessible via predictable URLs (/uploads/{category}/{timestamp}_{uuid}.{ext}). Anyone who knows or guesses a URL can access the file without authentication.

Goal: Ensure only users with access to a residence can view media files associated with that residence.

User Decisions

Decision Choice
Security Method Token-based proxy endpoint
Protected Media All uploaded media (completions, documents, warranties)

Current Architecture (Problem)

Mobile App                          Go API                         Disk
    │                                  │                             │
    ├─→ POST /api/documents/ ─────────→│ Save to /uploads/docs/     │
    │   (authenticated)                │────────────────────────────→│
    │                                  │                             │
    │←─ { file_url: "/uploads/..." } ←─│                             │
    │                                  │                             │
    ├─→ GET /uploads/docs/file.jpg ───→│ Static middleware          │
    │   (NO AUTH CHECK!)               │←───────────────────────────→│
    │←─ file bytes ←──────────────────←│                             │

Issues:

  1. r.Static("/uploads", cfg.Storage.UploadDir) serves files without authentication
  2. File URLs are predictable (timestamp + short UUID)
  3. URLs can be shared/leaked, granting permanent access
  4. No audit trail for file access

Architecture

Mobile App                          Go API                         Disk
    │                                  │                             │
    ├─→ POST /api/documents/ ─────────→│ Save to /uploads/docs/     │
    │   (authenticated)                │────────────────────────────→│
    │                                  │                             │
    │←─ { file_url: "/api/media/..." }←│ Return proxy URL           │
    │                                  │                             │
    ├─→ GET /api/media/{type}/{id}────→│ Media Handler:             │
    │   Authorization: Token xxx       │  1. Verify token            │
    │                                  │  2. Get file record         │
    │                                  │  3. Check residence access  │
    │                                  │  4. Stream file             │
    │←─ file bytes ←──────────────────←│←───────────────────────────→│

Key Changes

  1. Remove public static file serving - No more r.Static("/uploads", ...)
  2. Create authenticated media endpoint - GET /api/media/{type}/{id}
  3. Update stored URLs - Store internal paths, return proxy URLs in API responses
  4. Add access control - Verify user has residence access before serving

Implementation Plan

Phase 1: Create Media Handler

New File: /internal/handlers/media_handler.go

type MediaHandler struct {
    documentRepo   repositories.DocumentRepository
    taskRepo       repositories.TaskRepository
    residenceRepo  repositories.ResidenceRepository
    storageSvc     *services.StorageService
}

// GET /api/media/document/{id}
// GET /api/media/document-image/{id}
// GET /api/media/completion-image/{id}
func (h *MediaHandler) ServeMedia(c *gin.Context) {
    // 1. Extract user from auth context
    user := c.MustGet(middleware.AuthUserKey).(*models.User)

    // 2. Get media type and ID from path
    mediaType := c.Param("type")  // "document", "document-image", "completion-image"
    mediaID := c.Param("id")

    // 3. Look up the file record and get residence ID
    residenceID, filePath, err := h.getFileInfo(mediaType, mediaID)

    // 4. Check residence access
    hasAccess, _ := h.residenceRepo.HasAccess(residenceID, user.ID)
    if !hasAccess {
        c.JSON(403, gin.H{"error": "Access denied"})
        return
    }

    // 5. Stream the file
    c.File(filePath)
}

Phase 2: Update Models & Responses

Change how URLs are stored and returned:

Model Current Storage New Storage API Response
Document.FileURL /uploads/docs/file.pdf docs/file.pdf (relative) /api/media/document/{id}
DocumentImage.ImageURL /uploads/images/img.jpg images/img.jpg (relative) /api/media/document-image/{id}
TaskCompletionImage.ImageURL /uploads/completions/img.jpg completions/img.jpg (relative) /api/media/completion-image/{id}

Update Response DTOs:

// /internal/dto/responses/document_response.go
type DocumentResponse struct {
    ID       uint   `json:"id"`
    // Don't expose FileURL directly
    // FileURL  string `json:"file_url"` // REMOVE
    MediaURL string `json:"media_url"` // NEW: "/api/media/document/123"
    // ...
}

type DocumentImageResponse struct {
    ID       uint   `json:"id"`
    MediaURL string `json:"media_url"` // "/api/media/document-image/456"
    Caption  string `json:"caption"`
}

Phase 3: Update Router

File: /internal/router/router.go

// REMOVE this line:
// r.Static("/uploads", cfg.Storage.UploadDir)

// ADD authenticated media routes:
media := api.Group("/media")
media.Use(middleware.AuthRequired(cfg, userService))
{
    media.GET("/document/:id", mediaHandler.ServeDocument)
    media.GET("/document-image/:id", mediaHandler.ServeDocumentImage)
    media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage)
}

Phase 4: Update Mobile Clients

iOS - Update image loading to include auth headers:

// Before: Direct URL access
AsyncImage(url: URL(string: document.fileUrl))

// After: Use authenticated request
AuthenticatedImage(url: document.mediaUrl)

// New component that adds auth header
struct AuthenticatedImage: View {
    let url: String

    var body: some View {
        // Use URLSession with auth header, or
        // use a library like Kingfisher with request modifier
    }
}

KMM - Update Ktor client for image requests:

// Add auth header to image requests
suspend fun fetchMedia(mediaUrl: String): ByteArray {
    val token = TokenStorage.getToken()
    return httpClient.get(mediaUrl) {
        header("Authorization", "Token $token")
    }.body()
}

Files to Modify

Go API (myCribAPI-go)

File Change
/internal/handlers/media_handler.go NEW - Authenticated media serving
/internal/router/router.go Remove static serving, add media routes
/internal/dto/responses/document_response.go Add MediaURL field, remove direct file URL
/internal/dto/responses/task_response.go Add MediaURL to completion images
/internal/services/document_service.go Update to generate proxy URLs
/internal/services/task_service.go Update to generate proxy URLs for completions

iOS (MyCribKMM/iosApp)

File Change
iosApp/Components/AuthenticatedImage.swift NEW - Image view with auth headers
iosApp/Task/TaskCompletionDetailView.swift Use AuthenticatedImage
iosApp/Documents/DocumentDetailView.swift Use AuthenticatedImage

KMM/Android

File Change
network/MediaApi.kt NEW - Authenticated media fetching
ui/components/AuthenticatedImage.kt NEW - Compose image with auth

Migration Strategy

For Existing Data

Option A: Update URLs in database (Recommended)

-- Strip /uploads prefix from existing URLs
UPDATE documents SET file_url = REPLACE(file_url, '/uploads/', '') WHERE file_url LIKE '/uploads/%';
UPDATE document_images SET image_url = REPLACE(image_url, '/uploads/', '') WHERE image_url LIKE '/uploads/%';
UPDATE task_completion_images SET image_url = REPLACE(image_url, '/uploads/', '') WHERE image_url LIKE '/uploads/%';

Option B: Handle both formats in code (Temporary)

func (h *MediaHandler) getFilePath(storedURL string) string {
    // Handle legacy /uploads/... URLs
    if strings.HasPrefix(storedURL, "/uploads/") {
        return filepath.Join(h.uploadDir, strings.TrimPrefix(storedURL, "/uploads/"))
    }
    // Handle new relative paths
    return filepath.Join(h.uploadDir, storedURL)
}

Security Considerations

Pros of This Approach

  • ✓ All media access requires valid auth token
  • ✓ Access control tied to residence membership
  • ✓ No URL guessing possible (URLs are opaque IDs)
  • ✓ Can add audit logging easily
  • ✓ Can add rate limiting per user

Cons / Trade-offs

  • ✗ Every image request hits the API (increased server load)
  • ✗ Can't use CDN caching for images
  • ✗ Mobile apps need to handle auth for image loading

Mitigations

  1. Add caching headers - Cache-Control: private, max-age=3600
  2. Consider short-lived signed URLs later if performance becomes an issue
  3. Add response compression for images if not already enabled

Implementation Order

  1. Create MediaHandler with access control logic
  2. Update router - add media routes, keep static for now
  3. Update response DTOs - add MediaURL fields
  4. Update services - generate proxy URLs
  5. Test with Postman - verify access control works
  6. Update iOS - AuthenticatedImage component
  7. Update Android/KMM - AuthenticatedImage component
  8. Run migration - update existing URLs in database
  9. Remove static serving - final step after clients updated
  10. Add audit logging (optional)