    # 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

---

## Recommended Solution: Authenticated Media Proxy

### 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`**

```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:**

```go
// /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`**

```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:**

```swift
// 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:**

```kotlin
// 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)
```sql
-- 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)
```go
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)
