Files
agatha aaacfae653 Feat: Serve images directly from Cloudflare R2 CDN
API responses now include file_url and thumbnail_url fields. When
S3_PUBLIC_BASE_URL is configured, these point to the CDN domain;
when unset, they fall back to the existing API proxy paths so local
dev requires no additional setup. UI updated to use response URL
fields directly instead of constructing proxy URLs client-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:17:22 +00:00

5.9 KiB

Implementation Plan: CDN Image Serving

Branch: 014-r2-cdn-serving | Date: 2026-05-08 | Spec: 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)

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

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:

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)

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:

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)