# Implementation Plan: CDN Image Serving

**Branch**: `014-r2-cdn-serving` | **Date**: 2026-05-08 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/014-r2-cdn-serving/spec.md`

## Summary

Extend the image metadata API response to include `file_url` and `thumbnail_url` fields. When `S3_PUBLIC_BASE_URL` is configured, these fields contain CDN URLs pointing directly to Cloudflare R2. When unconfigured, they fall back to the existing API proxy paths so local development requires no setup changes. The UI is updated to use these response fields instead of constructing proxy URLs client-side. Proxy endpoints are retained unchanged.

## Technical Context

**Language/Version**: Python 3.12 (API), TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Angular (latest stable), pydantic-settings
**Storage**: PostgreSQL (image metadata), S3-compatible object storage (R2 in production, MinIO in dev)
**Testing**: pytest (unit + integration), Angular component tests
**Target Platform**: Linux (k3s), local Docker Compose
**Project Type**: Web service (API) + SPA (UI)
**Performance Goals**: No additional latency on API responses; image load latency reduced by eliminating API proxy hop in production
**Constraints**: No breaking changes to existing API response fields; proxy endpoints must remain functional
**Scale/Scope**: Single-owner app; ~100 existing images migrated to R2 prior to this feature

## Constitution Check

| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation of concerns | PASS | URL construction stays in router layer; storage backend unchanged |
| §2.3 Storage abstraction | PASS | No changes to `StorageBackend` interface or `S3StorageBackend` |
| §2.6 No speculative abstraction | PASS | No new interfaces introduced; URL logic is a simple helper |
| §3.1 API versioning (`/api/v1/`) | PASS | Adding fields to response is non-breaking per §3.1 |
| §3.2 OpenAPI as contract | PASS | New fields documented in contracts/image-response.md |
| §5.1 Tests alongside implementation | REQUIRED | Unit tests for URL construction; integration tests for response fields |
| §7.2 Environment configuration | PASS | `S3_PUBLIC_BASE_URL` via env var; no hardcoded URLs |

No constitution violations. All gates pass.

## Project Structure

### Documentation (this feature)

```text
specs/014-r2-cdn-serving/
├── plan.md                     # This file
├── research.md                 # Technical decisions
├── contracts/
│   └── image-response.md       # Updated image response schema
├── quickstart.md               # Integration test scenarios
└── tasks.md                    # Phase 2 output (speckit-tasks)
```

### Source Code Changes

```text
api/
├── app/
│   ├── config.py               # Add: s3_public_base_url: str | None = None
│   └── routers/
│       └── images.py           # Update: _image_to_dict gains cdn_base param;
│                               #         add file_url + thumbnail_url to response;
│                               #         pass cdn_base from get_settings() at endpoint level
├── tests/
│   ├── unit/
│   │   └── test_url_construction.py   # New: pure unit tests for URL logic
│   └── integration/
│       └── test_images.py      # Update: assert file_url + thumbnail_url present in responses

ui/src/app/
├── services/
│   └── image.service.ts        # Update: add file_url/thumbnail_url to ImageRecord;
│                               #         remove getFileUrl()/getThumbnailUrl() methods
├── library/
│   └── library.component.ts    # Update: use img.thumbnail_url instead of getThumbnailUrl(img.id)
└── detail/
    └── detail.component.ts     # Update: use img.file_url instead of getFileUrl(img.id)

.env.example                    # Add: S3_PUBLIC_BASE_URL= (empty = local dev proxy fallback)
```

## Key Implementation Details

### URL construction logic (`api/app/routers/images.py`)

`_image_to_dict` gains a `cdn_base: str | None` parameter:

```python
def _image_to_dict(image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None):
    base = cdn_base.rstrip("/") if cdn_base else None
    file_url = f"{base}/{image.storage_key}" if base else f"/api/v1/images/{image.id}/file"
    thumbnail_url = (
        (f"{base}/{image.thumbnail_key}" if base else f"/api/v1/images/{image.id}/thumbnail")
        if image.thumbnail_key else None
    )
    return {
        ...,          # existing fields unchanged
        "file_url": file_url,
        "thumbnail_url": thumbnail_url,
    }
```

Each endpoint calls `get_settings()` once and passes `settings.s3_public_base_url` as `cdn_base`.

### Config addition (`api/app/config.py`)

```python
s3_public_base_url: str | None = None
```

No validator needed — `None` is the valid "not configured" state.

### UI changes (`ui/src/app/services/image.service.ts`)

`ImageRecord` gains two new fields:
```typescript
file_url: string;
thumbnail_url: string | null;
```

`getFileUrl(id)` and `getThumbnailUrl(id)` methods are removed. Components use `image.file_url` and `image.thumbnail_url` directly.

## Phase Breakdown

### Phase 1: API — config + URL construction (US1 foundation)
- Add `s3_public_base_url` to config
- Update `_image_to_dict` with `cdn_base` parameter
- Update all call sites to pass `cdn_base` from settings
- Unit tests for URL construction (both CDN and fallback paths)
- Integration tests verifying `file_url`/`thumbnail_url` in all image responses

### Phase 2: UI — consume response URLs (US1 + US2)
- Update `ImageRecord` interface
- Remove `getFileUrl`/`getThumbnailUrl` methods from service
- Update library component
- Update detail component
- Update service tests

### Phase 3: Config + docs
- Add `S3_PUBLIC_BASE_URL` to `.env.example`
- Manual end-to-end verification (local dev + production)
