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>
This commit is contained in:
281
docs/Secure Media Access Control.md
Normal file
281
docs/Secure Media Access Control.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# 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)
|
||||
@@ -6,16 +6,16 @@ This document describes how tasks are categorized into kanban columns for displa
|
||||
|
||||
Tasks are organized into 6 kanban columns based on their state and due date. The categorization logic is implemented in `internal/repositories/task_repo.go` in the `GetKanbanData` and `GetKanbanDataForMultipleResidences` functions.
|
||||
|
||||
## Columns
|
||||
## Columns Summary
|
||||
|
||||
| Column | Name | Color | Description |
|
||||
|--------|------|-------|-------------|
|
||||
| 1 | **Overdue** | `#FF3B30` (Red) | Tasks past their due date |
|
||||
| 2 | **Due Soon** | `#FF9500` (Orange) | Tasks due within the threshold (default 30 days) |
|
||||
| 3 | **Upcoming** | `#007AFF` (Blue) | Tasks due beyond the threshold or with no due date |
|
||||
| 4 | **In Progress** | `#5856D6` (Purple) | Tasks with status "In Progress" |
|
||||
| 5 | **Completed** | `#34C759` (Green) | Tasks with at least one completion record |
|
||||
| 6 | **Cancelled** | `#8E8E93` (Gray) | Tasks marked as cancelled |
|
||||
| Column | Name | Color | Button Types | Description |
|
||||
|--------|------|-------|--------------|-------------|
|
||||
| 1 | **Overdue** | `#FF3B30` (Red) | edit, complete, cancel, mark_in_progress | Tasks past their due date |
|
||||
| 2 | **Due Soon** | `#FF9500` (Orange) | edit, complete, cancel, mark_in_progress | Tasks due within the threshold (default 30 days) |
|
||||
| 3 | **Upcoming** | `#007AFF` (Blue) | edit, complete, cancel, mark_in_progress | Tasks due beyond the threshold or with no due date |
|
||||
| 4 | **In Progress** | `#5856D6` (Purple) | edit, complete, cancel | Tasks with status "In Progress" |
|
||||
| 5 | **Completed** | `#34C759` (Green) | view | Tasks with at least one completion record |
|
||||
| 6 | **Cancelled** | `#8E8E93` (Gray) | uncancel, delete | Tasks marked as cancelled |
|
||||
|
||||
## Categorization Flow
|
||||
|
||||
@@ -86,7 +86,8 @@ if task.IsCancelled {
|
||||
}
|
||||
```
|
||||
- **Condition**: `is_cancelled = true`
|
||||
- **Actions Available**: `uncancel`, `delete`
|
||||
- **Button Types**: `uncancel`, `delete`
|
||||
- **Rationale**: Cancelled tasks can be restored (uncancel) or permanently removed (delete)
|
||||
|
||||
### 2. Completed
|
||||
```go
|
||||
@@ -97,7 +98,8 @@ if len(task.Completions) > 0 {
|
||||
```
|
||||
- **Condition**: Task has at least one `TaskCompletion` record
|
||||
- **Note**: A task is considered completed based on having completion records, NOT based on status
|
||||
- **Actions Available**: `view`
|
||||
- **Button Types**: `view`
|
||||
- **Rationale**: Completed tasks are read-only for historical purposes; users can view completion details and photos
|
||||
|
||||
### 3. In Progress
|
||||
```go
|
||||
@@ -107,7 +109,8 @@ if task.Status != nil && task.Status.Name == "In Progress" {
|
||||
}
|
||||
```
|
||||
- **Condition**: Task's status name is exactly `"In Progress"`
|
||||
- **Actions Available**: `edit`, `complete`
|
||||
- **Button Types**: `edit`, `complete`, `cancel`
|
||||
- **Rationale**: In-progress tasks can be edited, marked complete, or cancelled if work is abandoned
|
||||
|
||||
### 4. Due Date Based Categories (only for tasks not cancelled, completed, or in progress)
|
||||
|
||||
@@ -118,7 +121,8 @@ if task.DueDate.Before(now) {
|
||||
}
|
||||
```
|
||||
- **Condition**: `due_date < current_time`
|
||||
- **Actions Available**: `edit`, `cancel`, `mark_in_progress`
|
||||
- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress`
|
||||
- **Rationale**: Overdue tasks need urgent attention - can complete immediately, start working on them, edit the due date, or cancel if no longer needed
|
||||
|
||||
#### Due Soon
|
||||
```go
|
||||
@@ -128,14 +132,28 @@ if task.DueDate.Before(threshold) {
|
||||
```
|
||||
- **Condition**: `current_time <= due_date < (current_time + days_threshold)`
|
||||
- **Default threshold**: 30 days
|
||||
- **Actions Available**: `edit`, `complete`, `mark_in_progress`
|
||||
- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress`
|
||||
- **Rationale**: Due soon tasks are approaching their deadline - users can complete early, start working, reschedule, or cancel
|
||||
|
||||
#### Upcoming
|
||||
```go
|
||||
upcoming = append(upcoming, task)
|
||||
```
|
||||
- **Condition**: `due_date >= (current_time + days_threshold)` OR `due_date IS NULL`
|
||||
- **Actions Available**: `edit`, `cancel`
|
||||
- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress`
|
||||
- **Rationale**: Future tasks may need to be completed early (ahead of schedule), started, rescheduled, or cancelled
|
||||
|
||||
## Button Types Reference
|
||||
|
||||
| Button Type | Action | Description |
|
||||
|-------------|--------|-------------|
|
||||
| `edit` | Update task | Modify task details (title, description, due date, category, etc.) |
|
||||
| `complete` | Create completion | Mark task as done with optional notes and photos |
|
||||
| `cancel` | Cancel task | Mark task as cancelled (soft delete, can be uncancelled) |
|
||||
| `mark_in_progress` | Start work | Change status to "In Progress" to indicate work has begun |
|
||||
| `view` | View details | View task and completion details (read-only) |
|
||||
| `uncancel` | Restore task | Restore a cancelled task to its previous state |
|
||||
| `delete` | Hard delete | Permanently remove task (only for cancelled tasks) |
|
||||
|
||||
## Column Metadata
|
||||
|
||||
@@ -145,7 +163,7 @@ Each column includes metadata for the mobile clients:
|
||||
{
|
||||
Name: "overdue_tasks", // Internal identifier
|
||||
DisplayName: "Overdue", // User-facing label
|
||||
ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, // Available actions
|
||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||
Icons: map[string]string{ // Platform-specific icons
|
||||
"ios": "exclamationmark.triangle",
|
||||
"android": "Warning"
|
||||
@@ -156,6 +174,17 @@ Each column includes metadata for the mobile clients:
|
||||
}
|
||||
```
|
||||
|
||||
### Icons by Column
|
||||
|
||||
| Column | iOS Icon | Android Icon |
|
||||
|--------|----------|--------------|
|
||||
| Overdue | `exclamationmark.triangle` | `Warning` |
|
||||
| Due Soon | `clock` | `Schedule` |
|
||||
| Upcoming | `calendar` | `Event` |
|
||||
| In Progress | `hammer` | `Build` |
|
||||
| Completed | `checkmark.circle` | `CheckCircle` |
|
||||
| Cancelled | `xmark.circle` | `Cancel` |
|
||||
|
||||
## Days Threshold Parameter
|
||||
|
||||
The `daysThreshold` parameter (default: 30) determines the boundary between "Due Soon" and "Upcoming":
|
||||
@@ -185,7 +214,7 @@ The following tasks are excluded from the kanban board entirely:
|
||||
{
|
||||
"name": "overdue_tasks",
|
||||
"display_name": "Overdue",
|
||||
"button_types": ["edit", "cancel", "mark_in_progress"],
|
||||
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||
"icons": {
|
||||
"ios": "exclamationmark.triangle",
|
||||
"android": "Warning"
|
||||
@@ -194,13 +223,86 @@ The following tasks are excluded from the kanban board entirely:
|
||||
"tasks": [...],
|
||||
"count": 2
|
||||
},
|
||||
// ... other columns
|
||||
{
|
||||
"name": "due_soon_tasks",
|
||||
"display_name": "Due Soon",
|
||||
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||
"icons": {
|
||||
"ios": "clock",
|
||||
"android": "Schedule"
|
||||
},
|
||||
"color": "#FF9500",
|
||||
"tasks": [...],
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"name": "upcoming_tasks",
|
||||
"display_name": "Upcoming",
|
||||
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||
"icons": {
|
||||
"ios": "calendar",
|
||||
"android": "Event"
|
||||
},
|
||||
"color": "#007AFF",
|
||||
"tasks": [...],
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"name": "in_progress_tasks",
|
||||
"display_name": "In Progress",
|
||||
"button_types": ["edit", "complete", "cancel"],
|
||||
"icons": {
|
||||
"ios": "hammer",
|
||||
"android": "Build"
|
||||
},
|
||||
"color": "#5856D6",
|
||||
"tasks": [...],
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"name": "completed_tasks",
|
||||
"display_name": "Completed",
|
||||
"button_types": ["view"],
|
||||
"icons": {
|
||||
"ios": "checkmark.circle",
|
||||
"android": "CheckCircle"
|
||||
},
|
||||
"color": "#34C759",
|
||||
"tasks": [...],
|
||||
"count": 15
|
||||
},
|
||||
{
|
||||
"name": "cancelled_tasks",
|
||||
"display_name": "Cancelled",
|
||||
"button_types": ["uncancel", "delete"],
|
||||
"icons": {
|
||||
"ios": "xmark.circle",
|
||||
"android": "Cancel"
|
||||
},
|
||||
"color": "#8E8E93",
|
||||
"tasks": [...],
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"days_threshold": 30,
|
||||
"residence_id": "123"
|
||||
}
|
||||
```
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why "In Progress" doesn't have "mark_in_progress"
|
||||
Tasks already in the "In Progress" column are already marked as in progress, so this action doesn't make sense.
|
||||
|
||||
### Why "Completed" only has "view"
|
||||
Completed tasks represent historical records. Users can view them but shouldn't be able to uncomplete or modify them. If a task was completed incorrectly, the user should delete the completion record through the completion detail view.
|
||||
|
||||
### Why "Cancelled" has "delete" but others don't
|
||||
Hard deletion is only available for cancelled tasks as a final cleanup action. For active tasks, users should cancel first (soft delete), then delete if truly needed. This prevents accidental data loss.
|
||||
|
||||
### Why all active columns have "cancel"
|
||||
Users should always be able to abandon a task they no longer need, regardless of its due date or progress state.
|
||||
|
||||
## Code Location
|
||||
|
||||
- **Repository**: `internal/repositories/task_repo.go`
|
||||
|
||||
Reference in New Issue
Block a user