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>
After LOGIN_MAX_FAILURES consecutive failed attempts from the same source
IP within LOGIN_WINDOW_SECONDS, POST /api/v1/auth/token returns HTTP 429
with a Retry-After header for LOGIN_COOLDOWN_SECONDS. A successful login
resets the counter. Trusted upstream proxy IPs/CIDRs can be declared via
LOGIN_TRUSTED_PROXY_IPS so X-Forwarded-For is honoured correctly behind
nginx ingress or similar reverse proxies.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extends GET /api/v1/tags with sort=count_desc and min_count query params
- New TagsComponent at /tags (public, no auth guard) shows all tags sorted by image count
- Clicking a tag navigates to /?tags=<name> for a pre-filtered library view
- LibraryComponent reads ?tags= query param on init to support deep-linking from tag browser
- Library header gains a "Browse tags" link to /tags for discoverability
- All 15 TDD tasks complete; ruff, ng lint, and ng build clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Pillow dependency and thumbnail.py with generate_thumbnail() — produces
WebP ≤400px, preserves aspect ratio, never upscales, handles GIF frame 0
- Alembic migration 002 adds nullable thumbnail_key column to images table
- Upload route generates thumbnail via asyncio.to_thread (non-blocking),
stores at {hash}-thumb; failure is tolerated and upload succeeds with null key
- New GET /api/v1/images/{id}/thumbnail endpoint: serves WebP thumbnail or
falls back to original for pre-feature images; ETag + immutable cache headers
- Delete route cleans up thumbnail storage object alongside original
- Library grid switches from /file to /thumbnail for all image src bindings
- 59 tests passing (46 existing + 13 new across unit, upload, serving, delete)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the presigned-URL redirect (302) in GET /api/v1/images/{id}/file
with a direct proxy that fetches bytes from S3 server-side and returns them
to the client. The browser never contacts the storage backend, eliminating
the /etc/hosts workaround needed in local development.
- StorageBackend: swap get_presigned_url for get(key) -> bytes
- S3StorageBackend: implement get() via aiobotocore get_object
- serve_image_file: return Response with ETag + Cache-Control: immutable
- test_serving: assert 200 + content-type + ETag; add no-storage-details test
- Spec Kit artifacts for feature 002-api-image-proxy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Build fixes:
- ui/Dockerfile: npm install instead of npm ci (no lockfile)
- api/pyproject.toml: setuptools.build_meta instead of setuptools.backends.legacy:build
- api/Dockerfile: install curl so the Docker healthcheck doesn't always fail
- docker-compose.yml: add start_period: 30s to API healthcheck
Test fixes:
- pyproject.toml: asyncio_default_fixture_loop_scope/test_loop_scope = session to
prevent asyncpg connections being used across different event loops
- conftest.py: loop_scope="session" on session-scoped engine fixture
- main.py: custom HTTPException handler to flatten dict details to top level
(FastAPI wraps dict details as {"detail": {...}} by default)
- test_upload.py: use env var + cache_clear() to override max_upload_bytes since
monkeypatch can't reach past @lru_cache and already-imported references
- image_repo.py: add reload_with_tags() with populate_existing=True to force
SQLAlchemy to repopulate the identity-map object after tag mutations
- images.py: use reload_with_tags() instead of db.refresh(image, ["image_tags"])
which only loaded ImageTag rows without their .tag sub-relationship, causing
MissingGreenlet on any access to image.tags after attach/replace operations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>