81 Commits

Author SHA1 Message Date
31bcc1cc82 Fix: Update meta preview and bump manifests to v1.4.3
All checks were successful
Pipeline / UI Lint (push) Successful in 59s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m32s
Pipeline / API Integration Tests (push) Successful in 27s
Pipeline / Build & Push API Image (push) Successful in 43s
Pipeline / Build & Push UI Image (push) Successful in 54s
2026-05-15 16:15:02 -04:00
1166e8c5d3 Fix: Update meta preview images after filename refactor
All checks were successful
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m32s
Pipeline / API Integration Tests (push) Successful in 27s
Pipeline / Build & Push API Image (push) Successful in 45s
Pipeline / Build & Push UI Image (push) Successful in 52s
2026-05-12 10:41:05 -04:00
8e94c232b4 Chore: Update spec-kit and install memory-loader extension
All checks were successful
Pipeline / UI Lint (push) Successful in 59s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m34s
Pipeline / API Integration Tests (push) Successful in 28s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-11 20:55:55 +00:00
b00c52baa3 CI: Remove diagnosis step from integration test job
All checks were successful
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 20s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / API Integration Tests (push) Successful in 27s
Pipeline / Build & Push API Image (push) Successful in 2m56s
Pipeline / Build & Push UI Image (push) Successful in 3m5s
2026-05-10 19:53:14 -04:00
0dc350d534 CI: Update dummy OWNER_PASSWORD in jobs
All checks were successful
Pipeline / API Lint (push) Successful in 9s
Pipeline / API Integration Tests (push) Successful in 28s
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 18s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 19:51:46 -04:00
ac565e4b85 CI: Shrink dummy JWT secret key 2026-05-10 19:49:36 -04:00
0808e027a5 CI: Extend dummy JWT key to pass test without InsecureKeyLengthWarning
Some checks failed
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m31s
Pipeline / API Integration Tests (push) Failing after 27s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 19s
2026-05-10 19:45:59 -04:00
fc48b37ee7 CI: Add diagnosis step to integration test job
Some checks failed
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Lint (push) Successful in 8s
Pipeline / UI Tests (push) Successful in 1m34s
Pipeline / Build & Push UI Image (push) Has been skipped
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Integration Tests (push) Failing after 26s
Pipeline / Build & Push API Image (push) Has been skipped
2026-05-10 19:36:14 -04:00
026467c6db CI: Add explicit username and database to pg_isready healthcheck
Some checks failed
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / API Integration Tests (push) Failing after 43s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 19:33:16 -04:00
e852c773e7 CI: Use legacy Bitnami images for MinIO
Some checks failed
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m31s
Pipeline / API Integration Tests (push) Failing after 46s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 19:27:28 -04:00
69a4d5a084 CI: Try different approach to running PostgreSQL
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / UI Tests (push) Successful in 1m35s
Pipeline / API Unit Tests (push) Successful in 41s
Pipeline / API Lint (push) Successful in 9s
Pipeline / Build & Push API Image (push) Has been cancelled
Pipeline / Build & Push UI Image (push) Has been cancelled
Pipeline / API Integration Tests (push) Has been cancelled
2026-05-10 19:19:14 -04:00
e13a81e31e CI: Run both Postgres and MinIO with --network container:$(hostname)
The Gitea runner executes jobs inside a container. Port-mapped services
bind to the host VM's interface, not to the runner container's loopback,
so localhost:<port> is always unreachable regardless of services: config.

--network container:$(hostname) joins each service to the job container's
network namespace, making both accessible on localhost. Both DB URL and
S3 endpoint use localhost accordingly.

Also adds timeout-minutes: 15 to bound runaway jobs on cancel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:00:46 +00:00
0624795370 CI: Restore Postgres to services, use service name as hostname
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 13s
Pipeline / API Lint (push) Successful in 5s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / API Integration Tests (push) Failing after 38s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
Gitea runs jobs in containers, so service containers are networked by
name (same as GitHub Actions with container:). Postgres goes back into
services: and is addressed as 'postgres', not localhost. MinIO stays
as a manual docker run with --network container:$(hostname) since it
needs `server /data` and is addressed as localhost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:54:05 +00:00
e4a77fdea3 CI: Move Postgres to manual docker run with shared network namespace
Service containers bind ports to the host, not to localhost inside the
job container. Start both Postgres and MinIO manually with
--network container:$(hostname) so they share the job container's
network namespace and are reachable on localhost. Use docker exec for
pg_isready to avoid depending on postgresql-client in the runner image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:52:14 +00:00
22e8717e0c Chore: Exclude alembic/ from Ruff linting
Alembic scaffolds migration files from its own template which uses
pre-3.10 conventions (Union[X, Y], typing.Sequence, etc). Excluding
avoids noise on every new migration without affecting app code coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:50:05 +00:00
8a187b45b9 CI: Fix uv install and MinIO networking
Some checks failed
Pipeline / UI Lint (push) Successful in 56s
Pipeline / UI Tests (push) Successful in 1m28s
Pipeline / API Lint (push) Failing after 4s
Pipeline / API Integration Tests (push) Failing after 32s
Pipeline / API Unit Tests (push) Successful in 3m14s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
Install uv via official installer script instead of pip (pip not
available in the runner environment). Add ~/.local/bin to GITHUB_PATH
so uv is on PATH for subsequent steps.

MinIO: replace -p 9000:9000 (binds to host, unreachable from job
container) with --network container:$(hostname) which joins MinIO to
the job container's network namespace, making localhost:9000 resolve
correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:45:39 +00:00
47e8f80572 CI: Fix API jobs — drop container override, replace bitnami/minio
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Failing after 5s
Pipeline / API Lint (push) Failing after 3s
Pipeline / UI Tests (push) Successful in 1m30s
Pipeline / Build & Push API Image (push) Has been cancelled
Pipeline / Build & Push UI Image (push) Has been cancelled
Pipeline / API Integration Tests (push) Has been cancelled
Gitea Actions execs JavaScript actions (actions/checkout) inside the
job container, unlike GitHub Actions which uses the host. The uv Python
image has no Node.js, causing exit 127. Fix: drop container: from all
three API jobs and run on the default ubuntu-latest environment.

Integration tests: Postgres stays as a service container (no special
startup command needed). MinIO moved to a manual docker run step using
quay.io/minio/minio with `server /data` — the only way to pass a
startup command. Bucket created via mc binary downloaded in-step.
Service hostnames change from service-name to localhost now that there
is no explicit job container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:40:00 +00:00
ebfef1b783 Fix: Clean up lint errors introduced in test fixes
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Failing after 3s
Pipeline / API Lint (push) Failing after 2s
Pipeline / API Integration Tests (push) Failing after 0s
Pipeline / UI Tests (push) Successful in 1m28s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
- Remove unused NEVER import from detail.component.spec.ts
- Replace `null as unknown as ImageRecord` with `null as unknown as typeof MOCK_IMAGE`
  to match the narrower inferred type (thumbnail_key: null) that setup() expects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:31:50 +00:00
ed98957dfe CI: Update pipeline
Some checks failed
Pipeline / UI Lint (push) Failing after 2m2s
Pipeline / API Unit Tests (push) Failing after 8s
Pipeline / API Lint (push) Failing after 2s
Pipeline / API Integration Tests (push) Failing after 8s
Pipeline / UI Tests (push) Successful in 5m53s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 18:22:48 -04:00
c0f7954fee CI: Add Gitea Actions pipeline with tests, linting, and release builds
Five test/lint jobs run on every push to master and every PR:
- ui-test: Karma/Firefox in node:22-bullseye
- ui-lint: ESLint via ng lint
- api-unit: pytest tests/unit/ via uv in Python 3.12
- api-lint: Ruff via uvx (no dep install needed)
- api-integration: pytest tests/integration/ with Postgres 16 and bitnami/minio services

Build jobs (build-api, build-ui) run only on v* tags and are gated
behind all five test/lint jobs passing. Images pushed to $REGISTRY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:13:12 +00:00
c987827f76 Fix: Resolve 13 pre-existing UI test failures across Login, Upload, and Detail components
- LoginComponent: provide ActivatedRoute stub (component reads returnUrl query param)
- UploadComponent: add cdr.markForCheck() to handleUploadError so OnPush view updates
  when the method is called directly; fix success test to check showSuccess not toastMessage
- DetailComponent: drive not-found-card and tag-error tests through component methods
  that call markForCheck() rather than directly mutating state on OnPush components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:01:17 +00:00
6058aa6150 Chore: Bump manifests for v1.4.1 deployment 2026-05-10 14:17:10 -04:00
28113f38e6 Chore: Mark spec 018 as shipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:15:10 +00:00
d883b76c0d Chore: Track active feature pointer for spec 018
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:13:24 +00:00
0ad82e60ac Feat: Replace pagination bar with numbered page buttons and chevron controls
Adds « ‹ [1][2][3][4] › » navigation to the library. Page window
slides to keep the current page in view. Prev/next/first/last controls
are always rendered but disabled at their respective bounds. Also wires
up karmaConfig in angular.json so FirefoxHeadless is used for tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:11:18 +00:00
40ceecda76 Chore: Mark all shipped specs with SHIPPED marker file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:37:28 +00:00
fca3190eb1 Chore: Add comment to Dockerfile.prod flagging explicit directory list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:42:16 +00:00
c210978261 Chore: Revert initContainer command after successful migration 2026-05-09 20:39:22 -04:00
a61c67614f Chore: Bump manifests and add migration init container sequence 2026-05-09 20:26:51 -04:00
27425889b3 Fix: Include scripts/ in production Docker image
Dockerfile.prod explicitly listed copied directories and omitted
scripts/, so the migration script was absent from the prod image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:18:48 +00:00
61d923d5be Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:13:55 +00:00
87eb2703f5 Chore: Bump manifests for v1.3.1 2026-05-09 18:43:33 -04:00
bc0f5173c0 Feat: Substring tag search — match anywhere in tag name
Changes prefix-only LIKE to case-insensitive ILIKE with leading
wildcard so queries like "at" now match "cat", "scatter", etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:42:23 +00:00
309cfce71c Chore: Bump manifests for v1.3.0 release 2026-05-09 18:34:26 -04:00
b094389131 Fix: Await second microtask tick in copyUrl reject test
The .catch() handler on a rejected promise resolves on the second
microtask tick, not the first — one extra await Promise.resolve() is
needed before the assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:31:58 +00:00
7d49c12ce2 Feat: Add Copy URL button and reusable toast notification system
Detail page now has a "Copy URL" button that copies the image's direct
file URL to the clipboard. A toast service (BehaviorSubject-backed,
auto-dismissing after 3s) confirms success or failure. ToastComponent
is registered at the app root and available to all future features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:21:48 +00:00
443887ea93 Chore: Bump manifests for v1.2.1 2026-05-09 17:31:28 -04:00
e4bfe13072 Feat: Add gradient fade on truncated tag rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:30:18 +00:00
0a76bb03b5 Fix: Prevent partial second tag row on image cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:27:39 +00:00
8cbf1e527a Fix: React to external URL changes and cap tag-row height in library
Clicking the Reactbin home link (or any navigation to / that removes
?page=) now resets the displayed page by subscribing to queryParamMap
for post-init URL changes. Cards with many tags no longer push the
pagination bar down since the tag row is clamped to one line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:24:44 +00:00
a280d8c761 Chore: Bump manifests for v1.2.0 release 2026-05-09 17:10:03 -04:00
781be909bc Feat: Replace Load More with Previous/Next pagination in library
Page size changes from 50 to 24. Library now shows discrete page navigation
with a "Page N of M" indicator, total image count, and URL state (?page=N)
so pages are bookmarkable and the browser Back button works. Tag filter
resets to page 1. Out-of-range page params are clamped silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:08:42 +00:00
e5e1acb533 Chore: Bump manifests after adding previews 2026-05-09 16:18:50 -04:00
c9bfdaf241 Feat: Add Open Graph and Twitter Card meta tags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:17:35 +00:00
75a1449354 Chore: Bump manifests for v1.1.1 release 2026-05-09 13:55:44 -04:00
68881b30f1 Ops: Add script to test lockout with spoofed X-Forwarded-For headers 2026-05-09 13:54:49 -04:00
9021f4816a Fix: Prefer X-Real-IP over XFF[0] in get_client_ip to close spoof bypass
XFF[0] is attacker-controllable; a crafted X-Forwarded-For header could
attribute login failures to a victim IP, triggering their lockout while
the attacker accumulates none. ingress-nginx sets X-Real-IP via its
realip module using an authoritative CIDR allowlist and overwrites any
client-supplied value, making it spoof-resistant. Fallback to XFF[0]
is retained for defence in depth but now emits a warning if reached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:52:05 +00:00
35d21dafa4 Fix: Strip whitespace from S3_PUBLIC_BASE_URL before building CDN URLs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:35:22 +00:00
34d8c3848b Ops: Bump manifests for v1.1.0 release 2026-05-08 20:25:32 -04:00
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
728efeaa48 Ops: Bump manifests for v1.0.1 2026-05-08 14:49:40 -04:00
c858e47daa Feat: Add favicon and web manifest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:43:45 +00:00
9db20fdf90 Fix: Raise nginx ingress body size limit to 52m for image uploads
Default client_max_body_size of 1MB was rejecting uploads larger than 1MB
with a 413 before the request reached the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:34:08 +00:00
9b66fe1918 Docs: Update constitution to v1.4.0
Aligns principles with actual project state: soften TDD wording to allow
tests alongside implementation, replace CI gate with concrete local test
suite gate, add production infrastructure to tech stack (k3s, nginx,
Vault + VSO), and document plaintext password storage as a known gap
that must be resolved before further auth work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:01:48 +00:00
e9a2e9f014 Docs: Update example image for README.md 2026-05-08 11:54:36 -04:00
7b3d4a9257 Docs: Add comprehensive README with local dev and production deployment guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:51:32 +00:00
7c57629941 Fix: Add correct annotation to ingress 2026-05-07 18:36:24 -04:00
4fe8b19d19 Fix: Adjust Minio security context 2026-05-07 18:29:36 -04:00
e34c9f7b7f Chore: Set image pull policy 2026-05-07 18:21:43 -04:00
551ddbec3b Ops: Adjust deployment manifests for environment 2026-05-07 17:49:48 -04:00
666c32cd69 Ops: Point manifests at Juggalol container registry 2026-05-07 17:38:28 -04:00
bf27c97deb Feat: Add Kubernetes manifests for k3s production deployment
Adds complete k8s/ manifest tree: Namespace, VaultAuth + VaultStaticSecret
CRDs (VSO secret sync from Vault KV v2), API and UI Deployments and Services,
nginx Ingress with cert-manager TLS, MinIO StatefulSet with PVC and init Job,
and Alembic init container on the API Deployment for automatic schema
migrations. Includes .yamllint.yml config and validate-k8s Makefile target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:19:09 +00:00
ce279e6121 Chore: Update speckit context to feature 012
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:43:03 +00:00
b14508e4cf Chore: Rebuild api-test image before running integration tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:42:16 +00:00
602648ef56 Feat: Gate API docs endpoints behind API_DOCS_ENABLED env var
When API_DOCS_ENABLED=false, FastAPI registers no routes for /docs,
/redoc, or /openapi.json, returning 404 for all three. Default is true
for backwards compatibility. Invalid values fall back to true (FR-007).

Fix: Remove tests/ and alembic/ from api/.dockerignore so the test
Dockerfile (which uses COPY . .) includes the test suite; Dockerfile.prod
is unaffected as it only copies app/ explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:40:48 +00:00
1b3468b72d Feat: Add production-grade multi-stage container image for UI
Two-stage build (node:22-slim builder + nginxinc/nginx-unprivileged:alpine
runtime) with SPA fallback routing, long-lived cache headers for fingerprinted
assets, non-root user (UID 101), and no Node.js toolchain in runtime image
(82 MB vs 329 MB+ single-stage). Verified by ui/tests/build/verify_production_image.sh
covering build, health, SPA routing, non-root, stdout logging, cache-control
headers, SIGTERM exit 0, Node.js absent, secret-free layers, and dep-layer
cache hit. 102 integration tests still pass; shellcheck clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:18:55 +00:00
12176471e1 Feat: Add production-grade multi-stage container image for API
Two-stage build (uv builder + python:3.12-slim runtime) with non-root
user (UID 1001), no dev deps, layer-cache-optimised dep install, and
graceful SIGTERM shutdown. Verified by api/tests/build/verify_production_image.sh
covering build, health endpoint, non-root, stdout logging, secret-free
layers, missing-env-var exit, and dep-layer cache hit. All 102 integration
tests still pass; shellcheck clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:59:29 +00:00
7a835d3172 Feat: Rate-limit login endpoint to block brute-force attacks
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>
2026-05-06 21:01:37 +00:00
f3e0021ee8 Feat: Enforce PostgreSQL for integration tests; add Docker test stack
- conftest.py: pytest_configure guard rejects non-postgresql+asyncpg:// URLs
  before any test collects (per constitution §2.5/§5.2 v1.3.0)
- docker-compose.test.yml: isolated postgres-test (5433) + minio-test (9002)
  + api-test runner; one command runs the full suite against real PostgreSQL
- Makefile: test-unit and test-integration targets
- .env.test.example: documents variables needed to run tests outside Docker
- Fix pre-existing test bug: integration tests using client fixture (NoOpAuthProvider)
  for write operations (upload/delete/patch) now use authed_client with Bearer
  token — these were never caught because tests never ran against a live stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:14:12 +00:00
354c85292d Docs: Bump constitution to v1.3.0 — require PostgreSQL for integration tests
§2.5: Remove the planned PostgreSQL→SQLite refactor note; prohibit
alternative database engines in integration tests.
§5.2: Explicitly require a real PostgreSQL instance for integration
tests; ban SQLite — a GROUP BY/HAVING production bug was masked by
SQLite's permissive dialect in feature 007.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:47:57 +00:00
265b967f6b Fix: Use WHERE instead of HAVING for min_count filter in list_tags()
HAVING requires GROUP BY; count_subq is a correlated scalar subquery, not
an aggregate, so PostgreSQL rejects it. WHERE works correctly and the
integration tests used SQLite which is permissive about this rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:42:50 +00:00
355014f975 Feat: Add tag browser page at /tags with count-sorted tag list and library deep-link
- 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>
2026-05-06 18:40:06 +00:00
6092a4454e Chore: Update .gitignore 2026-05-03 16:29:57 -04:00
28df9a1261 Feat: Header title links to grid; sign-out redirects to grid
Make the app title a clickable link to / so users can return to the
image grid from any sub-page without the browser back button. Change
the sign-out destination from /login to / since the grid is publicly
accessible and avoids unnecessary friction post-logout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:14:35 +00:00
9246f75fdd Feat: Polish Angular UI with cohesive design system
Introduces a shared CSS custom property token layer and applies it
across all five views (library, upload, detail, login, app shell).
Each view now has intentional loading, empty, and error states.

- styles.css: 13 design tokens on :root; shimmer skeleton animation
- Library: 150ms-debounced skeleton loading, empty state with /upload
  link, error card with retry, card hover lift, broken-image fallback
- Upload: token-styled drop-zone, Uploading… spinner, 4s success
  banner, distinct validation vs. network error messages
- Detail: image skeleton, network error card (separate from 404
  not-found card), Owner actions panel, danger tag error styling,
  broken-image fallback
- Login: vertically centred surface card, danger field/server errors,
  Signing in… disabled button
- App shell: 48px fixed header, app name left, sign-out right, no
  reflow on auth state change
- All 24 ESLint errors resolved (including pre-existing auth spec
  issues); ng build and ng lint pass clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:03:56 +00:00
5179786261 Docs: Bump constitution to v1.2.0 — reflect JWT auth completion
Phase 2 (JWT bearer) is shipped; update §2.4 phase status, add PyJWT
to §6 tech stack table, remove username/password from §8 out-of-scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:17:18 +00:00
86961d19ee Chore: Add updated files 2026-05-03 15:13:35 -04:00
5fbbc1e67f Feat: Implement JWT bearer token authentication
Protects image upload, delete, and tag-update endpoints behind
Bearer token auth. Public read endpoints remain open. Angular SPA
gains a login page, auth interceptor, and route guard for /upload.

- JWTAuthProvider (HS256, sub/iat/exp, secrets.compare_digest)
- POST /api/v1/auth/token login endpoint
- require_auth FastAPI dependency on all write routes
- AuthService, LoginComponent, authInterceptor, authGuard
- Detail page hides write controls for unauthenticated visitors
- 43 unit tests passing; integration tests require Docker stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:12:38 +00:00
d91a65abe5 Docs: Update scope boundaries in constitution
Due to the introduction of image thumbnail generation in
cd89ba5dea, the scope boundaries in §10 of
the project constitution should be updated with a clarification.
2026-05-03 14:02:51 -04:00
ec7bf591a4 Chore: Add example image to README.md 2026-05-03 13:46:16 -04:00
f953c88984 Feat: Pre-generate WebP thumbnails on upload for faster library load
- 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>
2026-05-03 17:26:16 +00:00
255 changed files with 35325 additions and 388 deletions

View File

@@ -11,5 +11,27 @@ S3_REGION=us-east-1
# Angular SPA — injected at build or runtime
API_BASE_URL=http://localhost:8000
# CDN base URL for serving images (e.g. https://cdn.example.com).
# Leave empty in local dev to use API proxy fallback.
S3_PUBLIC_BASE_URL=
# Upload size limit in bytes (default 50 MiB)
MAX_UPLOAD_BYTES=52428800
# Owner credentials and JWT signing secret
JWT_SECRET_KEY=change-me-to-a-long-random-string
JWT_EXPIRY_SECONDS=86400
OWNER_USERNAME=owner
OWNER_PASSWORD=change-me
# Login brute-force protection
LOGIN_MAX_FAILURES=5
LOGIN_WINDOW_SECONDS=300
LOGIN_COOLDOWN_SECONDS=900
# Comma-separated IPs/CIDRs of trusted upstream proxies (e.g. nginx ingress pod CIDR).
# Leave empty when not behind a reverse proxy.
LOGIN_TRUSTED_PROXY_IPS=
# API documentation endpoints (Swagger UI, ReDoc, OpenAPI schema)
# Set to false in production to avoid exposing the API surface publicly.
API_DOCS_ENABLED=true

36
.env.test.example Normal file
View File

@@ -0,0 +1,36 @@
# Integration test environment variables
# Used when running pytest directly on the host (outside Docker).
#
# Start test services first:
# docker compose -f docker-compose.test.yml up -d postgres-test minio-test minio-init-test
#
# Then source this file and run tests:
# export $(grep -v '^#' .env.test.example | xargs)
# cd api && python -m pytest tests/integration/ -v
# PostgreSQL test database (postgres-test container on host port 5433)
TEST_DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
# MinIO test instance (minio-test container on host port 9002)
S3_ENDPOINT_URL=http://localhost:9002
S3_BUCKET_NAME=reactbin-test
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_REGION=us-east-1
# Auth (test values — not for production)
JWT_SECRET_KEY=test-secret-key-for-testing-only
OWNER_USERNAME=testowner
OWNER_PASSWORD=testpassword
# API
API_BASE_URL=http://localhost:8000
MAX_UPLOAD_BYTES=52428800
# Login brute-force protection
LOGIN_MAX_FAILURES=5
LOGIN_WINDOW_SECONDS=300
LOGIN_COOLDOWN_SECONDS=900
# Comma-separated IPs/CIDRs of trusted upstream proxies; leave empty for direct connections.
LOGIN_TRUSTED_PROXY_IPS=

View File

@@ -0,0 +1,234 @@
name: Pipeline
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
jobs:
# ── UI ────────────────────────────────────────────────────────────────────────
ui-test:
name: UI Tests
runs-on: ubuntu-latest
container:
image: node:22-bullseye
steps:
- uses: actions/checkout@v4
- name: Install Firefox
run: apt-get update -qq && apt-get install -y --no-install-recommends firefox-esr
- name: Cache node_modules
uses: actions/cache@v3
with:
path: ui/node_modules
key: npm-${{ hashFiles('ui/package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
working-directory: ui
- name: Run tests
run: FIREFOX_BIN=/usr/bin/firefox-esr npx ng test --watch=false
working-directory: ui
ui-lint:
name: UI Lint
runs-on: ubuntu-latest
container:
image: node:22-bullseye
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
uses: actions/cache@v3
with:
path: ui/node_modules
key: npm-${{ hashFiles('ui/package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
working-directory: ui
- name: Run ESLint
run: npm run lint
working-directory: ui
# ── API ───────────────────────────────────────────────────────────────────────
api-unit:
name: API Unit Tests
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
steps:
- name: Install Node (for JS actions)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends nodejs ca-certificates curl
- uses: actions/checkout@v4
- name: Cache uv store
uses: actions/cache@v3
with:
path: /root/.cache/uv
key: uv-${{ hashFiles('api/uv.lock') }}
restore-keys: uv-
- name: Install dependencies
run: uv sync --group dev
working-directory: api
- name: Run unit tests
run: uv run pytest tests/unit/ -q
working-directory: api
env:
DATABASE_URL: postgresql+asyncpg://u:p@localhost/db
S3_ENDPOINT_URL: http://localhost:9000
S3_BUCKET_NAME: test
S3_ACCESS_KEY_ID: key
S3_SECRET_ACCESS_KEY: secret
S3_REGION: us-east-1
API_BASE_URL: http://localhost:8000
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpassword
api-lint:
name: API Lint
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
steps:
- name: Install Node (for JS actions)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends nodejs ca-certificates curl
- uses: actions/checkout@v4
- name: Run Ruff
run: uvx ruff check .
working-directory: api
api-integration:
name: API Integration Tests
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: reactbin
POSTGRES_PASSWORD: reactbin
POSTGRES_DB: reactbin_test
options: >-
--health-cmd "pg_isready -U reactbin -d reactbin_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
minio:
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_DEFAULT_BUCKETS: reactbin-test
options: >-
--health-cmd "mc ready local || exit 1"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Install Node and curl (for JS actions and mc)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends nodejs ca-certificates curl
- uses: actions/checkout@v4
- name: Cache uv store
uses: actions/cache@v3
with:
path: /root/.cache/uv
key: uv-${{ hashFiles('api/uv.lock') }}
restore-keys: uv-
- name: Install dependencies
run: uv sync --group dev
working-directory: api
- name: Run integration tests
run: uv run pytest tests/integration/ -q
working-directory: api
env:
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
S3_ENDPOINT_URL: http://minio:9000
S3_BUCKET_NAME: reactbin-test
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_REGION: us-east-1
API_BASE_URL: http://localhost:8000
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpassword
# ── Image builds (tag-only, gated on all jobs) ────────────────────────────────
build-api:
name: Build & Push API Image
runs-on: ubuntu-latest
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile.prod
push: true
tags: |
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:${{ github.ref_name }}
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:latest
build-ui:
name: Build & Push UI Image
runs-on: ubuntu-latest
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./ui
file: ./ui/Dockerfile.prod
push: true
tags: |
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:${{ github.ref_name }}
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:latest

6
.gitignore vendored
View File

@@ -1,7 +1,11 @@
# Developer notes
notes/
# Environment
.env
.env.*
!.env.example
!.env.test.example
# Python
__pycache__/
@@ -12,6 +16,8 @@ venv/
*.egg-info/
dist/
build/
!api/tests/build/
!ui/tests/build/
.pytest_cache/
.ruff_cache/
.coverage

BIN
.img/reactbin-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -18,6 +18,13 @@ hooks:
prompt: Execute speckit.git.feature?
description: Create feature branch before specification
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before specification
condition: null
before_clarify:
- extension: git
command: speckit.git.commit
@@ -26,6 +33,13 @@ hooks:
prompt: Commit outstanding changes before clarification?
description: Auto-commit before spec clarification
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before clarification
condition: null
before_plan:
- extension: git
command: speckit.git.commit
@@ -34,6 +48,13 @@ hooks:
prompt: Commit outstanding changes before planning?
description: Auto-commit before implementation planning
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before planning
condition: null
before_tasks:
- extension: git
command: speckit.git.commit
@@ -42,6 +63,13 @@ hooks:
prompt: Commit outstanding changes before task generation?
description: Auto-commit before task generation
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before task generation
condition: null
before_implement:
- extension: git
command: speckit.git.commit
@@ -50,6 +78,13 @@ hooks:
prompt: Commit outstanding changes before implementation?
description: Auto-commit before implementation
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before implementation
condition: null
before_checklist:
- extension: git
command: speckit.git.commit
@@ -58,6 +93,13 @@ hooks:
prompt: Commit outstanding changes before checklist?
description: Auto-commit before checklist generation
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before checklist generation
condition: null
before_analyze:
- extension: git
command: speckit.git.commit
@@ -66,6 +108,13 @@ hooks:
prompt: Commit outstanding changes before analysis?
description: Auto-commit before analysis
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before analysis
condition: null
before_taskstoissues:
- extension: git
command: speckit.git.commit

View File

@@ -18,6 +18,20 @@
},
"registered_skills": [],
"installed_at": "2026-05-02T15:15:14.534434+00:00"
},
"memory-loader": {
"version": "1.0.0",
"source": "local",
"manifest_hash": "sha256:d1caef45965accd4316d8aede0a4ac67f910017ea3c501814cfc7e2d8177ab0b",
"enabled": true,
"priority": 10,
"registered_commands": {
"claude": [
"speckit.memory-loader.load"
]
},
"registered_skills": [],
"installed_at": "2026-05-11T20:50:02.702659+00:00"
}
}
}

View File

@@ -0,0 +1,9 @@
# Changelog
## [1.0.0] - 2026-04-20
### Added
- Initial release
- `speckit.memory-loader.load` command to read all `.specify/memory/*.md` files
- `before_*` hooks for specify, plan, tasks, implement, clarify, checklist, and analyze lifecycle commands
- Graceful degradation when memory directory is missing or files are unreadable

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 KevinBrown5280
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,81 @@
# spec-kit-memory-loader
A [Spec Kit](https://github.com/github/spec-kit) extension that loads `.specify/memory/` files before spec-kit lifecycle commands so LLM agents have project governance context (constitution, glossary, conventions, resource standards).
## Problem
Spec-kit lifecycle commands (`/speckit.specify`, `/speckit.plan`, `/speckit.implement`, etc.) execute without awareness of project-specific governance documents stored in `.specify/memory/`. This means:
- Constitution principles are not consulted during specification
- Glossary terms are not available during planning
- Coding conventions are missed during implementation
- Resource standards are ignored during task generation
## Solution
The Memory Loader extension registers `before_*` hooks on all major spec-kit lifecycle commands. Before each command runs, it reads every `.md` file from `.specify/memory/` and outputs their contents, giving the LLM agent full governance context.
## Installation
```bash
# From release
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/tags/v1.0.0.zip
# From main branch
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/heads/main.zip
# Development mode (local clone)
specify extension add --dev /path/to/spec-kit-memory-loader
```
## Commands
| Command | Description | Modifies Files? |
|---------|-------------|-----------------|
| `speckit.memory-loader.load` | Read all project memory files and output their contents for context | No — read-only |
## How It Works
1. **Gather**: Reads every `.md` file from `.specify/memory/`
- If the directory does not exist, skips silently
- If a file cannot be read, skips it and continues
2. **Output**: For each file, prints a headed section:
```
## Memory: {filename}
{file contents}
```
3. **Summarize**: After all files, outputs:
```
Context loaded: {memory_count} memory files
```
## Hooks
The extension fires automatically before these lifecycle commands:
| Hook | Command | Description |
|------|---------|-------------|
| `before_specify` | `speckit.memory-loader.load` | Load context before specification |
| `before_plan` | `speckit.memory-loader.load` | Load context before planning |
| `before_tasks` | `speckit.memory-loader.load` | Load context before task generation |
| `before_implement` | `speckit.memory-loader.load` | Load context before implementation |
| `before_clarify` | `speckit.memory-loader.load` | Load context before clarification |
| `before_checklist` | `speckit.memory-loader.load` | Load context before checklist generation |
| `before_analyze` | `speckit.memory-loader.load` | Load context before analysis |
## Design Decisions
- **Read-only** — never modifies any files
- **Graceful degradation** — missing directory or unreadable files are skipped silently
- **Governance only** — loads project-level memory; feature-specific reference docs are handled separately by a companion extension
## Requirements
- Spec Kit >= 0.6.0
## License
MIT

View File

@@ -0,0 +1,33 @@
---
description: "Read all project memory files and output their contents for LLM context"
---
# Load Project Memory
Read ALL `.md` files in `.specify/memory/` and output their contents. This gives you project governance context (constitution, glossary, conventions, resource standards) for the command that follows.
## Steps
1. **Gather**: Read every `.md` file from `.specify/memory/`.
- If the directory does not exist, skip it silently.
- If a file cannot be read, skip it and continue.
2. **Output**: For each file, print a headed section:
```
## Memory: {filename}
{file contents}
```
3. **Summarize**: After all files, output:
```
Context loaded: {memory_count} memory files
```
## Usage Notes
- Designed as a mandatory `before_*` hook that fires before spec-kit lifecycle commands.
- Loads governance context only. Feature-specific reference docs are loaded by the `spec-reference-loader` extension.
- This is a read-only operation — do NOT modify any files.

View File

@@ -0,0 +1,61 @@
schema_version: "1.0"
extension:
id: "memory-loader"
name: "Memory Loader"
version: "1.0.0"
description: "Loads .specify/memory/ files before spec-kit lifecycle commands so LLM agents have project governance context"
author: "KevinBrown5280"
repository: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
license: "MIT"
homepage: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
requires:
speckit_version: ">=0.6.0"
provides:
commands:
- name: "speckit.memory-loader.load"
file: "commands/speckit.memory-loader.load.md"
description: "Read all project memory files and output their contents for context"
hooks:
before_specify:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before specification"
before_plan:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before planning"
before_tasks:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before task generation"
before_implement:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before implementation"
before_clarify:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before clarification"
before_checklist:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before checklist generation"
before_analyze:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before analysis"
tags:
- "memory"
- "context"
- "governance"

View File

@@ -1,3 +1 @@
{
"feature_directory": "specs/002-api-image-proxy"
}
{"feature_directory":"specs/018-pagination-controls"}

View File

@@ -6,5 +6,5 @@
"here": true,
"integration": "claude",
"script": "sh",
"speckit_version": "0.8.2.dev0"
"speckit_version": "0.8.8"
}

View File

@@ -1,4 +1,15 @@
{
"version": "0.8.8",
"integration_state_schema": 1,
"installed_integrations": [
"claude"
],
"integration_settings": {
"claude": {
"script": "sh",
"invoke_separator": "-"
}
},
"integration": "claude",
"version": "0.8.2.dev0"
"default_integration": "claude"
}

View File

@@ -1,16 +1,16 @@
{
"integration": "claude",
"version": "0.8.2.dev0",
"installed_at": "2026-05-02T15:15:14.461699+00:00",
"version": "0.8.8",
"installed_at": "2026-05-11T20:40:51.902830+00:00",
"files": {
".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90",
".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977",
".claude/skills/speckit-clarify/SKILL.md": "f2560f9f2007b4e995130f0c42633f08837a76a35d94e84091713a6f39bb1064",
".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
".claude/skills/speckit-implement/SKILL.md": "da9b4d6f9894d300515c66c057cee74025b27f2238895e3c22b59c6266b5be74",
".claude/skills/speckit-implement/SKILL.md": "6029565c1a56de8919d1846b187cd644f734a0e30a6067a709803e6bc0d2abf7",
".claude/skills/speckit-plan/SKILL.md": "8141ebbce228ad0b422a84e3b995d2bd85de917b96eadd02b5fcb56fb23f2594",
".claude/skills/speckit-specify/SKILL.md": "8599f8e2e3463de7d4f47591565340be2f775fd61b7dd9d2175503bc3b713b77",
".claude/skills/speckit-tasks/SKILL.md": "792589edf0ebf89af797c6bdda4e9d2c9938c696181d6f1484bf7a7cd090efaa",
".claude/skills/speckit-specify/SKILL.md": "caadc05119eca453709a0425ed88d253883f9c55da4c13a4898367653a859483",
".claude/skills/speckit-tasks/SKILL.md": "54c4665be61818ed50aa528bb4c51db3627079b2c67d47f2b01046268288c4a5",
".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b"
}
}

View File

@@ -1,6 +1,6 @@
{
"integration": "speckit",
"version": "0.8.2.dev0",
"version": "0.8.8",
"installed_at": "2026-05-02T15:15:14.478105+00:00",
"files": {
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
@@ -11,6 +11,7 @@
".specify/templates/plan-template.md": "5ad267630e370c73fe957dafa61bf76d633f3aea9d2f0b5195087d729cdd1e41",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c"
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2"
}
}

View File

@@ -1,8 +1,8 @@
<!--
SYNC IMPACT REPORT
==================
Version change: [TEMPLATE — no prior version] → 1.1.0
Ratified: 2026-05-01 | Last amended: 2026-05-02
Version change: 1.3.0 → 1.4.0
Ratified: 2026-05-01 | Last amended: 2026-05-08
Principles introduced (first population from docs/CONSTITUTION.md):
- §2 Architecture Principles (6 sub-principles)
@@ -82,22 +82,23 @@ or SDK-specific types directly — only the interface contract.
### 2.4 Auth abstraction (progressive)
Authentication is treated as a pluggable backend from day one, even though
Phase 1 ships with no auth. The API MUST route all request-identity resolution
through a single `AuthProvider` interface. The no-op provider (Phase 1) returns
a static anonymous identity. Adding username/password or OIDC in a later phase
MUST be a new provider implementation, not a rewrite of business logic.
Authentication is treated as a pluggable backend from day one. The API MUST
route all request-identity resolution through a single `AuthProvider` interface.
Each phase introduces a new provider implementation; no phase rewrites business
logic already behind the interface.
**Phase 1 implements: no-auth (localhost only).**
**Planned phases: username/password, then OIDC.**
**Phase 1 — no-auth (NoOpAuthProvider): complete.**
**Phase 2 — JWT bearer token (JWTAuthProvider, HS256, single owner): complete.**
**Phase 3 — OIDC: planned.**
The constitution acknowledges all three; the spec governs which is built.
### 2.5 Database abstraction
PostgreSQL is the Phase 1 database. All DB access MUST go through a repository
layer (one repository class per domain aggregate). Raw SQL or an ORM is
acceptable, but no query logic MAY live outside a repository. This makes the
planned PostgreSQL → SQLite refactor a repository-layer change only.
PostgreSQL is the database. All DB access MUST go through a repository layer
(one repository class per domain aggregate). Raw SQL or an ORM is acceptable,
but no query logic MAY live outside a repository. No alternative database
engine (SQLite, DuckDB, in-memory substitutes) MAY be used in integration
tests — dialect differences mask production bugs.
### 2.6 No speculative abstraction
@@ -170,17 +171,23 @@ OR/NOT logic is explicitly out of scope until the constitution is revised.
## 5. Testing Discipline
### 5.1 TDD is non-negotiable
### 5.1 Tests are required alongside every implementation task
No production code MAY be written before a failing test exists for it. This
applies to both API and UI. Tasks MUST include a "write failing test" step
before any implementation step.
Every implementation task MUST be accompanied by tests covering its behaviour.
The ideal is red-green-refactor: write a failing test, then make it pass. In
practice, tests written in the same task as the implementation are acceptable;
what is non-negotiable is that no implementation task is marked done without
corresponding test coverage. Tasks MUST NOT be split such that implementation
is complete but tests are deferred to a later task.
### 5.2 Test pyramid
- **Unit tests** — pure logic, repository mocks, no I/O
- **Integration tests** — API routes tested against a real (test) database
and a real (test) S3-compatible bucket (e.g. MinIO in Docker)
- **Integration tests** — API routes tested against a real PostgreSQL instance
and a real S3-compatible bucket (e.g. MinIO in Docker). SQLite and other
in-memory database substitutes are **prohibited** — PostgreSQL-specific
behaviour (GROUP BY enforcement, JSON operators, constraint handling) MUST
be exercised by the test suite.
- **E2E tests** — Angular + API, minimal set covering the core happy paths
Unit and integration tests are required. E2E tests are best-effort in v1.
@@ -190,25 +197,34 @@ Unit and integration tests are required. E2E tests are best-effort in v1.
API tests in `api/tests/`, UI tests colocated with their components. No
separate top-level `tests/` directory that mirrors the source tree.
### 5.4 CI must pass before any task is considered done
### 5.4 The test suite must pass before any task is considered done
"Done" means: all tests pass, linter passes, type checker passes. A task MUST
NOT be marked complete while CI is failing.
NOT be marked complete while any of these are failing.
The acceptance gate is `make test-unit && make test-integration` plus `ruff
check` / `ruff format --check` for the API. A formal CI pipeline is planned
but not yet in place; until one exists, passing the above commands locally is
the required gate. When CI is introduced it MUST enforce the same checks.
---
## 6. Tech Stack Constraints
| Concern | Choice | Rationale |
|---|---|---|
| API language | Python 3.12+ | Primary language, type hints required |
| API framework | FastAPI | Async, OpenAPI-native |
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
| DB migrations | Alembic | Schema changes tracked in version control |
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
| UI language | TypeScript strict mode | No `any`, no implicit types |
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
| Concern | Choice | Rationale |
|------------------|-------------------------------------------|-------------------------------------------|
| API language | Python 3.12+ | Primary language, type hints required |
| API framework | FastAPI | Async, OpenAPI-native |
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
| DB migrations | Alembic | Schema changes tracked in version control |
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
| Auth tokens | PyJWT (HS256) | Lightweight; compatible with OIDC migration path |
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
| UI language | TypeScript strict mode | No `any`, no implicit types |
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
| Production runtime | k3s (Kubernetes) | Manifests in `k8s/`; see deployment docs |
| Ingress | nginx ingress controller + cert-manager | TLS via Let's Encrypt (`letsencrypt-prod` ClusterIssuer) |
| Secret management | HashiCorp Vault + VSO (Vault Secrets Operator) | Secrets never committed; VSO syncs Vault KV v2 → K8s Secrets |
---
@@ -241,12 +257,20 @@ revised:
- Multi-user support
- Public sharing or embeds
- Collections or albums beyond tag-based grouping
- Image editing or transformation
- Image editing or transformation beyond thumbnail generation
- OR/NOT tag logic
- Mobile-native app
- Username/password auth (planned Phase 2)
- OIDC auth (planned Phase 3)
**Known gaps carried forward from v1** — these are not out of scope; they are
acknowledged deficiencies that MUST be resolved before the affected area is
expanded:
- **Password hashing**: The owner password is currently stored and compared in
plaintext. Hashing (bcrypt or Argon2) MUST be implemented before any
additional authentication work (e.g. OIDC, additional accounts) is started.
Specs that touch credential storage MUST address this first.
---
## 9. Governance
@@ -277,12 +301,16 @@ Phase 1 design is complete.
## 10. Revision Log
| Version | Date | Change |
|---|---|---|
| 1.0.0 | 2026-05-01 | Initial constitution |
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
| Version | Date | Change |
|---------|------------|---------------------------------------------------------------------------------------------------------------------------------|
| 1.0.0 | 2026-05-01 | Initial constitution |
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
| 1.3.0 | 2026-05-06 | §2.5: Remove planned PostgreSQL → SQLite refactor note; prohibit alternative database engines in integration tests. §5.2: Explicitly require PostgreSQL for integration tests; prohibit SQLite — a production HAVING/GROUP BY bug was masked by SQLite's permissive dialect. |
| 1.4.0 | 2026-05-08 | §5.1: Soften strict TDD wording to reflect actual practice — tests alongside implementation are acceptable; deferring tests to a later task is not. §5.4: Replace "CI must pass" with local test suite gate; note CI is planned but not yet in place. §6: Add production runtime rows (k3s, nginx ingress + cert-manager, Vault + VSO). §8: Add "known gaps" subsection; document plaintext password storage as a deficiency that must be resolved before further auth work. |
---
**Version**: 1.1.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-02
**Version**: 1.4.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-08

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -e
# Parse command line arguments
JSON_MODE=false
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
;;
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
esac
done
# Source common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
# Validate branch
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.plan first to create the implementation plan." >&2
exit 1
fi
if [[ ! -f "$FEATURE_SPEC" ]]; then
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi
# Build available docs list
docs=()
[[ -f "$RESEARCH" ]] && docs+=("research.md")
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
docs+=("contracts/")
fi
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
# Resolve tasks template through override stack
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
exit 1
fi
# Output results
if $JSON_MODE; then
if has_jq; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg tasks_template "${TASKS_TEMPLATE:-}" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
fi
else
echo "FEATURE_DIR: $FEATURE_DIR"
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
echo "AVAILABLE_DOCS:"
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
fi

View File

@@ -8,7 +8,7 @@ description: "Task list template for feature implementation"
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Per §5.1 of the constitution, TDD is non-negotiable — test tasks MUST appear before every implementation task. The test task labels below marked "OPTIONAL" refer to the *type* of test (E2E is best-effort per §5.2), not whether tests are written at all.
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
@@ -79,7 +79,7 @@ Examples of foundational tasks (adjust based on your project):
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (REQUIRED per §5.1 — TDD) ⚠️
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
@@ -105,7 +105,7 @@ Examples of foundational tasks (adjust based on your project):
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (REQUIRED per §5.1 — TDD) ⚠️
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
@@ -127,7 +127,7 @@ Examples of foundational tasks (adjust based on your project):
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (REQUIRED per §5.1 — TDD) ⚠️
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
@@ -198,7 +198,7 @@ Examples of foundational tasks (adjust based on your project):
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (TDD — write before implementation):
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"

4
.yamllint.yml Normal file
View File

@@ -0,0 +1,4 @@
extends: relaxed
rules:
line-length:
max: 120

View File

@@ -1,5 +1,4 @@
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at
`specs/002-api-image-proxy/plan.md`.
shell commands, and other important information, read the current plan
<!-- SPECKIT END -->

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod validate-k8s
test-unit:
cd api && python -m pytest tests/unit/ -v
test-integration:
docker compose -f docker-compose.test.yml build api-test
docker compose -f docker-compose.test.yml run --rm api-test
build-prod:
docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:latest
verify-prod:
bash api/tests/build/verify_production_image.sh
build-ui-prod:
docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
verify-ui-prod:
bash ui/tests/build/verify_production_image.sh
# Offline: yamllint only. Online (requires kubeconfig): kubectl apply --dry-run=client -f k8s/
validate-k8s:
yamllint -d relaxed k8s/
kubectl apply --dry-run=client -f k8s/

142
README.md
View File

@@ -1,2 +1,142 @@
# reactbin
Organize your reaction images.
_Organize your reaction images._
![Reactbin UI](.img/reactbin-ui.png)
A self-hosted reaction image board. Single owner account, tag-based browsing, S3-compatible image storage.
---
## Local development
```bash
cp .env.example .env
# Edit .env — defaults work out of the box for local dev
docker compose up
```
- UI: http://localhost:4200
- API: http://localhost:8000
- MinIO console: http://localhost:9001 (minioadmin / minioadmin)
The API serves on port 8000 directly in dev. In production the nginx ingress routes `/api/` there.
### Running tests
```bash
make test-unit # pytest unit tests (no Docker)
make test-integration # builds api-test image, runs full suite against Postgres + MinIO
```
### Production image builds
```bash
make build-prod # builds reactbin-api-prod:latest from api/Dockerfile.prod
make verify-prod # smoke-tests the production image
make build-ui-prod # builds reactbin-ui-prod:latest from ui/Dockerfile.prod
make verify-ui-prod # smoke-tests the production UI image
```
---
## Production deployment (k3s)
### Cluster prerequisites
- nginx ingress controller
- cert-manager with a `letsencrypt-prod` ClusterIssuer
- Vault Secrets Operator (VSO) installed and connected to Vault
- Vault KV v2 secrets populated (see below)
### Vault secrets
Two KV v2 paths. VSO syncs these into Kubernetes Secrets automatically.
**`reactbin/api/config`** → K8s Secret `api-env`
| Key | Notes |
|-----|-------|
| `DATABASE_URL` | `postgresql+asyncpg://user:pass@host:5432/db` |
| `JWT_SECRET_KEY` | Long random string — `openssl rand -base64 48` |
| `OWNER_USERNAME` | Login username |
| `OWNER_PASSWORD` | Login password |
| `S3_ENDPOINT_URL` | `http://minio.reactbin.svc.cluster.local:9000` |
| `S3_BUCKET_NAME` | `reactbin` |
| `S3_ACCESS_KEY_ID` | Same value as `MINIO_ROOT_USER` |
| `S3_SECRET_ACCESS_KEY` | Same value as `MINIO_ROOT_PASSWORD` |
| `API_BASE_URL` | `https://<your-domain>` |
| `LOGIN_TRUSTED_PROXY_IPS` | Pod CIDR of nginx ingress pods, e.g. `10.42.0.0/16` — needed for per-client login rate limiting behind the ingress |
**`reactbin/minio/credentials`** → K8s Secret `minio-credentials`
| Key | Notes |
|-----|-------|
| `MINIO_ROOT_USER` | MinIO admin username |
| `MINIO_ROOT_PASSWORD` | `openssl rand -base64 32` |
### Apply order
```bash
# 1. Namespace first
kubectl apply -f k8s/namespace.yaml
# 2. Vault CRDs — wait for VSO to create api-env and minio-credentials Secrets
kubectl apply -f k8s/vault/
kubectl get secret -n reactbin api-env minio-credentials # wait until both appear
# 3. API, UI, Ingress — replace 'latest' tags and <your-domain> first
kubectl apply -f k8s/api/ -f k8s/ui/ -f k8s/ingress.yaml
kubectl rollout status deployment/api -n reactbin # Alembic init container runs here
# 4. MinIO — wait for StatefulSet ready before running the bucket init Job
kubectl apply -f k8s/minio/service.yaml -f k8s/minio/statefulset.yaml
kubectl rollout status statefulset/minio -n reactbin
kubectl apply -f k8s/minio/init-job.yaml
```
Before applying: substitute real image tags in the Deployment manifests and replace `<your-domain>` in `k8s/ingress.yaml`.
### Updating a secret
1. Update the value in Vault
2. Force VSO to sync immediately (otherwise waits up to 1 hour):
```bash
kubectl annotate vaultstaticsecret api-secret -n reactbin \
secrets.hashicorp.com/force-sync=$(date +%s) --overwrite
```
3. Restart the deployment to pick up the new Secret:
```bash
kubectl rollout restart deployment/api -n reactbin
```
### Validating manifests
```bash
make validate-k8s # yamllint + kubectl apply --dry-run=client (requires kubeconfig)
```
---
## Environment variables reference
All variables are read at startup from environment / `.env`.
| Variable | Default | Notes |
|----------|---------|-------|
| `DATABASE_URL` | — | Async DSN: `postgresql+asyncpg://...` |
| `JWT_SECRET_KEY` | — | Required; use a long random string in production |
| `JWT_EXPIRY_SECONDS` | `86400` | Token lifetime (24 h) |
| `OWNER_USERNAME` | — | Single owner account username |
| `OWNER_PASSWORD` | — | Single owner account password |
| `S3_ENDPOINT_URL` | — | MinIO or any S3-compatible endpoint |
| `S3_BUCKET_NAME` | `reactbin` | |
| `S3_ACCESS_KEY_ID` | — | |
| `S3_SECRET_ACCESS_KEY` | — | |
| `S3_REGION` | `us-east-1` | |
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MiB |
| `API_BASE_URL` | — | Used for generating public URLs |
| `API_DOCS_ENABLED` | `true` | Set to `false` in production |
| `LOGIN_MAX_FAILURES` | `5` | Failed attempts before cooldown |
| `LOGIN_WINDOW_SECONDS` | `300` | Sliding window for failure count |
| `LOGIN_COOLDOWN_SECONDS` | `900` | Lock duration after threshold hit |
| `LOGIN_TRUSTED_PROXY_IPS` | `""` | Comma-separated CIDRs of trusted upstream proxies |

55
api/Dockerfile.prod Normal file
View File

@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
# ════════════════════════════════════════════════
# Build stage: install production deps via uv
# ════════════════════════════════════════════════
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
# Layer cache split: deps only (changes rarely)
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# Layer cache split: source (changes often)
COPY app/ ./app/
# ════════════════════════════════════════════════
# Runtime stage: lean image with venv + source
# ════════════════════════════════════════════════
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
# Explicitly list every source directory — add new top-level dirs here or they won't exist in prod
COPY --chown=appuser:appgroup app/ ./app/
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup alembic.ini .
COPY --chown=appuser:appgroup scripts/ ./scripts/
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/api/v1/health || exit 1
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--timeout-graceful-shutdown", "30"]

View File

@@ -0,0 +1,23 @@
"""add thumbnail_key column to images
Revision ID: 002
Revises: 001
Create Date: 2026-05-03
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("images", sa.Column("thumbnail_key", sa.String(70), nullable=True))
def downgrade() -> None:
op.drop_column("images", "thumbnail_key")

View File

@@ -0,0 +1,24 @@
"""add short_id column to images
Revision ID: 003
Revises: 002
Create Date: 2026-05-09
"""
from alembic import op
import sqlalchemy as sa
revision = "003"
down_revision = "002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("images", sa.Column("short_id", sa.String(8), nullable=True))
op.create_index("ix_images_short_id", "images", ["short_id"], unique=True)
def downgrade() -> None:
op.drop_index("ix_images_short_id", table_name="images")
op.drop_column("images", "short_id")

View File

@@ -0,0 +1,24 @@
"""set short_id NOT NULL on images
Revision ID: 004
Revises: 003
Create Date: 2026-05-09
IMPORTANT: Run migrate_to_short_ids.py script BEFORE applying this migration.
This migration will fail if any rows still have short_id IS NULL.
"""
from alembic import op
revision = "004"
down_revision = "003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("images", "short_id", nullable=False)
def downgrade() -> None:
op.alter_column("images", "short_id", nullable=True)

View File

@@ -0,0 +1,51 @@
import secrets
from datetime import UTC, datetime, timedelta
import jwt
from fastapi import HTTPException
from app.auth.provider import AuthProvider, Identity
_UNAUTHORIZED = HTTPException(
status_code=401, detail={"detail": "Unauthorized", "code": "unauthorized"}
)
class JWTAuthProvider(AuthProvider):
def __init__(
self,
secret_key: str,
expiry_seconds: int,
owner_username: str,
owner_password: str,
) -> None:
self._secret_key = secret_key
self._expiry_seconds = expiry_seconds
self._owner_username = owner_username
self._owner_password = owner_password
def create_token(self) -> str:
now = datetime.now(tz=UTC)
payload = {
"sub": "owner",
"iat": now,
"exp": now + timedelta(seconds=self._expiry_seconds),
}
return jwt.encode(payload, self._secret_key, algorithm="HS256")
def verify_credentials(self, username: str, password: str) -> bool:
username_ok = secrets.compare_digest(username, self._owner_username)
password_ok = secrets.compare_digest(password, self._owner_password)
return username_ok and password_ok
async def get_identity(self, authorization: str | None) -> Identity:
if not authorization or not authorization.startswith("Bearer "):
raise _UNAUTHORIZED
token = authorization.removeprefix("Bearer ")
try:
jwt.decode(token, self._secret_key, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise _UNAUTHORIZED from None
except jwt.InvalidTokenError:
raise _UNAUTHORIZED from None
return Identity(id="owner", anonymous=False)

View File

@@ -4,5 +4,5 @@ _ANONYMOUS = Identity(id="anonymous", anonymous=True)
class NoOpAuthProvider(AuthProvider):
async def get_identity(self) -> Identity:
async def get_identity(self, authorization: str | None) -> Identity:
return _ANONYMOUS

View File

@@ -10,5 +10,5 @@ class Identity:
class AuthProvider(ABC):
@abstractmethod
async def get_identity(self) -> Identity:
"""Resolve the request identity."""
async def get_identity(self, authorization: str | None) -> Identity:
"""Resolve the request identity from the Authorization header value."""

View File

@@ -0,0 +1,99 @@
import ipaddress
import logging
import time
from dataclasses import dataclass, field
from ipaddress import IPv4Network, IPv6Network
from threading import Lock
from starlette.requests import Request
logger = logging.getLogger(__name__)
def get_client_ip(
request: Request,
trusted_networks: list[IPv4Network | IPv6Network],
) -> str:
"""Return the resolved client IP.
Prefers X-Real-IP over X-Forwarded-For when the TCP peer is a trusted
proxy. ingress-nginx sets X-Real-IP via its realip module using an
authoritative CIDR allowlist; it overwrites any client-supplied value, so
it cannot be spoofed via XFF injection. XFF[0] is the fallback for paths
that lack nginx (none currently exist, but kept for defence in depth).
"""
peer = request.client.host if request.client else "unknown"
if trusted_networks and peer != "unknown":
try:
peer_addr = ipaddress.ip_address(peer)
if any(peer_addr in net for net in trusted_networks):
real_ip = request.headers.get("X-Real-IP", "").strip()
if real_ip:
return real_ip
# XFF[0] fallback — warn because this path should not be
# reached in production (nginx always sets X-Real-IP).
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if xff:
logger.warning(
"X-Real-IP absent from trusted peer %s; falling back to XFF[0]", peer
)
return xff
except ValueError:
pass
return peer
@dataclass
class _Record:
failures: int = 0
window_start: float = field(default_factory=time.time)
blocked_until: float = 0.0
class LoginRateLimiter:
def __init__(
self,
max_failures: int = 5,
window_seconds: int = 300,
cooldown_seconds: int = 900,
) -> None:
self._max = max_failures
self._window = window_seconds
self._cooldown = cooldown_seconds
self._store: dict[str, _Record] = {}
self._lock = Lock()
@property
def cooldown_seconds(self) -> int:
return self._cooldown
def is_blocked(self, ip: str) -> bool:
now = time.time()
with self._lock:
rec = self._store.get(ip)
if rec is None:
return False
if rec.blocked_until > now:
return True
if rec.blocked_until > 0:
del self._store[ip]
return False
def record_failure(self, ip: str) -> None:
now = time.time()
with self._lock:
rec = self._store.get(ip)
if rec is None:
rec = _Record(window_start=now)
self._store[ip] = rec
if now - rec.window_start > self._window:
rec.failures = 0
rec.window_start = now
rec.failures += 1
if rec.failures >= self._max:
rec.blocked_until = now + self._cooldown
logger.warning("Login blocked for %s after %d failures", ip, rec.failures)
def record_success(self, ip: str) -> None:
with self._lock:
self._store.pop(ip, None)

View File

@@ -1,4 +1,6 @@
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -12,7 +14,29 @@ class Settings(BaseSettings):
s3_secret_access_key: str
s3_region: str = "us-east-1"
api_base_url: str = "http://localhost:8000"
s3_public_base_url: str | None = None
max_upload_bytes: int = 52_428_800 # 50 MiB
jwt_secret_key: str
jwt_expiry_seconds: int = 86400
owner_username: str
owner_password: str
login_max_failures: int = 5
login_window_seconds: int = 300
login_cooldown_seconds: int = 900
login_trusted_proxy_ips: str = ""
api_docs_enabled: bool = True
@field_validator("api_docs_enabled", mode="before")
@classmethod
def coerce_docs_enabled(cls, v):
if isinstance(v, bool):
return v
try:
from pydantic import TypeAdapter
return TypeAdapter(bool).validate_python(v)
except Exception:
return True
@lru_cache

View File

@@ -1,4 +1,4 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings

View File

@@ -1,10 +1,10 @@
from typing import AsyncGenerator
from collections.abc import AsyncGenerator
from fastapi import Depends
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.noop import NoOpAuthProvider
from app.auth.provider import AuthProvider
from app.auth.jwt_provider import JWTAuthProvider
from app.auth.provider import AuthProvider, Identity
from app.database import get_session_factory
from app.storage.backend import StorageBackend
from app.storage.s3_backend import S3StorageBackend
@@ -23,12 +23,38 @@ def get_storage() -> StorageBackend:
def get_auth() -> AuthProvider:
global _auth
if _auth is None:
_auth = NoOpAuthProvider()
from app.config import get_settings
s = get_settings()
_auth = JWTAuthProvider(
secret_key=s.jwt_secret_key,
expiry_seconds=s.jwt_expiry_seconds,
owner_username=s.owner_username,
owner_password=s.owner_password,
)
return _auth
def get_jwt_auth() -> JWTAuthProvider:
auth = get_auth()
assert isinstance(auth, JWTAuthProvider)
return auth
async def require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth),
) -> Identity:
identity = await auth.get_identity(authorization)
if identity.anonymous:
raise HTTPException(
status_code=401,
detail={"detail": "Authentication required", "code": "unauthorized"},
)
return identity
async def get_db() -> AsyncGenerator[AsyncSession, None]:
factory = get_session_factory()
async with factory() as session:
async with session.begin():
yield session
async with factory() as session, session.begin():
yield session

View File

@@ -1,17 +1,30 @@
from contextlib import asynccontextmanager
import ipaddress
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from app.auth.rate_limiter import LoginRateLimiter
from app.config import get_settings
from app.database import get_engine, get_session_factory, Base
from app.database import Base, get_engine
@asynccontextmanager
async def lifespan(application: FastAPI):
settings = get_settings()
# Verify DB connection and run migrations on startup
application.state.login_rate_limiter = LoginRateLimiter(
max_failures=settings.login_max_failures,
window_seconds=settings.login_window_seconds,
cooldown_seconds=settings.login_cooldown_seconds,
)
trusted_networks = []
for part in settings.login_trusted_proxy_ips.split(","):
part = part.strip()
if part:
with suppress(ValueError):
trusted_networks.append(ipaddress.ip_network(part, strict=False))
application.state.login_trusted_networks = trusted_networks
engine = get_engine()
async with engine.begin() as conn:
# In production, Alembic handles migrations; this is a dev convenience
@@ -20,7 +33,20 @@ async def lifespan(application: FastAPI):
await engine.dispose()
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
_settings = get_settings()
app = FastAPI(
title="Reactbin API",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if _settings.api_docs_enabled else None,
redoc_url="/redoc" if _settings.api_docs_enabled else None,
openapi_url="/openapi.json" if _settings.api_docs_enabled else None,
)
# Defaults so app.state is populated even when lifespan doesn't run (e.g. tests)
app.state.login_rate_limiter = LoginRateLimiter()
app.state.login_trusted_networks = []
@app.exception_handler(HTTPException)
@@ -36,7 +62,8 @@ async def health():
# Routers registered after all modules are defined to avoid circular imports
from app.routers import images, tags # noqa: E402
from app.routers import auth, images, tags # noqa: E402
app.include_router(auth.router, prefix="/api/v1")
app.include_router(images.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1")

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -9,7 +9,7 @@ from app.database import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
class Image(Base):
@@ -22,10 +22,16 @@ class Image(Base):
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
)
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan")
image_tags: Mapped[list["ImageTag"]] = relationship(
back_populates="image", cascade="all, delete-orphan"
)
@property
def tags(self) -> list[str]:
@@ -37,7 +43,9 @@ class Tag(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
)
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")

View File

@@ -1,5 +1,4 @@
import uuid
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,15 +11,27 @@ class ImageRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
async def get_by_hash(self, hash_hex: str) -> Image | None:
result = await self._session.execute(
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
select(Image)
.where(Image.hash == hash_hex)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
async def get_by_id(self, image_id: uuid.UUID) -> Image | None:
result = await self._session.execute(
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
select(Image)
.where(Image.id == image_id)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def get_by_short_id(self, short_id: str) -> Image | None:
result = await self._session.execute(
select(Image)
.where(Image.short_id == short_id)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
@@ -34,6 +45,8 @@ class ImageRepository:
width: int,
height: int,
storage_key: str,
short_id: str,
thumbnail_key: str | None = None,
) -> Image:
image = Image(
hash=hash_hex,
@@ -43,6 +56,8 @@ class ImageRepository:
width=width,
height=height,
storage_key=storage_key,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
self._session.add(image)
await self._session.flush()
@@ -55,7 +70,7 @@ class ImageRepository:
limit: int = 50,
offset: int = 0,
) -> tuple[list[Image], int]:
from sqlalchemy import func, and_
from sqlalchemy import func
base_query = select(Image).options(
selectinload(Image.image_tags).selectinload(ImageTag.tag)

View File

@@ -1,7 +1,7 @@
import re
import uuid
from sqlalchemy import select, func
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Image, ImageTag, Tag
@@ -48,9 +48,7 @@ class TagRepository:
for name in tag_names:
tag = await self.upsert_by_name(name)
existing = await self._session.execute(
select(ImageTag).where(
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
)
select(ImageTag).where(ImageTag.image_id == image.id, ImageTag.tag_id == tag.id)
)
if existing.scalar_one_or_none() is None:
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
@@ -76,6 +74,8 @@ class TagRepository:
prefix: str | None = None,
limit: int = 100,
offset: int = 0,
sort: str = "name",
min_count: int = 0,
) -> tuple[list[dict], int]:
count_subq = (
select(func.count(ImageTag.image_id))
@@ -86,17 +86,20 @@ class TagRepository:
query = select(Tag, count_subq.label("image_count"))
if prefix:
query = query.where(Tag.name.like(f"{prefix}%"))
query = query.where(Tag.name.ilike(f"%{prefix}%"))
if min_count > 0:
query = query.where(count_subq >= min_count)
total_query = select(func.count()).select_from(query.subquery())
total_result = await self._session.execute(total_query)
total = total_result.scalar_one()
paginated = query.order_by(Tag.name).limit(limit).offset(offset)
order = [count_subq.desc(), Tag.name.asc()] if sort == "count_desc" else [Tag.name.asc()]
paginated = query.order_by(*order).limit(limit).offset(offset)
rows = await self._session.execute(paginated)
items = [
{"id": str(tag.id), "name": tag.name, "image_count": count}
for tag, count in rows.all()
{"id": str(tag.id), "name": tag.name, "image_count": count} for tag, count in rows.all()
]
return items, total

55
api/app/routers/auth.py Normal file
View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.auth.jwt_provider import JWTAuthProvider
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
from app.dependencies import get_jwt_auth
router = APIRouter(tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
@router.post("/auth/token", response_model=TokenResponse)
async def login(
request: Request,
body: LoginRequest,
auth: JWTAuthProvider = Depends(get_jwt_auth),
):
limiter: LoginRateLimiter = request.app.state.login_rate_limiter
ip: str = get_client_ip(request, request.app.state.login_trusted_networks)
if limiter.is_blocked(ip):
return JSONResponse(
status_code=429,
content={
"detail": "Too many failed login attempts. Please try again later.",
"code": "login_rate_limited",
},
headers={"Retry-After": str(limiter.cooldown_seconds)},
)
if not auth.verify_credentials(body.username, body.password):
limiter.record_failure(ip)
raise HTTPException(
status_code=401,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
limiter.record_success(ip)
token = auth.create_token()
return TokenResponse(
access_token=token,
token_type="bearer",
expires_in=auth._expiry_seconds,
)

View File

@@ -1,30 +1,57 @@
import asyncio
import logging
import re
import struct
import uuid
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.provider import AuthProvider
from app.auth.provider import Identity
from app.config import get_settings
from app.dependencies import get_auth, get_db, get_storage
from app.dependencies import get_db, get_storage, require_auth
from app.models import Image
from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository
from app.storage.backend import StorageBackend
from app.utils import compute_sha256
from app.thumbnail import generate_thumbnail
from app.utils import compute_sha256, generate_short_id
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
logger = logging.getLogger(__name__)
router = APIRouter(tags=["images"])
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
def _error(detail: str, code: str, status: int):
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str, Any]:
def _validate_short_id(short_id: str) -> str:
if not _SHORT_ID_RE.match(short_id):
raise HTTPException(
status_code=422,
detail={"detail": "Invalid image ID", "code": "invalid_short_id"},
)
return short_id
def _image_to_dict(
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
) -> dict[str, Any]:
_base = cdn_base.strip().rstrip("/") if cdn_base else None
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/i/{image.short_id}/file"
thumbnail_url = (
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/i/{image.short_id}/thumbnail")
if image.thumbnail_key
else None
)
data: dict[str, Any] = {
"id": str(image.id),
"short_id": image.short_id,
"hash": image.hash,
"filename": image.filename,
"mime_type": image.mime_type,
@@ -32,6 +59,9 @@ def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str,
"width": image.width,
"height": image.height,
"storage_key": image.storage_key,
"thumbnail_key": image.thumbnail_key,
"file_url": file_url,
"thumbnail_url": thumbnail_url,
"created_at": image.created_at.isoformat(),
"tags": image.tags,
}
@@ -103,7 +133,7 @@ async def upload_image(
tags: str | None = Form(None),
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
auth: AuthProvider = Depends(get_auth),
_: Identity = Depends(require_auth),
settings=Depends(get_settings),
):
data = await file.read()
@@ -127,10 +157,13 @@ async def upload_image(
hash_hex = compute_sha256(data)
image_repo = ImageRepository(db)
_cdn_base = settings.s3_public_base_url
existing = await image_repo.get_by_hash(hash_hex)
if existing:
return Response(
content=__import__("json").dumps(_image_to_dict(existing, duplicate=True)),
content=__import__("json").dumps(
_image_to_dict(existing, cdn_base=_cdn_base, duplicate=True)
),
status_code=200,
media_type="application/json",
)
@@ -149,24 +182,55 @@ async def upload_image(
)
width, height = _read_image_dimensions(data, mime_type)
await storage.put(hash_hex, data, mime_type)
image = await image_repo.create(
hash_hex=hash_hex,
filename=file.filename or "upload",
mime_type=mime_type,
size_bytes=len(data),
width=width,
height=height,
storage_key=hash_hex,
)
from sqlalchemy.exc import IntegrityError
for _ in range(10):
short_id = generate_short_id()
await storage.put(short_id, data, mime_type)
thumbnail_key: str | None = None
try:
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
await storage.put(f"{short_id}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{short_id}-thumb"
except Exception:
logger.warning(
"Thumbnail generation failed for %s; proceeding without thumbnail", short_id
)
try:
image = await image_repo.create(
hash_hex=hash_hex,
filename=file.filename or "upload",
mime_type=mime_type,
size_bytes=len(data),
width=width,
height=height,
storage_key=short_id,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
break
except IntegrityError:
await db.rollback()
await storage.delete(short_id)
if thumbnail_key:
await storage.delete(thumbnail_key)
thumbnail_key = None
continue
else:
raise HTTPException(
status_code=500,
detail={"detail": "Failed to assign unique ID", "code": "id_collision"},
)
if tag_names:
tag_repo = TagRepository(db)
await tag_repo.attach_tags(image, tag_names)
image = await image_repo.reload_with_tags(image.id)
return _image_to_dict(image, duplicate=False)
return _image_to_dict(image, cdn_base=_cdn_base, duplicate=False)
@router.get("/images")
@@ -175,42 +239,48 @@ async def list_images(
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
settings=Depends(get_settings),
):
limit = min(limit, 100)
_cdn_base = settings.s3_public_base_url
tag_names = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
image_repo = ImageRepository(db)
images, total = await image_repo.list_images(tag_names=tag_names, limit=limit, offset=offset)
return {
"items": [_image_to_dict(img) for img in images],
"items": [_image_to_dict(img, cdn_base=_cdn_base) for img in images],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/images/{image_id}")
@router.get("/i/{short_id}")
async def get_image(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
settings=Depends(get_settings),
):
_validate_short_id(short_id)
_cdn_base = settings.s3_public_base_url
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
return _image_to_dict(image)
return _image_to_dict(image, cdn_base=_cdn_base)
@router.get("/images/{image_id}/file")
@router.get("/i/{short_id}/file")
async def serve_image_file(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -233,14 +303,51 @@ async def serve_image_file(
)
@router.patch("/images/{image_id}/tags")
@router.get("/i/{short_id}/thumbnail")
async def serve_image_thumbnail(
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
key = image.thumbnail_key or image.storage_key
media_type = "image/webp" if image.thumbnail_key else image.mime_type
try:
data = await storage.get(key)
except Exception:
raise HTTPException(
status_code=500,
detail={"detail": "Failed to retrieve image content", "code": "storage_error"},
) from None
return Response(
content=data,
media_type=media_type,
headers={
"ETag": f'"{image.hash}"',
"Cache-Control": "public, max-age=31536000, immutable",
},
)
@router.patch("/i/{short_id}/tags")
async def update_image_tags(
image_id: uuid.UUID,
short_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
_: Identity = Depends(require_auth),
settings=Depends(get_settings),
):
_validate_short_id(short_id)
_cdn_base = settings.s3_public_base_url
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -259,23 +366,28 @@ async def update_image_tags(
await tag_repo.replace_tags_on_image(image, tag_names)
image = await image_repo.reload_with_tags(image.id)
return _image_to_dict(image)
return _image_to_dict(image, cdn_base=_cdn_base)
@router.delete("/images/{image_id}", status_code=204)
@router.delete("/i/{short_id}", status_code=204)
async def delete_image(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
_: Identity = Depends(require_auth),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
storage_key = image.storage_key
thumbnail_key = image.thumbnail_key
await image_repo.delete(image)
await storage.delete(storage_key)
if thumbnail_key:
await storage.delete(thumbnail_key)
return Response(status_code=204)

View File

@@ -12,9 +12,13 @@ async def list_tags(
q: str | None = None,
limit: int = 100,
offset: int = 0,
sort: str = "name",
min_count: int = 0,
db: AsyncSession = Depends(get_db),
):
limit = min(limit, 200)
limit = min(limit, 500)
tag_repo = TagRepository(db)
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset)
items, total = await tag_repo.list_tags(
prefix=q, limit=limit, offset=offset, sort=sort, min_count=min_count
)
return {"items": items, "total": total, "limit": limit, "offset": offset}

16
api/app/thumbnail.py Normal file
View File

@@ -0,0 +1,16 @@
import contextlib
import io
from PIL import Image
def generate_thumbnail(data: bytes, mime_type: str) -> bytes:
img = Image.open(io.BytesIO(data))
with contextlib.suppress(EOFError):
img.seek(0)
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA" if img.mode == "P" and "transparency" in img.info else "RGB")
img.thumbnail((400, 400), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="WEBP", quality=80)
return buf.getvalue()

View File

@@ -1,5 +1,13 @@
import hashlib
import secrets
import string
BASE62 = string.ascii_letters + string.digits
def compute_sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def generate_short_id(length: int = 8) -> str:
return "".join(secrets.choice(BASE62) for _ in range(length))

View File

@@ -15,6 +15,8 @@ dependencies = [
"aiobotocore>=2.13",
"pydantic-settings>=2.2",
"python-multipart>=0.0.9",
"pillow>=10.0",
"PyJWT>=2.8",
]
[project.optional-dependencies]
@@ -28,10 +30,14 @@ dev = [
[tool.ruff]
line-length = 100
target-version = "py312"
exclude = ["alembic/"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = []
ignore = [
"B008", # FastAPI Depends/File/Form in function signatures — intentional
"B904", # raise-without-from inside except — HTTPException re-raise pattern
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
@@ -42,3 +48,11 @@ testpaths = ["tests"]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
[dependency-groups]
dev = [
"anyio>=4.13.0",
"httpx>=0.28.1",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]

0
api/scripts/__init__.py Normal file
View File

View File

@@ -0,0 +1,107 @@
"""
Migrate existing images to use short_id-based storage keys.
Run after applying Alembic migration 003 (adds short_id column).
Run before applying migration 004 (sets short_id NOT NULL).
Usage:
python -m scripts.migrate_to_short_ids
"""
import asyncio
import logging
from typing import Any
from sqlalchemy import select
from app.database import get_session_factory
from app.models import Image
from app.storage.s3_backend import S3StorageBackend
from app.utils import generate_short_id
logger = logging.getLogger(__name__)
async def migrate_image(image: Any, storage: Any, session: Any) -> bool:
"""Migrate one image to a short_id-based key. Returns True if migrated, False if skipped."""
if image.short_id is not None:
return False
new_short_id = generate_short_id()
old_key = image.storage_key
old_thumb_key = image.thumbnail_key
try:
data = await storage.get(old_key)
await storage.put(new_short_id, data, image.mime_type)
# Verify copy succeeded
await storage.get(new_short_id)
except Exception as exc:
logger.error("Failed to copy storage object for image %s: %s", image.id, exc)
return False
new_thumb_key: str | None = None
if old_thumb_key:
try:
thumb_data = await storage.get(old_thumb_key)
new_thumb_key = f"{new_short_id}-thumb"
await storage.put(new_thumb_key, thumb_data, "image/webp")
await storage.get(new_thumb_key)
except Exception as exc:
logger.warning("Failed to copy thumbnail for image %s: %s", image.id, exc)
new_thumb_key = None
try:
image.short_id = new_short_id
image.storage_key = new_short_id
image.thumbnail_key = new_thumb_key
await session.flush()
await storage.delete(old_key)
if old_thumb_key and new_thumb_key:
await storage.delete(old_thumb_key)
except Exception as exc:
logger.error("Failed to update DB record for image %s: %s", image.id, exc)
return False
return True
async def run_migration(images: list, storage: Any, session: Any) -> tuple[int, int, int]:
"""Process a list of images. Returns (migrated, skipped, failed) counts."""
migrated = skipped = failed = 0
for image in images:
if image.short_id is not None:
skipped += 1
continue
try:
success = await migrate_image(image, storage, session)
if success:
migrated += 1
else:
failed += 1
except Exception as exc:
logger.error("Unexpected error migrating image %s: %s", image.id, exc)
failed += 1
return migrated, skipped, failed
async def main() -> None:
logging.basicConfig(level=logging.INFO)
storage = S3StorageBackend()
async with get_session_factory()() as session:
result = await session.execute(select(Image).where(Image.short_id.is_(None)))
images = list(result.scalars().all())
logger.info("Found %d images to migrate", len(images))
migrated, skipped, failed = await run_migration(images, storage, session)
await session.commit()
print(f"Migrated: {migrated}, Skipped: {skipped}, Failed: {failed}")
if __name__ == "__main__":
asyncio.run(main())

0
api/tests/build/.gitkeep Normal file
View File

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# TDD verification script for api/Dockerfile.prod
# Fails (red) if Dockerfile.prod does not exist or any check fails.
set -euo pipefail
IMAGE="reactbin-api-prod:verify-$$"
IMAGE2="reactbin-api-prod:verify-cache-$$"
PG_CONTAINER=""
APP_CONTAINER=""
cleanup() {
[ -n "$APP_CONTAINER" ] && docker rm -f "$APP_CONTAINER" 2>/dev/null || true
[ -n "$PG_CONTAINER" ] && docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rmi "$IMAGE" 2>/dev/null || true
docker rmi "$IMAGE2" 2>/dev/null || true
}
trap cleanup EXIT
# ── US1 check 1: build ────────────────────────────────────────────────────────
echo "[verify] Building $IMAGE..."
docker build -f api/Dockerfile.prod api/ -t "$IMAGE"
echo "[verify] Build OK"
# ── US1 check 2: start with a throwaway postgres ──────────────────────────────
echo "[verify] Starting postgres..."
PG_CONTAINER=$(docker run -d \
-e POSTGRES_DB=reactbin_verify \
-e POSTGRES_USER=verify \
-e POSTGRES_PASSWORD=verify \
postgres:16-alpine)
for i in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U verify -q 2>/dev/null; then break; fi
sleep 1
if [[ $i -eq 30 ]]; then echo "FAIL: postgres did not become ready"; exit 1; fi
done
PG_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_CONTAINER")
echo "[verify] Starting production container..."
APP_CONTAINER=$(docker run -d \
-p 18000:8000 \
-e JWT_SECRET_KEY=verify-key \
-e OWNER_USERNAME=testowner \
-e OWNER_PASSWORD=testpassword \
-e DATABASE_URL="postgresql+asyncpg://verify:verify@${PG_IP}:5432/reactbin_verify" \
-e S3_ENDPOINT_URL=http://noop:9000 \
-e S3_BUCKET_NAME=noop \
-e S3_ACCESS_KEY_ID=noop \
-e S3_SECRET_ACCESS_KEY=noop \
-e S3_REGION=us-east-1 \
"$IMAGE")
# ── US1 check 3: health endpoint ──────────────────────────────────────────────
echo "[verify] Polling health endpoint..."
for i in $(seq 1 30); do
if curl -sf http://localhost:18000/api/v1/health > /dev/null; then break; fi
sleep 1
if [[ $i -eq 30 ]]; then echo "FAIL: health check timed out after 30s"; exit 1; fi
done
echo "[verify] Health check passed"
# ── US2 check 1: non-root user ────────────────────────────────────────────────
UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)
if [[ "$UID_IN_CONTAINER" -eq 0 ]]; then
echo "FAIL: process running as root (UID 0)"; exit 1
fi
echo "[verify] Non-root user OK (UID $UID_IN_CONTAINER)"
# ── C1: stdout/stderr log capture ────────────────────────────────────────────
LOGS=$(docker logs "$APP_CONTAINER" 2>&1)
if [[ -z "$LOGS" ]]; then
echo "FAIL: no output on stdout/stderr"; exit 1
fi
if ! echo "$LOGS" | grep -qiE "(started server|application startup complete|uvicorn)"; then
echo "FAIL: no startup logs found on stdout/stderr"; exit 1
fi
echo "[verify] Stdout logging OK"
# ── US1 check 4: SIGTERM → exit 0 ────────────────────────────────────────────
docker stop "$APP_CONTAINER" > /dev/null
EXIT_CODE=$(docker wait "$APP_CONTAINER")
if [[ "$EXIT_CODE" -ne 0 ]]; then
echo "FAIL: non-zero exit code $EXIT_CODE after SIGTERM"; exit 1
fi
echo "[verify] Graceful shutdown OK (exit $EXIT_CODE)"
# ── US2 check 2: dev deps absent ─────────────────────────────────────────────
if docker run --rm "$IMAGE" /app/.venv/bin/python -c "import pytest" 2>/dev/null; then
echo "FAIL: pytest importable in production image (dev deps present)"; exit 1
fi
echo "[verify] Dev deps absent OK"
# ── C2: no hardcoded secrets in image layers ─────────────────────────────────
if docker history --no-trunc "$IMAGE" 2>&1 | grep -qiE "(password|secret_key|api_key|token)"; then
echo "FAIL: potential secret found in image history"; exit 1
fi
echo "[verify] No secrets in image layers OK"
# ── C3: missing env var → non-zero exit ──────────────────────────────────────
set +e
docker run --rm -e JWT_SECRET_KEY=verify-key "$IMAGE" 2>/dev/null
MISSING_ENV_EXIT=$?
set -e
if [[ "$MISSING_ENV_EXIT" -eq 0 ]]; then
echo "FAIL: container exited 0 despite missing OWNER_USERNAME"; exit 1
fi
echo "[verify] Missing-env-var exit check OK (exit $MISSING_ENV_EXIT)"
# ── US3: dep layer cached on source-only rebuild ──────────────────────────────
echo "[verify] Testing cache hit on source-only rebuild..."
touch api/app/main.py
BUILD2_OUTPUT=$(docker build --progress=plain -f api/Dockerfile.prod api/ -t "$IMAGE2" 2>&1)
if ! echo "$BUILD2_OUTPUT" | grep -q "CACHED"; then
echo "FAIL: dependency layer not reused on source-only rebuild"; exit 1
fi
echo "[verify] Dep layer cache hit confirmed (US3 OK)"
echo "[verify] All checks passed (US1 + US2 + US3)."

View File

@@ -1,19 +1,32 @@
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.main import app
from app.config import get_settings
from app.database import Base
from app.dependencies import get_db, get_storage, get_auth
# Provide required settings for the test environment before any app imports resolve them
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
os.environ.setdefault("OWNER_USERNAME", "testowner")
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
from app.auth.jwt_provider import JWTAuthProvider # noqa: E402
from app.config import get_settings # noqa: E402
from app.database import Base # noqa: E402
from app.dependencies import get_auth, get_db, get_storage # noqa: E402
from app.main import app # noqa: E402
# Bust the LRU cache so get_settings() picks up the env vars set above
get_settings.cache_clear()
_TEST_JWT_SECRET = os.environ["JWT_SECRET_KEY"]
_TEST_OWNER_USERNAME = os.environ["OWNER_USERNAME"]
_TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def engine():
settings = get_settings()
# Use a separate test database URL if TEST_DATABASE_URL is set
import os
db_url = os.getenv("TEST_DATABASE_URL", settings.database_url)
eng = create_async_engine(db_url, echo=False)
async with eng.begin() as conn:
@@ -34,8 +47,8 @@ async def db_session(engine):
@pytest_asyncio.fixture
async def client(db_session):
from app.storage.s3_backend import S3StorageBackend
from app.auth.noop import NoOpAuthProvider
from app.storage.s3_backend import S3StorageBackend
storage = S3StorageBackend()
auth = NoOpAuthProvider()
@@ -57,3 +70,52 @@ async def client(db_session):
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def jwt_auth_provider() -> JWTAuthProvider:
return JWTAuthProvider(
secret_key=_TEST_JWT_SECRET,
expiry_seconds=3600,
owner_username=_TEST_OWNER_USERNAME,
owner_password=_TEST_OWNER_PASSWORD,
)
@pytest_asyncio.fixture
async def authed_client(db_session, jwt_auth_provider):
from app.storage.s3_backend import S3StorageBackend
storage = S3StorageBackend()
auth = jwt_auth_provider
async def override_db():
yield db_session
def override_storage():
return storage
def override_auth():
return auth
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_storage] = override_storage
app.dependency_overrides[get_auth] = override_auth
valid_token = auth.create_token()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c, valid_token
app.dependency_overrides.clear()
def pytest_configure(config):
db_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")
if not db_url.startswith("postgresql+asyncpg://"):
pytest.exit(
"Integration tests require a PostgreSQL database "
"(postgresql+asyncpg://...). "
"Set TEST_DATABASE_URL or DATABASE_URL accordingly. "
f"Got: {db_url!r}",
returncode=1,
)

View File

@@ -0,0 +1,51 @@
import pytest
_VALID_CREDS = {"username": "testowner", "password": "testpassword"}
@pytest.mark.asyncio
async def test_login_success(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json=_VALID_CREDS)
assert response.status_code == 200
body = response.json()
assert isinstance(body.get("access_token"), str)
assert len(body["access_token"]) > 0
assert body.get("token_type") == "bearer"
assert body.get("expires_in", 0) > 0
@pytest.mark.asyncio
async def test_login_wrong_password(authed_client):
client, _ = authed_client
response = await client.post(
"/api/v1/auth/token",
json={"username": "testowner", "password": "wrongpassword"},
)
assert response.status_code == 401
assert response.json().get("code") == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_wrong_username(authed_client):
client, _ = authed_client
response = await client.post(
"/api/v1/auth/token",
json={"username": "notowner", "password": "testpassword"},
)
assert response.status_code == 401
assert response.json().get("code") == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_missing_password(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json={"username": "testowner"})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login_missing_username(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json={"password": "testpassword"})
assert response.status_code == 422

View File

@@ -1,11 +1,12 @@
"""
T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404
T065 — DELETE /api/v1/i/{short_id} → 204; subsequent GET returns 404
T066 — DELETE verifies MinIO object is removed
T067 — DELETE of unknown ID → 404 image_not_found
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
def _minimal_jpeg_v2() -> bytes:
@@ -17,44 +18,78 @@ def _minimal_jpeg_v2() -> bytes:
@pytest.mark.asyncio
async def test_delete_removes_record(client):
async def test_delete_removes_record(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_jpeg_v2()
upload = await client.post(
"/api/v1/images",
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
get_resp = await client.get(f"/api/v1/images/{image_id}")
get_resp = await client.get(f"/api/v1/i/{image_id}")
assert get_resp.status_code == 404
assert get_resp.json()["code"] == "image_not_found"
@pytest.mark.asyncio
async def test_delete_removes_storage_object(client):
async def test_delete_removes_storage_object(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_jpeg_v2() + b"\x00"
upload = await client.post(
"/api/v1/images",
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
storage_key = upload.json()["hash"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
# Confirm storage redirect no longer works (404 since record is gone)
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
file_resp = await client.get(f"/api/v1/i/{image_id}/file")
assert file_resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_unknown_id_returns_404(client):
response = await client.delete(f"/api/v1/images/{uuid.uuid4()}")
async def test_delete_unknown_id_returns_404(authed_client):
client, token = authed_client
response = await client.delete(
"/api/v1/i/NotFound",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
@pytest.mark.asyncio
async def test_delete_removes_thumbnail(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
buf = io.BytesIO()
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
data = buf.getvalue()
upload = await client.post(
"/api/v1/images",
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert upload.status_code == 201
image_id = upload.json()["short_id"]
assert upload.json()["thumbnail_key"] is not None
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
thumb_resp = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert thumb_resp.status_code == 404
assert thumb_resp.json()["code"] == "image_not_found"

View File

@@ -0,0 +1,48 @@
import importlib
from starlette.testclient import TestClient
from app.config import get_settings
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"JWT_SECRET_KEY": "test-secret",
"OWNER_USERNAME": "admin",
"OWNER_PASSWORD": "password",
"S3_ENDPOINT_URL": "http://localhost:9000",
"S3_BUCKET_NAME": "test-bucket",
"S3_ACCESS_KEY_ID": "key",
"S3_SECRET_ACCESS_KEY": "secret",
}
def _set_env(monkeypatch, extra=None):
for k, v in {**_BASE_ENV, **(extra or {})}.items():
monkeypatch.setenv(k, v)
def test_docs_hidden_when_flag_disabled(monkeypatch):
_set_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
get_settings.cache_clear()
import app.main as m
importlib.reload(m)
client = TestClient(m.app, raise_server_exceptions=False)
assert client.get("/docs").status_code == 404
assert client.get("/redoc").status_code == 404
assert client.get("/openapi.json").status_code == 404
assert client.get("/api/v1/health").status_code == 200
get_settings.cache_clear()
def test_docs_visible_when_flag_enabled(monkeypatch):
_set_env(monkeypatch, {"API_DOCS_ENABLED": "true"})
get_settings.cache_clear()
import app.main as m
importlib.reload(m)
client = TestClient(m.app, raise_server_exceptions=False)
assert client.get("/docs").status_code == 200
assert client.get("/redoc").status_code == 200
assert client.get("/openapi.json").status_code == 200
get_settings.cache_clear()

View File

@@ -0,0 +1,121 @@
import os
import pytest
from httpx import AsyncClient
from app.auth.rate_limiter import LoginRateLimiter
from app.main import app
BAD_CREDS = {"username": "attacker", "password": "wrong"}
VALID_CREDS = {
"username": os.environ.get("OWNER_USERNAME", "testowner"),
"password": os.environ.get("OWNER_PASSWORD", "testpassword"),
}
def _fresh_limiter():
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=30)
@pytest.mark.asyncio
async def test_repeated_failures_trigger_429(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(3):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 429
assert resp.json()["code"] == "login_rate_limited"
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_success_resets_counter(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(2):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
await client.post("/api/v1/auth/token", json=VALID_CREDS)
for _ in range(3):
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 401, "counter should have reset after success"
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_429_has_retry_after_header(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(3):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 429
assert "Retry-After" in resp.headers
assert int(resp.headers["Retry-After"]) > 0
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_429_body_shape(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(3):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 429
assert resp.json() == {
"detail": "Too many failed login attempts. Please try again later.",
"code": "login_rate_limited",
}
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_xff_header_ignored_when_no_trusted_networks(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
# Send 3 failures all claiming to be "1.2.3.4" via XFF
for _ in range(3):
await client.post(
"/api/v1/auth/token",
json=BAD_CREDS,
headers={"X-Forwarded-For": "1.2.3.4"},
)
# 4th request with a *different* XFF — if XFF were trusted, this
# would appear to be a fresh IP and get 401. Since XFF is ignored,
# the real peer ("testclient") is blocked and we get 429.
resp = await client.post(
"/api/v1/auth/token",
json=BAD_CREDS,
headers={"X-Forwarded-For": "9.9.9.9"},
)
assert resp.status_code == 429, (
"XFF should be ignored when no trusted networks are configured; "
"expected real peer to be blocked"
)
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks

View File

@@ -0,0 +1,92 @@
"""
Tests that write endpoints require authentication (US2).
These use the authed_client fixture which wires JWTAuthProvider.
"""
import io
import pytest
def _minimal_jpeg() -> bytes:
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x02"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_upload_without_token_returns_401(authed_client):
client, _ = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_upload_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code in (200, 201)
@pytest.mark.asyncio
async def test_delete_without_token_returns_401(authed_client):
client, _ = authed_client
response = await client.delete("/api/v1/i/NotFound")
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_delete_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["short_id"]
response = await client.delete(
f"/api/v1/i/{image_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_patch_tags_without_token_returns_401(authed_client):
client, _ = authed_client
response = await client.patch(
"/api/v1/i/NotFound/tags",
json={"tags": ["a"]},
)
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_patch_tags_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["short_id"]
response = await client.patch(
f"/api/v1/i/{image_id}/tags",
json={"tags": ["protected-tag"]},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200

View File

@@ -0,0 +1,70 @@
"""
US3 regression tests: all read endpoints must remain accessible without a token
even after require_auth is applied to write endpoints.
"""
import io
import pytest
def _minimal_jpeg() -> bytes:
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x03"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_list_images_without_token_is_200(authed_client):
client, _ = authed_client
response = await client.get("/api/v1/images")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_image_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_file_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_thumbnail_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_list_tags_without_token_is_200(authed_client):
client, _ = authed_client
response = await client.get("/api/v1/tags")
assert response.status_code == 200

View File

@@ -3,6 +3,7 @@ T041 — GET /api/v1/images?tags=cat,funny → only images with both tags
T042 — same query excludes images with only one matching tag
"""
import io
import pytest
@@ -15,7 +16,9 @@ def _minimal_gif() -> bytes:
@pytest.mark.asyncio
async def test_and_filter_returns_only_matching_images(client):
async def test_and_filter_returns_only_matching_images(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_gif()
# Image with both tags
@@ -23,6 +26,7 @@ async def test_and_filter_returns_only_matching_images(client):
"/api/v1/images",
files={"file": ("both.gif", io.BytesIO(data), "image/gif")},
data={"tags": "andcat,andfunny"},
headers=headers,
)
both_id = r_both.json()["id"]
@@ -31,6 +35,7 @@ async def test_and_filter_returns_only_matching_images(client):
"/api/v1/images",
files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")},
data={"tags": "andcat"},
headers=headers,
)
response = await client.get("/api/v1/images?tags=andcat,andfunny")
@@ -42,7 +47,9 @@ async def test_and_filter_returns_only_matching_images(client):
@pytest.mark.asyncio
async def test_filter_excludes_partial_tag_match(client):
async def test_filter_excludes_partial_tag_match(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_gif()
# Image with only "exclcat"
@@ -50,6 +57,7 @@ async def test_filter_excludes_partial_tag_match(client):
"/api/v1/images",
files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")},
data={"tags": "exclcat"},
headers=headers,
)
# Filter requires both exclcat and exclother

View File

@@ -1,12 +1,21 @@
"""
T055 — GET /api/v1/images/{id}/file → 200 with binary content, ETag, Cache-Control
T055 — GET /api/v1/i/{short_id}/file → 200 with binary content, ETag, Cache-Control
T056 — /file for unknown ID → 404 image_not_found
T057 — /file response exposes no storage-specific details
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
from sqlalchemy import update
from app.models import Image
def _real_jpeg() -> bytes:
buf = io.BytesIO()
PILImage.new("RGB", (200, 150), color=(120, 80, 200)).save(buf, format="JPEG")
return buf.getvalue()
def _minimal_webp() -> bytes:
@@ -19,18 +28,20 @@ def _minimal_webp() -> bytes:
@pytest.mark.asyncio
async def test_file_returns_200_with_content(client):
async def test_file_returns_200_with_content(authed_client):
client, token = authed_client
data = _minimal_webp()
upload = await client.post(
"/api/v1/images",
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code in (200, 201)
upload_body = upload.json()
image_id = upload_body["id"]
image_id = upload_body["short_id"]
image_hash = upload_body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/file")
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/")
assert response.headers["etag"] == f'"{image_hash}"'
@@ -40,25 +51,81 @@ async def test_file_returns_200_with_content(client):
@pytest.mark.asyncio
async def test_file_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
response = await client.get("/api/v1/i/NotFound/file")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
@pytest.mark.asyncio
async def test_file_response_exposes_no_storage_details(client):
async def test_file_response_exposes_no_storage_details(authed_client):
client, token = authed_client
data = _minimal_webp()
upload = await client.post(
"/api/v1/images",
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
assert "location" not in response.headers
assert "minio" not in response.text.lower()
assert "s3://" not in response.text.lower()
assert "amazonaws.com" not in response.text.lower()
@pytest.mark.asyncio
async def test_thumbnail_returns_webp(authed_client):
client, token = authed_client
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
body = upload.json()
image_id = body["short_id"]
image_hash = body["hash"]
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
assert response.headers["content-type"] == "image/webp"
assert response.headers["etag"] == f'"{image_hash}"'
assert "immutable" in response.headers["cache-control"]
assert len(response.content) > 0
@pytest.mark.asyncio
async def test_thumbnail_fallback_returns_original(authed_client, db_session):
client, token = authed_client
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
image_id = upload.json()["short_id"]
await db_session.execute(
update(Image).where(Image.short_id == image_id).values(thumbnail_key=None)
)
await db_session.flush()
db_session.expire_all()
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
assert "image/jpeg" in response.headers["content-type"]
assert len(response.content) > 0
@pytest.mark.asyncio
async def test_thumbnail_unknown_id_returns_404(client):
response = await client.get("/api/v1/i/NotFound/thumbnail")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"

View File

@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
T058 — PATCH with invalid tag → 422 invalid_tag
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
T001 — GET /api/v1/tags?sort=count_desc returns tags ordered highest-count-first
T002 — GET /api/v1/tags?min_count=N excludes tags with image_count < N
"""
import io
import pytest
def _minimal_png() -> bytes:
import struct, zlib
import struct
import zlib
def chunk(name: bytes, data: bytes) -> bytes:
c = name + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
@@ -27,12 +31,14 @@ def _minimal_png() -> bytes:
@pytest.mark.asyncio
async def test_upload_with_tags_persists_tags(client):
async def test_upload_with_tags_persists_tags(authed_client):
client, token = authed_client
data = _minimal_png()
response = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "cat,funny"},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
@@ -40,12 +46,15 @@ async def test_upload_with_tags_persists_tags(client):
@pytest.mark.asyncio
async def test_duplicate_upload_tags_unchanged(client):
async def test_duplicate_upload_tags_unchanged(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "original-tag"},
headers=headers,
)
assert r1.status_code in (200, 201)
original_tags = set(r1.json()["tags"])
@@ -54,6 +63,7 @@ async def test_duplicate_upload_tags_unchanged(client):
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "different-tag"},
headers=headers,
)
assert r2.status_code == 200
assert r2.json()["duplicate"] is True
@@ -61,18 +71,22 @@ async def test_duplicate_upload_tags_unchanged(client):
@pytest.mark.asyncio
async def test_patch_replaces_tag_set(client):
async def test_patch_replaces_tag_set(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("patch-test.png", io.BytesIO(data), "image/png")},
data={"tags": "old-tag"},
headers=headers,
)
image_id = r1.json()["id"]
image_id = r1.json()["short_id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["new-tag", "another"]},
headers=headers,
)
assert patch.status_code == 200
body = patch.json()
@@ -81,17 +95,21 @@ async def test_patch_replaces_tag_set(client):
@pytest.mark.asyncio
async def test_patch_invalid_tag_returns_422(client):
async def test_patch_invalid_tag_returns_422(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
headers=headers,
)
image_id = r1.json()["id"]
image_id = r1.json()["short_id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
headers=headers,
)
assert patch.status_code == 422
body = patch.json()
@@ -99,12 +117,14 @@ async def test_patch_invalid_tag_returns_422(client):
@pytest.mark.asyncio
async def test_list_tags_alphabetical_with_counts(client):
async def test_list_tags_alphabetical_with_counts(authed_client):
client, token = authed_client
data = _minimal_png()
await client.post(
"/api/v1/images",
files={"file": ("tag-list-test.png", io.BytesIO(data), "image/png")},
data={"tags": "zebra,apple"},
headers={"Authorization": f"Bearer {token}"},
)
response = await client.get("/api/v1/tags")
assert response.status_code == 200
@@ -117,12 +137,14 @@ async def test_list_tags_alphabetical_with_counts(client):
@pytest.mark.asyncio
async def test_list_tags_prefix_filter(client):
async def test_list_tags_prefix_filter(authed_client):
client, token = authed_client
data = _minimal_png()
await client.post(
"/api/v1/images",
files={"file": ("prefix-test.png", io.BytesIO(data), "image/png")},
data={"tags": "cat,catfish,caterpillar,dog"},
headers={"Authorization": f"Bearer {token}"},
)
response = await client.get("/api/v1/tags?q=cat")
assert response.status_code == 200
@@ -130,3 +152,70 @@ async def test_list_tags_prefix_filter(client):
for item in body["items"]:
assert item["name"].startswith("cat")
assert not any(item["name"] == "dog" for item in body["items"])
def _unique_png(seed: int) -> bytes:
"""Generate a 1x1 PNG with a seed-determined pixel so each seed produces a distinct hash."""
import struct
import zlib
def chunk(name: bytes, data: bytes) -> bytes:
c = name + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
r, g, b = (seed * 37) % 256, (seed * 53) % 256, (seed * 71) % 256
idat_data = zlib.compress(bytes([0, r, g, b]))
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", idat_data)
+ chunk(b"IEND", b"")
)
@pytest.mark.asyncio
async def test_list_tags_sort_count_desc(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
# popular-sort-tag appears on 2 images, rare-sort-tag on 1 — verify count_desc ordering
for seed in (100, 101):
await client.post(
"/api/v1/images",
files={"file": (f"sort-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
data={"tags": "popular-sort-tag,rare-sort-tag" if seed == 100 else "popular-sort-tag"},
headers=headers,
)
response = await client.get("/api/v1/tags?sort=count_desc")
assert response.status_code == 200
items = response.json()["items"]
sort_items = [i for i in items if i["name"] in ("popular-sort-tag", "rare-sort-tag")]
assert len(sort_items) == 2
# popular-sort-tag (count=2) must come before rare-sort-tag (count=1)
names = [i["name"] for i in sort_items]
assert names.index("popular-sort-tag") < names.index("rare-sort-tag")
# Counts must be non-increasing
counts = [i["image_count"] for i in items]
assert counts == sorted(counts, reverse=True)
@pytest.mark.asyncio
async def test_list_tags_min_count_excludes_below_threshold(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
# common-min-tag appears on 2 images, uncommon-min-tag on 1
for seed in (200, 201):
await client.post(
"/api/v1/images",
files={"file": (f"min-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
data={"tags": "common-min-tag,uncommon-min-tag" if seed == 200 else "common-min-tag"},
headers=headers,
)
# min_count=2 should exclude uncommon-min-tag (count=1) but keep common-min-tag (count=2)
response = await client.get("/api/v1/tags?min_count=2")
assert response.status_code == 200
items = response.json()["items"]
names = [i["name"] for i in items]
assert "common-min-tag" in names
assert "uncommon-min-tag" not in names
# All returned tags must have image_count >= 2
for item in items:
assert item["image_count"] >= 2

View File

@@ -3,10 +3,20 @@ T026 — valid JPEG upload → 201, record in DB, object in MinIO
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
T079GET /api/v1/images/{id} 404 → error envelope shape
T013upload produces short_id; storage_key equals short_id; thumbnail_key = {short_id}-thumb
"""
import io
import re
from unittest.mock import patch
import pytest
from PIL import Image as PILImage
def _real_jpeg(color: tuple = (100, 150, 200), size: tuple = (200, 150)) -> bytes:
buf = io.BytesIO()
PILImage.new("RGB", size, color=color).save(buf, format="JPEG")
return buf.getvalue()
def _minimal_jpeg() -> bytes:
@@ -18,11 +28,13 @@ def _minimal_jpeg() -> bytes:
@pytest.mark.asyncio
async def test_upload_new_image_returns_201(client):
async def test_upload_new_image_returns_201(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
@@ -35,12 +47,15 @@ async def test_upload_new_image_returns_201(client):
@pytest.mark.asyncio
async def test_upload_duplicate_returns_200_with_flag(client):
async def test_upload_duplicate_returns_200_with_flag(authed_client):
client, token = authed_client
data = _minimal_jpeg()
headers = {"Authorization": f"Bearer {token}"}
# First upload
r1 = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
@@ -48,6 +63,7 @@ async def test_upload_duplicate_returns_200_with_flag(client):
r2 = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
body = r2.json()
@@ -56,10 +72,12 @@ async def test_upload_duplicate_returns_200_with_flag(client):
@pytest.mark.asyncio
async def test_upload_invalid_mime_type_returns_422(client):
async def test_upload_invalid_mime_type_returns_422(authed_client):
client, token = authed_client
response = await client.post(
"/api/v1/images",
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 422
body = response.json()
@@ -68,10 +86,12 @@ async def test_upload_invalid_mime_type_returns_422(client):
@pytest.mark.asyncio
async def test_upload_oversized_file_returns_422(client):
async def test_upload_oversized_file_returns_422(authed_client):
import os
from app.config import get_settings
client, token = authed_client
os.environ["MAX_UPLOAD_BYTES"] = "10"
get_settings.cache_clear()
@@ -79,6 +99,7 @@ async def test_upload_oversized_file_returns_422(client):
response = await client.post(
"/api/v1/images",
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 422
body = response.json()
@@ -90,9 +111,139 @@ async def test_upload_oversized_file_returns_422(client):
@pytest.mark.asyncio
async def test_get_unknown_image_returns_404_with_envelope(client):
import uuid
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
response = await client.get("/api/v1/i/NotFound")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
assert "detail" in body
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
@pytest.mark.asyncio
async def test_upload_returns_short_id(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("s1.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert "short_id" in body
assert _SHORT_ID_RE.match(body["short_id"]), f"short_id invalid: {body['short_id']}"
@pytest.mark.asyncio
async def test_upload_storage_key_equals_short_id(authed_client):
client, token = authed_client
data = _real_jpeg(color=(10, 20, 30))
response = await client.post(
"/api/v1/images",
files={"file": ("s2.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert body["storage_key"] == body["short_id"]
@pytest.mark.asyncio
async def test_upload_thumbnail_key_equals_short_id_thumb(authed_client):
client, token = authed_client
data = _real_jpeg(color=(30, 60, 90))
response = await client.post(
"/api/v1/images",
files={"file": ("s3.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
if body["thumbnail_key"] is not None:
assert body["thumbnail_key"] == f"{body['short_id']}-thumb"
@pytest.mark.asyncio
async def test_duplicate_upload_returns_same_short_id(authed_client):
client, token = authed_client
data = _real_jpeg(color=(200, 100, 50))
headers = {"Authorization": f"Bearer {token}"}
r1 = await client.post(
"/api/v1/images",
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
r2 = await client.post(
"/api/v1/images",
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
assert r2.json()["duplicate"] is True
assert r2.json()["short_id"] == r1.json()["short_id"]
@pytest.mark.asyncio
async def test_upload_returns_thumbnail_key(authed_client):
client, token = authed_client
data = _real_jpeg(color=(100, 150, 200))
response = await client.post(
"/api/v1/images",
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert "thumbnail_key" in body
assert body["thumbnail_key"] is not None
assert body["thumbnail_key"].endswith("-thumb")
assert "file_url" in body
assert body["file_url"].startswith("/api/v1/i/")
assert "thumbnail_url" in body
assert body["thumbnail_url"].startswith("/api/v1/i/")
@pytest.mark.asyncio
async def test_duplicate_upload_reuses_thumbnail_key(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _real_jpeg(color=(200, 100, 50))
r1 = await client.post(
"/api/v1/images",
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
r2 = await client.post(
"/api/v1/images",
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
tk1 = r1.json()["thumbnail_key"]
tk2 = r2.json()["thumbnail_key"]
assert tk1 is not None
assert tk1 == tk2
@pytest.mark.asyncio
async def test_upload_succeeds_when_thumbnail_fails(authed_client):
client, token = authed_client
data = _real_jpeg(color=(50, 200, 150))
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
response = await client.post(
"/api/v1/images",
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code in (200, 201)
body = response.json()
assert body["thumbnail_key"] is None
assert "file_url" in body
assert body["file_url"].startswith("/api/v1/i/")
assert body["thumbnail_url"] is None

View File

@@ -1,40 +1,101 @@
import os
import pytest
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"S3_ENDPOINT_URL": "http://localhost:9000",
"S3_BUCKET_NAME": "test-bucket",
"S3_ACCESS_KEY_ID": "key",
"S3_SECRET_ACCESS_KEY": "secret",
"S3_REGION": "us-east-1",
"API_BASE_URL": "http://localhost:8000",
"JWT_SECRET_KEY": "test-secret",
"OWNER_USERNAME": "admin",
"OWNER_PASSWORD": "password",
}
def _apply_env(monkeypatch, extra=None):
for k, v in {**_BASE_ENV, **(extra or {})}.items():
monkeypatch.setenv(k, v)
def test_settings_load_from_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
_apply_env(monkeypatch)
# Import inside test to pick up monkeypatched env
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
assert s.s3_bucket_name == "test-bucket"
assert s.max_upload_bytes == 52428800 # default
assert s.jwt_secret_key == "test-secret"
assert s.jwt_expiry_seconds == 86400 # default
assert s.owner_username == "admin"
def test_settings_max_upload_bytes_override(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.max_upload_bytes == 10485760
def test_settings_jwt_expiry_override(monkeypatch):
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.jwt_expiry_seconds == 3600
def test_api_docs_enabled_default(monkeypatch):
_apply_env(monkeypatch)
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.api_docs_enabled is True
def test_api_docs_enabled_false(monkeypatch):
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.api_docs_enabled is False
def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "not-a-bool"})
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.api_docs_enabled is True

View File

@@ -1,5 +1,6 @@
import hashlib
from app.utils import compute_sha256
from app.utils import compute_sha256, generate_short_id
def test_sha256_known_bytes():
@@ -18,3 +19,24 @@ def test_sha256_returns_64_char_hex():
result = compute_sha256(b"test data")
assert len(result) == 64
assert all(c in "0123456789abcdef" for c in result)
def test_generate_short_id_length():
assert len(generate_short_id()) == 8
def test_generate_short_id_charset():
result = generate_short_id()
assert all(
c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in result
)
def test_generate_short_id_randomness():
assert generate_short_id() != generate_short_id()
def test_generate_short_id_importable():
from app.utils import generate_short_id as fn
assert callable(fn)

View File

@@ -0,0 +1,96 @@
import jwt as pyjwt
import pytest
from fastapi import HTTPException
from app.auth.jwt_provider import JWTAuthProvider
SECRET = "test-secret-key"
USERNAME = "owner"
PASSWORD = "hunter2"
def make_provider(**kwargs) -> JWTAuthProvider:
defaults = dict(
secret_key=SECRET,
expiry_seconds=3600,
owner_username=USERNAME,
owner_password=PASSWORD,
)
return JWTAuthProvider(**{**defaults, **kwargs})
def test_create_token_is_valid_jwt():
provider = make_provider()
token = provider.create_token()
payload = pyjwt.decode(token, SECRET, algorithms=["HS256"])
assert payload["sub"] == "owner"
assert "iat" in payload
assert "exp" in payload
@pytest.mark.asyncio
async def test_get_identity_returns_owner():
provider = make_provider()
token = provider.create_token()
identity = await provider.get_identity(f"Bearer {token}")
assert identity.id == "owner"
assert identity.anonymous is False
@pytest.mark.asyncio
async def test_get_identity_raises_on_expired_token():
provider = make_provider(expiry_seconds=-1)
token = provider.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(f"Bearer {token}")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_wrong_key():
provider = make_provider()
other = make_provider(secret_key="different-secret")
token = other.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(f"Bearer {token}")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_garbage():
provider = make_provider()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity("Bearer not.a.real.token")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_missing_header():
provider = make_provider()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(None)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_missing_bearer_prefix():
provider = make_provider()
token = provider.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(token)
assert exc_info.value.status_code == 401
def test_verify_credentials_true():
provider = make_provider()
assert provider.verify_credentials(USERNAME, PASSWORD) is True
def test_verify_credentials_false_wrong_password():
provider = make_provider()
assert provider.verify_credentials(USERNAME, "wrongpassword") is False
def test_verify_credentials_false_wrong_username():
provider = make_provider()
assert provider.verify_credentials("notowner", PASSWORD) is False

View File

@@ -0,0 +1,110 @@
"""Unit tests for migrate_to_short_ids script logic."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def mock_image_null_short_id():
img = MagicMock()
img.id = "img-uuid-1"
img.short_id = None
img.storage_key = "oldhashkey1234567890"
img.thumbnail_key = "oldhashkey1234567890-thumb"
img.mime_type = "image/jpeg"
return img
@pytest.fixture
def mock_image_with_short_id():
img = MagicMock()
img.id = "img-uuid-2"
img.short_id = "AbCd1234"
img.storage_key = "AbCd1234"
img.thumbnail_key = "AbCd1234-thumb"
img.mime_type = "image/jpeg"
return img
@pytest.mark.asyncio
async def test_migrate_processes_image_without_short_id(mock_image_null_short_id):
"""Images with short_id IS NULL are processed: storage copied, DB updated, old keys deleted."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
storage.get = AsyncMock(return_value=b"imagedata")
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
old_key = mock_image_null_short_id.storage_key
new_short_id = "NewSh123"
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value=new_short_id):
result = await migrate_image(mock_image_null_short_id, storage, session)
assert result is True
storage.put.assert_any_call(new_short_id, b"imagedata", "image/jpeg")
storage.delete.assert_any_call(old_key)
@pytest.mark.asyncio
async def test_migrate_skips_image_with_short_id(mock_image_with_short_id):
"""Images that already have a short_id are skipped."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
session = MagicMock()
result = await migrate_image(mock_image_with_short_id, storage, session)
assert result is False
storage.get.assert_not_called() if hasattr(storage.get, "assert_not_called") else None
@pytest.mark.asyncio
async def test_migrate_continues_on_storage_error(mock_image_null_short_id):
"""If storage copy fails, error is logged and migrate_image returns False without aborting."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
storage.get = AsyncMock(side_effect=Exception("storage read error"))
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="ErrSh123"):
result = await migrate_image(mock_image_null_short_id, storage, session)
assert result is False
storage.put.assert_not_called()
@pytest.mark.asyncio
async def test_migrate_summary_counts(mock_image_null_short_id, mock_image_with_short_id):
"""run_migration reports correct migrated and skipped counts."""
from scripts.migrate_to_short_ids import run_migration
storage = MagicMock()
storage.get = AsyncMock(return_value=b"data")
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
images = [mock_image_null_short_id, mock_image_with_short_id]
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="NewSh999"):
migrated, skipped, failed = await run_migration(images, storage, session)
assert migrated == 1
assert skipped == 1
assert failed == 0

View File

@@ -0,0 +1,105 @@
import ipaddress
from unittest.mock import MagicMock
from starlette.requests import Request
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
# ---------------------------------------------------------------------------
# LoginRateLimiter tests
# ---------------------------------------------------------------------------
def make_limiter():
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
def test_not_blocked_initially():
assert make_limiter().is_blocked("1.2.3.4") is False
def test_blocked_after_threshold():
limiter = make_limiter()
for _ in range(3):
limiter.record_failure("1.2.3.4")
assert limiter.is_blocked("1.2.3.4") is True
def test_success_clears_failures():
limiter = make_limiter()
limiter.record_failure("1.2.3.4")
limiter.record_failure("1.2.3.4")
limiter.record_success("1.2.3.4")
assert limiter.is_blocked("1.2.3.4") is False
def test_ips_are_isolated():
limiter = make_limiter()
for _ in range(3):
limiter.record_failure("1.1.1.1")
assert limiter.is_blocked("2.2.2.2") is False
def test_window_resets_after_expiry():
import time
limiter = LoginRateLimiter(max_failures=3, window_seconds=0, cooldown_seconds=300)
limiter.record_failure("1.2.3.4")
limiter.record_failure("1.2.3.4")
time.sleep(0.01)
limiter.record_failure("1.2.3.4")
# window expired — counter reset on third call, so failures = 1, not 3
assert limiter.is_blocked("1.2.3.4") is False
def test_log_warning_on_lockout(caplog):
import logging
limiter = make_limiter()
with caplog.at_level(logging.WARNING, logger="app.auth.rate_limiter"):
for _ in range(3):
limiter.record_failure("5.6.7.8")
assert "Login blocked" in caplog.text
assert "5.6.7.8" in caplog.text
# ---------------------------------------------------------------------------
# get_client_ip tests
# ---------------------------------------------------------------------------
def make_request(peer: str, headers: dict) -> MagicMock:
req = MagicMock(spec=Request)
req.client.host = peer
req.headers = headers
return req
def test_get_client_ip_no_trusted_networks_returns_peer():
req = make_request("203.0.113.1", {"X-Forwarded-For": "10.0.0.1"})
assert get_client_ip(req, []) == "203.0.113.1"
def test_get_client_ip_trusted_peer_uses_real_ip():
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.9"
def test_get_client_ip_real_ip_wins_over_xff():
# Regression: spoofed XFF must not override nginx-set X-Real-IP.
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9", "X-Forwarded-For": "1.2.3.4"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.9"
def test_get_client_ip_untrusted_peer_ignores_xff():
req = make_request("8.8.8.8", {"X-Forwarded-For": "203.0.113.5"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "8.8.8.8"
def test_get_client_ip_trusted_peer_falls_back_to_xff_when_no_real_ip():
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.5"

View File

@@ -0,0 +1,59 @@
"""Unit tests for short_id generation, validation, and repository lookup."""
import re
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from app.routers.images import _validate_short_id
from app.utils import generate_short_id
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
def test_validate_short_id_accepts_valid():
_validate_short_id("AbCd1234") # must not raise
def test_validate_short_id_rejects_too_long():
with pytest.raises(HTTPException) as exc:
_validate_short_id("toolong!!")
assert exc.value.status_code == 422
def test_validate_short_id_rejects_too_short():
with pytest.raises(HTTPException) as exc:
_validate_short_id("short")
assert exc.value.status_code == 422
def test_validate_short_id_rejects_invalid_chars():
with pytest.raises(HTTPException) as exc:
_validate_short_id("has spa!")
assert exc.value.status_code == 422
def test_generate_short_id_unique():
ids = {generate_short_id() for _ in range(100)}
assert len(ids) > 90 # collision in 100 draws would be astronomically unlikely
def test_repo_get_by_short_id_uses_correct_field():
"""get_by_short_id selects on Image.short_id, not Image.id."""
import asyncio
from app.repositories.image_repo import ImageRepository
mock_session = MagicMock()
scalar = MagicMock()
scalar.scalar_one_or_none = MagicMock(return_value=None)
mock_session.execute = AsyncMock(return_value=scalar)
repo = ImageRepository(mock_session)
asyncio.get_event_loop().run_until_complete(repo.get_by_short_id("AbCd1234"))
call_args = mock_session.execute.call_args[0][0]
compiled = call_args.compile(compile_kwargs={"literal_binds": True})
assert "short_id" in str(compiled)
assert "AbCd1234" in str(compiled)

View File

@@ -2,16 +2,21 @@
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
T038 — tag validation: rejects names > 64 chars, invalid chars
"""
import pytest
from app.repositories.tag_repo import TagRepository
@pytest.mark.parametrize("raw,expected", [
("Cat", "cat"),
(" funny ", "funny"),
("REACTION", "reaction"),
(" MiXeD ", "mixed"),
])
@pytest.mark.parametrize(
"raw,expected",
[
("Cat", "cat"),
(" funny ", "funny"),
("REACTION", "reaction"),
(" MiXeD ", "mixed"),
],
)
def test_normalise_lowercases_and_strips(raw, expected):
assert TagRepository.normalise(raw) == expected

View File

@@ -0,0 +1,80 @@
"""Unit tests for thumbnail generation utility."""
import io
from PIL import Image as PILImage
from app.thumbnail import generate_thumbnail
def _make_jpeg(width: int, height: int) -> bytes:
buf = io.BytesIO()
img = PILImage.new("RGB", (width, height), color=(128, 64, 32))
img.save(buf, format="JPEG", quality=80)
return buf.getvalue()
def _make_png_rgba(width: int, height: int) -> bytes:
buf = io.BytesIO()
img = PILImage.new("RGBA", (width, height), color=(10, 20, 30, 180))
img.save(buf, format="PNG")
return buf.getvalue()
def _make_gif(width: int, height: int) -> bytes:
buf = io.BytesIO()
img = PILImage.new("P", (width, height))
img.save(buf, format="GIF")
return buf.getvalue()
def test_thumbnail_is_webp():
data = _make_jpeg(600, 400)
result = generate_thumbnail(data, "image/jpeg")
assert result[:4] == b"RIFF"
assert result[8:12] == b"WEBP"
def test_thumbnail_fits_within_400px():
data = _make_jpeg(800, 600)
result = generate_thumbnail(data, "image/jpeg")
img = PILImage.open(io.BytesIO(result))
w, h = img.size
assert w <= 400
assert h <= 400
def test_thumbnail_preserves_aspect_ratio():
original_w, original_h = 800, 300
data = _make_jpeg(original_w, original_h)
result = generate_thumbnail(data, "image/jpeg")
img = PILImage.open(io.BytesIO(result))
w, h = img.size
original_ratio = original_w / original_h
thumb_ratio = w / h
assert abs(original_ratio - thumb_ratio) / original_ratio < 0.01
def test_thumbnail_handles_gif_first_frame():
data = _make_gif(500, 500)
result = generate_thumbnail(data, "image/gif")
assert result[8:12] == b"WEBP"
img = PILImage.open(io.BytesIO(result))
assert not getattr(img, "is_animated", False)
def test_thumbnail_handles_png_with_alpha():
data = _make_png_rgba(300, 300)
result = generate_thumbnail(data, "image/png")
assert result[8:12] == b"WEBP"
img = PILImage.open(io.BytesIO(result))
assert img.format == "WEBP"
def test_thumbnail_does_not_upscale():
data = _make_jpeg(100, 100)
result = generate_thumbnail(data, "image/jpeg")
img = PILImage.open(io.BytesIO(result))
w, h = img.size
assert w <= 100
assert h <= 100

View File

@@ -0,0 +1,72 @@
import uuid
from unittest.mock import MagicMock
from app.routers.images import _image_to_dict
def _make_image(*, thumbnail_key=None):
img = MagicMock()
img.id = uuid.UUID("00000000-0000-0000-0000-000000000001")
img.short_id = "AbCd1234"
img.hash = "abc123"
img.filename = "test.jpg"
img.mime_type = "image/jpeg"
img.size_bytes = 1024
img.width = 100
img.height = 100
img.storage_key = "abc123storagekey"
img.thumbnail_key = thumbnail_key
img.created_at.isoformat.return_value = "2026-05-09T00:00:00"
img.tags = []
return img
def test_cdn_configured_with_thumbnail():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
assert result["short_id"] == "AbCd1234"
def test_cdn_configured_no_thumbnail():
img = _make_image(thumbnail_key=None)
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] is None
assert result["short_id"] == "AbCd1234"
def test_no_cdn_with_thumbnail():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base=None)
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
assert result["thumbnail_url"] == "/api/v1/i/AbCd1234/thumbnail"
def test_no_cdn_no_thumbnail():
img = _make_image(thumbnail_key=None)
result = _image_to_dict(img, cdn_base=None)
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
assert result["thumbnail_url"] is None
def test_cdn_trailing_slash_normalised():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base="https://cdn.example.com/")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
assert "//" not in result["file_url"].replace("https://", "")
def test_cdn_trailing_whitespace_normalised():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base="https://cdn.example.com ")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
def test_short_id_in_response():
img = _make_image()
result = _image_to_dict(img, cdn_base=None)
assert result["short_id"] == "AbCd1234"

View File

@@ -1,5 +1,6 @@
import pytest
from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]

1594
api/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

67
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,67 @@
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: reactbin
POSTGRES_PASSWORD: reactbin
POSTGRES_DB: reactbin_test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactbin"]
interval: 5s
timeout: 5s
retries: 5
minio-test:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9002:9000"
- "9003:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
minio-init-test:
image: minio/mc:latest
depends_on:
minio-test:
condition: service_healthy
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
entrypoint: >
/bin/sh -c "
mc alias set local http://minio-test:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/reactbin-test
"
api-test:
build:
context: ./api
environment:
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
S3_ENDPOINT_URL: http://minio-test:9000
S3_BUCKET_NAME: reactbin-test
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_REGION: us-east-1
JWT_SECRET_KEY: test-secret-key-for-testing-only
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpassword
API_BASE_URL: http://localhost:8000
MAX_UPLOAD_BYTES: "52428800"
depends_on:
postgres-test:
condition: service_healthy
minio-init-test:
condition: service_completed_successfully
command: ["python", "-m", "pytest", "tests/", "-v"]
working_dir: /app

52
k8s/api/deployment.yaml Normal file
View File

@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: reactbin
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
initContainers:
- name: migrate
image: git.juggalol.com/juggalol/reactbin-api:v1.4.3
command: ["alembic", "upgrade", "head"]
workingDir: /app
envFrom:
- secretRef:
name: api-env
securityContext:
runAsNonRoot: true
runAsUser: 1001
containers:
- name: api
image: git.juggalol.com/juggalol/reactbin-api:v1.4.3
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: api-env
env:
- name: API_DOCS_ENABLED
value: "false"
livenessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
runAsNonRoot: true
runAsUser: 1001

13
k8s/api/service.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: api
namespace: reactbin
spec:
type: ClusterIP
selector:
app: api
ports:
- name: http
port: 8000
targetPort: 8000

34
k8s/ingress.yaml Normal file
View File

@@ -0,0 +1,34 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: reactbin
namespace: reactbin
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "52m"
spec:
ingressClassName: nginx-public
tls:
- hosts:
- reactbin.juggalol.com
secretName: reactbin-tls
rules:
- host: reactbin.juggalol.com
http:
paths:
- path: /api/
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: ui
port:
number: 8080

24
k8s/minio/init-job.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: batch/v1
kind: Job
metadata:
name: minio-init
namespace: reactbin
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: mc
image: minio/mc:latest
# mc runs as root by default; FR-013 exception documented in spec
securityContext:
runAsNonRoot: false
command:
- sh
- -c
- |
mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc mb --ignore-existing local/reactbin
envFrom:
- secretRef:
name: minio-credentials

16
k8s/minio/service.yaml Normal file
View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: reactbin
spec:
type: ClusterIP
selector:
app: minio
ports:
- name: api
port: 9000
targetPort: 9000
- name: console
port: 9001
targetPort: 9001

View File

@@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: minio
namespace: reactbin
spec:
serviceName: minio
replicas: 1
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: minio
image: minio/minio:latest
args:
- server
- /data
- --console-address
- ":9001"
ports:
- containerPort: 9000
- containerPort: 9001
envFrom:
- secretRef:
name: minio-credentials
livenessProbe:
httpGet:
path: /minio/health/live
port: 9000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /minio/health/ready
port: 9000
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

4
k8s/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: reactbin

29
k8s/ui/deployment.yaml Normal file
View File

@@ -0,0 +1,29 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ui
namespace: reactbin
spec:
replicas: 1
selector:
matchLabels:
app: ui
template:
metadata:
labels:
app: ui
spec:
containers:
- name: ui
image: git.juggalol.com/juggalol/reactbin-ui:v1.4.3
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 101 # nginxinc/nginx-unprivileged default UID

13
k8s/ui/service.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: ui
namespace: reactbin
spec:
type: ClusterIP
selector:
app: ui
ports:
- name: http
port: 8080
targetPort: 8080

18
k8s/vault/api-secret.yaml Normal file
View File

@@ -0,0 +1,18 @@
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: api-secret
namespace: reactbin
spec:
vaultAuthRef: reactbin-vault-auth
mount: kv
type: kv-v2
# Required Vault keys at this path:
# DATABASE_URL, JWT_SECRET_KEY, OWNER_USERNAME, OWNER_PASSWORD,
# S3_ENDPOINT_URL, S3_BUCKET_NAME, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY,
# API_BASE_URL
path: reactbin/api/config
refreshAfter: 1h
destination:
name: api-env
create: true

View File

@@ -0,0 +1,16 @@
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: minio-secret
namespace: reactbin
spec:
vaultAuthRef: reactbin-vault-auth
mount: kv
type: kv-v2
# Required Vault keys at this path:
# MINIO_ROOT_USER, MINIO_ROOT_PASSWORD
path: reactbin/minio/credentials
refreshAfter: 1h
destination:
name: minio-credentials
create: true

22
k8s/vault/vault-auth.yaml Normal file
View File

@@ -0,0 +1,22 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: vso-reactbin
namespace: reactbin
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: reactbin-vault-auth
namespace: reactbin
spec:
method: kubernetes
mount: kubernetes
kubernetes:
# The operator must create this role in Vault and bind it to the
# default service account in the reactbin namespace with read access
# to both reactbin/api/config and reactbin/minio/credentials.
role: vso-reactbin
serviceAccount: vso-reactbin
audiences:
- vault

67
scripts/test_lockout.sh Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Test reactbin's login rate limiter and demonstrate the XFF injection bypass.
#
# Phase 1: Send 6 bad login attempts in quick succession.
# Attempts 1-5 should return 401 (invalid credentials).
# Attempt 6 should return 429 (rate limited) — the limiter blocks after
# max_failures=5 within the window.
#
# Phase 2: Send a 7th bad attempt with a spoofed X-Forwarded-For header
# pointing at a different IP. If the lockout keys correctly on the trusted
# client IP, this should still return 429 (same client, still locked).
# If reactbin trusts client-supplied XFF blindly, this would return 401
# instead — the spoof would make the request look like a different client
# that hasn't accumulated failures.
#
# Interpretation:
# - 429 on attempt 7 → lockout is correctly identifying the client
# - 401 on attempt 7 → XFF injection succeeded; server treated us as a
# new client because we set a fake XFF
#
# Note: this script is ONLY useful when run against the public origin path
# where XFF spoofing is potentially possible. It does not exercise the
# Cloudflare-proxied path because Cloudflare strips/replaces XFF before
# forwarding to origin.
set -u
URL="${URL:-https://reactbin.juggalol.com/api/v1/auth/token}"
SPOOFED_IP="${SPOOFED_IP:-198.51.100.99}" # TEST-NET-2, never routed
USERNAME="${USERNAME:-not-a-real-user}"
PASSWORD="${PASSWORD:-not-a-real-password}"
# JSON body for a bad login. Username/password chosen to be obviously fake;
# adjust if your auth provider has its own validation that would 400 instead
# of 401 on these values.
BODY=$(printf '{"username":"%s","password":"%s"}' "$USERNAME" "$PASSWORD")
echo "Target: $URL"
echo "Body: $BODY"
echo
echo "=== Phase 1: 6 bad logins from real client IP ==="
for i in 1 2 3 4 5 6; do
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
--data "$BODY" \
"$URL")
echo "Attempt $i: HTTP $code"
done
echo
echo "=== Phase 2: 7th attempt with spoofed X-Forwarded-For ==="
echo "Setting X-Forwarded-For: $SPOOFED_IP"
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-H "X-Forwarded-For: $SPOOFED_IP" \
--data "$BODY" \
"$URL")
echo "Attempt 7: HTTP $code"
echo
echo "Interpretation:"
echo " Attempt 7 = 429 → lockout correctly tracks real client; XFF spoof ineffective"
echo " Attempt 7 = 401 → XFF spoof succeeded; server believed the fake client IP"

View File

View File

View File

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Upload Thumbnails
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
All checklist items pass. Spec is ready for `/speckit-plan`.

View File

@@ -0,0 +1,90 @@
# API Contract: Upload Thumbnails
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
---
## New endpoint
### `GET /api/v1/images/{image_id}/thumbnail`
Returns the thumbnail content for the given image. If no thumbnail was generated
(image pre-dates the feature or generation failed), falls back to the full-size
original.
**Path parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `image_id` | UUID | Unique identifier of the image |
**Responses**
#### `200 OK` — Thumbnail (or original fallback) content
| Header | Value | Notes |
|--------|-------|-------|
| `Content-Type` | `image/webp` | Always WebP when a thumbnail exists; original `mime_type` when falling back to the original |
| `ETag` | `"{sha256-hex}"` | Same hash as the original image — content is immutable |
| `Cache-Control` | `public, max-age=31536000, immutable` | Safe: thumbnail content never changes |
Body: raw image bytes (WebP thumbnail, or original bytes as fallback).
#### `404 Not Found` — Image not found
```json
{ "detail": "Image not found", "code": "image_not_found" }
```
#### `500 Internal Server Error` — Storage retrieval failure
```json
{ "detail": "Failed to retrieve image content", "code": "storage_error" }
```
---
## Changed endpoint: `POST /api/v1/images`
The upload response body gains one new field:
| Field | Type | Notes |
|-------|------|-------|
| `thumbnail_key` | `string \| null` | S3 key of the generated thumbnail. `null` if generation failed. |
All existing fields are unchanged.
**Example response** (new field only shown):
```json
{
"id": "...",
"thumbnail_key": "abc123…-thumb",
...
}
```
---
## Changed endpoint: `GET /api/v1/images` and `GET /api/v1/images/{id}`
Both metadata responses gain the same `thumbnail_key` field (`string | null`).
---
## UI contract
The Angular `ImageService` gains one new method:
```
getThumbnailUrl(id: string): string
→ '/api/v1/images/{id}/thumbnail'
```
The `ImageRecord` interface gains:
```
thumbnail_key: string | null;
```
The library grid component uses `getThumbnailUrl(image.id)` as the `src` for
every grid cell. The detail component continues using `getFileUrl(image.id)`.

View File

@@ -0,0 +1,79 @@
# Data Model: Upload Thumbnails
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
## Schema change: `images` table
One nullable column is added to the existing `images` table.
| Column | Type | Nullable | Default | Notes |
|--------|------|----------|---------|-------|
| `thumbnail_key` | `VARCHAR(70)` | YES | `NULL` | S3 object key for the WebP thumbnail. `NULL` = no thumbnail available (generation failed or pre-dates this feature). Derived value: `{image.hash}-thumb`. |
No other tables change. No new tables are added.
### Migration
**File**: `api/alembic/versions/002_add_thumbnail_key.py`
```
upgrade: ALTER TABLE images ADD COLUMN thumbnail_key VARCHAR(70);
downgrade: ALTER TABLE images DROP COLUMN thumbnail_key;
```
---
## ORM model change: `Image`
`api/app/models.py``Image` class gains one field:
```
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
```
---
## New module: `api/app/thumbnail.py`
Contains the thumbnail generation logic. Not a model, but documented here because
it defines the thumbnail's shape:
| Aspect | Value |
|--------|-------|
| Output format | WebP |
| Max dimension (longest side) | 400 px |
| Aspect ratio | Preserved (never upscaled) |
| Source formats supported | JPEG, PNG, GIF (frame 0), WebP |
| Key signature | `async def generate_thumbnail(data: bytes, mime_type: str) -> bytes` |
---
## API response shape change
`_image_to_dict()` in `api/app/routers/images.py` adds `"thumbnail_key"` to its
output so the UI can determine whether a thumbnail is available:
```json
{
"id": "...",
"hash": "...",
"thumbnail_key": "abc123...-thumb", new (null if no thumbnail)
...
}
```
The UI uses the presence of `thumbnail_key` to decide whether to call
`/api/v1/images/{id}/thumbnail` (with thumbnail) or fall back to
`/api/v1/images/{id}/file` (without). In practice the endpoint itself
handles the fallback, so the UI can always call `/thumbnail`.
---
## Storage objects per image (after this feature)
| Object | Key | Format | Created at |
|--------|-----|--------|-----------|
| Original | `{sha256_hash}` | Original mime_type | Upload |
| Thumbnail | `{sha256_hash}-thumb` | `image/webp` | Upload (same request) |
Thumbnail object is deleted alongside original on image deletion.

View File

@@ -0,0 +1,246 @@
# Implementation Plan: Upload Thumbnails
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/003-upload-thumbnails/spec.md`
## Summary
When an image is uploaded, generate a WebP thumbnail (longest side ≤ 400 px,
aspect ratio preserved) and store it in S3 alongside the original. Add a
`GET /api/v1/images/{id}/thumbnail` endpoint that serves the thumbnail (or falls
back to the original for images that pre-date the feature). The Angular library
grid switches from `/file` to `/thumbnail`. The detail page is unchanged.
Changes span: a new Pillow dependency, a new `thumbnail.py` utility module, one
Alembic migration, the upload and delete routes, a new thumbnail serve route, and
the Angular image service and library component.
## Technical Context
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, Pillow (new — thumbnail generation), aiobotocore,
SQLAlchemy 2.x async, Alembic, Angular
**Storage**: S3-compatible object storage via `StorageBackend.put()` and `.get()`;
thumbnails stored at key `{sha256_hash}-thumb` in the same bucket
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
**Project Type**: Web application — FastAPI API + Angular SPA
**Performance Goals**: 20-image grid transfers ≥ 80% less data than full-size;
first page of 1,000-image library loads in under 2 s
**Constraints**: Thumbnail generation is synchronous within the upload request;
thumbnail failure must not block upload success; no backfill of existing images in v1
**Scale/Scope**: Single-user personal application; upload frequency low
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
| Principle | Check | Status |
|-----------|-------|--------|
| §2.1 Separation of concerns | `thumbnail.py` owns resize logic; router owns orchestration; UI knows nothing about S3 keys | ✅ |
| §2.2 Dependency direction | UI → API → Storage; thumbnail stored via `StorageBackend`; no upward imports | ✅ |
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend.put()` and `.get()`; no raw S3 SDK calls in routes or `thumbnail.py` | ✅ |
| §2.4 Auth abstraction | No change to auth flow | ✅ |
| §2.5 DB abstraction | New `thumbnail_key` column accessed only through `ImageRepository`; migration added | ✅ |
| §2.6 No speculative abstraction | `thumbnail.py` is a concrete module-level function; no interface added because one implementation exists | ✅ |
| §3.1 API versioning | New route at `/api/v1/images/{id}/thumbnail` | ✅ |
| §3.3 Error shape | `image_not_found` and `storage_error` codes used consistently | ✅ |
| §4.2 Images immutable after upload | Thumbnail is generated at upload time only; never mutated | ✅ |
| §4.3 Dedup by hash | Duplicate upload returns existing record including existing `thumbnail_key`; no re-generation | ✅ |
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
| §5.2 Test pyramid | Unit test for `thumbnail.py`; integration tests for new route + upload + delete | ✅ |
| §5.3 Test colocation | API tests in `api/tests/`; Angular spec files colocated with components | ✅ |
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
| §7.2 Env configuration | No new env vars; Pillow is a build dependency, not a runtime config | ✅ |
| §8 Scope boundaries | Backfill of existing images, multiple thumbnail sizes, animated WebP — all deferred | ✅ |
**Post-design re-check**: All gates still pass after Phase 1 design.
## Project Structure
### Documentation (this feature)
```text
specs/003-upload-thumbnails/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 decisions
├── data-model.md # Schema and module changes
├── contracts/
│ └── api.md # New endpoint + changed response shapes
├── checklists/
│ └── requirements.md # Spec quality checklist
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
```
### Files changed or created
```text
api/
├── pyproject.toml # Add Pillow dependency
├── app/
│ ├── thumbnail.py # NEW — generate_thumbnail()
│ ├── models.py # Add thumbnail_key column to Image
│ ├── repositories/
│ │ └── image_repo.py # Pass thumbnail_key on create
│ └── routers/
│ └── images.py # Upload: generate+store thumbnail
│ # Delete: remove thumbnail
│ # New route: GET /images/{id}/thumbnail
│ # _image_to_dict: add thumbnail_key field
└── alembic/
└── versions/
└── 002_add_thumbnail_key.py # NEW migration
ui/
└── src/
└── app/
├── services/
│ └── image.service.ts # Add getThumbnailUrl(); add thumbnail_key to ImageRecord
└── library/
└── library.component.ts # Use getThumbnailUrl() for grid image src
```
## Milestones
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
> the failing test(s) first, confirm they fail, then implement until they pass.
---
### M1 — Thumbnail generation utility
**Goal**: A tested, self-contained function that produces a WebP thumbnail from
raw image bytes.
**Deliverables**:
- Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`
- Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`:
- Open image bytes with Pillow
- Seek to frame 0 (handles animated GIFs)
- Convert mode as needed for WebP output
- Resize to fit within 400×400 using LANCZOS resampling (never upscale)
- Encode as WebP quality 80 and return bytes
- Unit tests in `api/tests/unit/test_thumbnail.py`:
- `test_thumbnail_is_webp` — output starts with WebP magic bytes
- `test_thumbnail_fits_within_400px` — both dimensions ≤ 400
- `test_thumbnail_preserves_aspect_ratio` — ratio within 1% of original
- `test_thumbnail_handles_gif_first_frame` — GIF input produces static WebP
- `test_thumbnail_handles_png_with_alpha` — RGBA PNG produces valid WebP
- `test_thumbnail_does_not_upscale` — 100×100 image stays ≤ 100×100
**Done criterion**: All unit tests pass; `ruff check api/` passes.
---
### M2 — Database migration
**Goal**: The `images` table has a nullable `thumbnail_key` column; the ORM model
and repository reflect it.
**Deliverables**:
- `api/alembic/versions/002_add_thumbnail_key.py``upgrade` adds
`VARCHAR(70) NULLABLE` column; `downgrade` drops it
- `api/app/models.py`: add `thumbnail_key: Mapped[str | None]` mapped to
`String(70)`, `nullable=True`, `default=None`
- `api/app/repositories/image_repo.py`: add `thumbnail_key: str | None = None`
parameter to `create()`; persist it on the new `Image` instance
**Done criterion**: `alembic upgrade head` runs cleanly inside Docker; all
existing 46 integration tests still pass (new column is nullable, no existing
test breaks).
---
### M3 — Upload route: generate and store thumbnail
**Goal**: Every new upload generates a thumbnail; duplicates reuse the existing
record; failures are tolerated without blocking the upload.
**TDD first** — new tests in `api/tests/integration/test_upload.py`:
- `test_upload_returns_thumbnail_key` — upload response includes non-null
`thumbnail_key` ending in `-thumb`
- `test_duplicate_upload_reuses_thumbnail_key` — second upload of the same
file returns the same `thumbnail_key` as the first
- `test_upload_succeeds_when_thumbnail_fails` — patch `generate_thumbnail` to
raise, upload returns 200/201 with `thumbnail_key: null`
**Implementation** in `api/app/routers/images.py` `upload_image()`:
1. After `await storage.put(hash_hex, data, mime_type)`, attempt thumbnail generation
and storage in a try/except; catch any exception and leave `thumbnail_key` as `None`
2. Call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)`
`generate_thumbnail` is CPU-bound (Pillow); `asyncio.to_thread` runs it in the
default thread pool executor so it does not block the async event loop
3. Pass `thumbnail_key` to `image_repo.create()`
4. Add `"thumbnail_key": image.thumbnail_key` to `_image_to_dict()`
**Done criterion**: New tests pass; all 46 existing tests pass.
---
### M4 — New `GET /api/v1/images/{id}/thumbnail` endpoint
**Goal**: Clients fetch thumbnail content; falls back to original if no thumbnail exists.
**TDD first** — new tests in `api/tests/integration/test_serving.py`:
- `test_thumbnail_returns_webp` — upload image, call `/thumbnail`, assert
200, `content-type: image/webp`, ETag, `Cache-Control` with immutable
- `test_thumbnail_fallback_returns_original` — set `thumbnail_key=None` on a
record, call `/thumbnail`, assert 200 with original mime_type
- `test_thumbnail_unknown_id_returns_404` — unknown UUID → 404 `image_not_found`
**Implementation**: new route `GET /images/{image_id}/thumbnail` in
`api/app/routers/images.py` using `image.thumbnail_key or image.storage_key`
to select the key, and `"image/webp" if image.thumbnail_key else image.mime_type`
for the content type. Same `ETag` + `Cache-Control` headers as `/file`.
**Done criterion**: All new tests pass; all existing tests pass.
---
### M5 — Delete route: remove thumbnail from storage
**Goal**: Deleting an image also removes its thumbnail; no orphaned objects left.
**TDD first** — new test in `api/tests/integration/test_delete.py`:
- `test_delete_removes_thumbnail` — upload image, delete it, then verify
`GET /images/{id}/thumbnail` returns 404
**Implementation** in `api/app/routers/images.py` `delete_image()`: after
deleting the DB record and the original object, call `await storage.delete(image.thumbnail_key)`
if `image.thumbnail_key` is not None.
**Done criterion**: New test passes; all existing delete tests pass.
---
### M6 — UI: library grid uses thumbnail endpoint
**Goal**: Library grid fetches thumbnails instead of full-size originals; detail
page is unchanged.
**Deliverables**:
- `ui/src/app/services/image.service.ts`:
- Add `thumbnail_key: string | null` to `ImageRecord` interface
- Add `getThumbnailUrl(id: string): string` returning `/api/v1/images/${id}/thumbnail`
- `ui/src/app/library/library.component.ts` + template: replace
`getFileUrl(image.id)` with `getThumbnailUrl(image.id)` for grid `<img src>`
- Update Angular spec files: add `thumbnail_key: null` to all `ImageRecord`
mock objects
- Verify `ng test` passes and `ng build` succeeds
**Done criterion**: Angular build clean; all Angular tests pass; library grid
`<img>` elements reference `/thumbnail` not `/file`.
## Post-design Constitution Re-check
| Principle | Verdict |
|-----------|---------|
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend`; `thumbnail.py` never touches S3 directly | ✅ |
| §2.5 DB abstraction | `thumbnail_key` persisted only through `ImageRepository.create()` | ✅ |
| §2.6 No speculative abstraction | One concrete function; no interface | ✅ |
| §4.2 Immutability | Thumbnail written once at upload; never mutated | ✅ |
| §5.1 TDD | Failing tests written before each milestone's implementation | ✅ |
All gates pass. Feature is ready for `/speckit-tasks`.

View File

@@ -0,0 +1,130 @@
# Research: Upload Thumbnails
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
## Decision 1: Image processing library
**Decision**: Add `Pillow` as a runtime dependency to the API.
**Rationale**: Pillow is the standard Python image processing library. It supports
reading JPEG, PNG, GIF (frame extraction), and WebP, and can encode output as WebP.
It handles aspect-ratio-preserving resize natively via `Image.thumbnail()`. No
other dependency is needed.
**Alternatives considered**:
- `wand` (ImageMagick binding): More powerful but much heavier; overkill for a
fixed-size resize operation.
- `opencv-python`: ML-focused, large binary; not justified for simple resize.
- Pure `aiobotocore` + external service: Adds operational complexity with no benefit
over a local library for a single-user app.
---
## Decision 2: Thumbnail dimensions and format
**Decision**: Longest side ≤ 400 px, WebP output, aspect ratio preserved. This
matches FR-003 and FR-004 exactly and the user's stated preference.
**Rationale**: WebP produces smaller files than JPEG/PNG at equivalent visual quality.
400 px covers a typical grid thumbnail at 1× and 2× display density without being
oversized. Pillow's `Image.thumbnail((400, 400))` implements this constraint directly
(it shrinks to fit within the bounding box, never upscaling).
**Alternatives considered**:
- JPEG thumbnails: Larger file sizes; no alpha channel support.
- Multiple sizes: Out of scope for v1 per spec Assumptions.
- On-demand resize: Rejected by user in favour of pre-generation.
---
## Decision 3: Thumbnail storage key convention
**Decision**: `{sha256_hash}-thumb` (e.g., the 64-char hash hex string + literal
`-thumb`, giving a 70-char key). Stored under the same S3 bucket as originals.
**Rationale**: Deterministic from the image hash — no new random state needed and the
key can always be reconstructed from the `Image.hash` field. The `-thumb` suffix
clearly distinguishes it from the original key. Fits within a `String(70)` column.
**Alternatives considered**:
- Separate bucket for thumbnails: More complex bucket policy management with no benefit
for a single-user app.
- UUID-based key: Non-deterministic; requires an extra DB round-trip to look up.
- `{hash}/thumb.webp` (path prefix): Works, but adds key parsing complexity for no gain.
---
## Decision 4: Database schema change
**Decision**: Add a nullable `thumbnail_key: String(70)` column to the `images`
table. `NULL` means no thumbnail exists (either generation failed or the image
pre-dates this feature). Add a new Alembic migration `002_add_thumbnail_key.py`.
**Rationale**: Explicitly tracking the thumbnail key in the DB makes the "does a
thumbnail exist?" question a simple `IS NOT NULL` check rather than an S3 head
request. Also allows the delete route to skip the thumbnail delete if the column
is NULL, avoiding a storage error for legacy images.
**Alternatives considered**:
- Derive key at runtime from `image.hash + "-thumb"` without a DB column: Simpler but
means no way to distinguish "thumbnail was generated" from "thumbnail was never
attempted", and delete would need a conditional S3 head request.
- Separate `thumbnails` table: Over-engineered; one thumbnail per image with no
additional attributes doesn't warrant its own table.
---
## Decision 5: Where thumbnail generation lives in the code
**Decision**: A standalone async function `generate_thumbnail(data: bytes, mime_type: str) -> bytes`
in a new module `api/app/thumbnail.py`. Called from the upload route after the original
is stored, before the Image record is created.
**Rationale**: Keeps the thumbnail logic self-contained and independently testable.
The upload route calls it but doesn't own it. Constitution §2.6 allows concrete
functions when no second implementation is needed — no abstract interface is warranted.
**Alternatives considered**:
- Method on `StorageBackend`: Wrong layer; storage knows nothing about image content.
- Inline in the upload route: Makes the route harder to test and read.
- A `ThumbnailService` class: No justification for a class when a module-level function suffices.
---
## Decision 6: Failure handling during upload
**Decision**: If `generate_thumbnail()` raises, log the exception, set `thumbnail_key`
to `NULL` on the Image record, and continue. The upload response succeeds. The
`GET /api/v1/images/{id}/thumbnail` endpoint falls back to the original when
`thumbnail_key` is NULL (FR-009).
**Rationale**: A thumbnail failure should not block the upload — the user still gets
their image in the library. The fallback in the thumbnail endpoint ensures the grid
still renders something.
---
## Decision 7: Thumbnail endpoint response
**Decision**: `GET /api/v1/images/{id}/thumbnail` follows the same pattern as
`GET /api/v1/images/{id}/file`:
- Returns `200` with binary content, `Content-Type: image/webp` (or original
`mime_type` when falling back to original), `ETag`, and
`Cache-Control: public, max-age=31536000, immutable`.
- Returns `404` with `{"detail": "...", "code": "image_not_found"}` if the image
does not exist.
- Falls back to the original when `thumbnail_key IS NULL`.
**Rationale**: Consistent with the existing `/file` endpoint pattern established in
feature 002. The UI only needs to know one URL per image for the grid.
---
## Decision 8: GIF handling
**Decision**: For GIF uploads, `generate_thumbnail()` extracts frame 0 via
`Image.seek(0)` before resizing. The output is always WebP (static, not animated).
**Rationale**: Matches spec assumption: "Animated GIF thumbnails capture only the
first frame; animation is not preserved in the thumbnail." Pillow supports this
with `im.seek(0)`.

View File

@@ -0,0 +1,180 @@
# Feature Specification: Upload Thumbnails
**Feature Branch**: `003-upload-thumbnails`
**Created**: 2026-05-03
**Status**: Draft
**Input**: User description: "When users load the UI, full size images are fetched, which may cause considerable load time when there are a lot of images -- a grid of 20 images could silently pull several hundred megabytes. We will solve this by pre-generating thumbnails on upload: when an image is uploaded, immediately produce one (or a few) fixed-size thumbnail variants and store them alongside the original. The library always fetches the thumbnail key, the detail page fetches the original key. Zero resize cost at serve time. A single fixed-size re-encoded as WebP for smaller bytes will cover the grid view."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Fast Library Load (Priority: P1)
A user opens the application or runs a tag-filtered search. The image grid
loads quickly even when the library contains many images, because each grid
cell fetches a compact thumbnail rather than the full-size original.
**Why this priority**: This is the core motivation. A grid of 20 images today
could pull hundreds of megabytes; thumbnails bring that down to a few
megabytes, making the library usable on slow or metered connections.
**Independent Test**: Upload 20 images of varying sizes (including some near
the 50 MB limit). Open the library. Measure total bytes transferred while the
grid loads. Compare against loading the same library before this feature.
Verify the grid renders fully and that each thumbnail is visually recognisable
as the correct image.
**Acceptance Scenarios**:
1. **Given** a library with multiple images, **When** the user opens the
library page, **Then** each grid cell displays a thumbnail that is visually
recognisable as its image, and the total data transferred to render the
full grid is substantially less than the sum of the original file sizes.
2. **Given** a user applies one or more tag filters, **When** the filtered
results are displayed, **Then** thumbnails are shown for all matching
images with the same reduced data footprint.
3. **Given** a library with images of mixed types (JPEG, PNG, GIF, WebP),
**When** the grid loads, **Then** thumbnails for all types display
correctly.
---
### User Story 2 — Full-Size Image on Detail Page (Priority: P1)
A user clicks an image in the library grid to open its detail page. The
full-size original is displayed, not the thumbnail. The experience is
unchanged from before this feature.
**Why this priority**: The detail page is where the user inspects or copies
an image; showing the thumbnail there would degrade the product's core value.
**Independent Test**: Open any image's detail page. Verify the image
displayed matches the original resolution and file size, not the thumbnail
dimensions.
**Acceptance Scenarios**:
1. **Given** the user clicks an image thumbnail in the library, **When** the
detail page loads, **Then** the full-size original image is displayed at
its native resolution.
2. **Given** the user navigates directly to an image's detail URL, **When**
the page loads, **Then** the full-size original is displayed.
---
### User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1)
A user uploads a new image. Without any additional action, a thumbnail is
available immediately. There is no separate step or explicit request to
generate a thumbnail.
**Why this priority**: The value of the feature depends entirely on thumbnails
being present for every image. Manual generation or lazy generation would
create inconsistencies in the grid.
**Independent Test**: Upload a new image. Immediately open the library. Verify
the new image's thumbnail appears in the grid without any extra action.
**Acceptance Scenarios**:
1. **Given** the user uploads a supported image, **When** the upload
completes, **Then** a thumbnail is available and appears correctly in
the library grid.
2. **Given** the user uploads a duplicate image (already in the library),
**When** the upload completes, **Then** no redundant thumbnail is
generated — the existing thumbnail is reused.
3. **Given** the user uploads an image at or near the maximum supported
file size (50 MB), **When** the upload completes, **Then** the thumbnail
is generated successfully and the upload response time remains acceptable.
---
### Edge Cases
- What happens when thumbnail generation fails during upload? → The upload
still succeeds and the original image is stored; a fallback to the original
is shown in the grid, or the item is hidden until the thumbnail is available
(assumption: fall back to original rather than silently drop the image).
- What happens when an image is deleted? → Both the original and its thumbnail
are removed from storage.
- What happens with existing images that were uploaded before this feature? →
Those images have no pre-generated thumbnail; the grid falls back to the
original for those entries until a backfill is performed (backfill is out of
scope for v1 of this feature).
- What happens with animated GIFs? → A static thumbnail is generated from the
first frame.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST generate a thumbnail for every newly uploaded
image as part of the upload operation, before the upload response is
returned to the caller.
- **FR-002**: Thumbnails MUST be stored in the same object storage as the
original, addressable by a distinct key derived from the image.
- **FR-003**: The thumbnail MUST be encoded as WebP regardless of the original
image format.
- **FR-004**: The thumbnail MUST fit within a fixed maximum dimension on its
longest side, preserving the original aspect ratio; no dimension of the
thumbnail MAY exceed 400 pixels.
- **FR-005**: The library grid view MUST fetch and display thumbnails instead
of original images.
- **FR-006**: The image detail view MUST continue to fetch and display the
full-size original.
- **FR-007**: When a duplicate image is uploaded, the thumbnail MUST NOT be
regenerated or re-stored; the existing thumbnail is reused.
- **FR-008**: When an image is deleted, its thumbnail MUST also be deleted
from storage.
- **FR-009**: If thumbnail generation fails during upload, the upload MUST
still succeed; the system MUST fall back to serving the original image in
the grid for that entry.
- **FR-010**: The API MUST expose a way for clients to retrieve the thumbnail
content for a given image, distinct from the full-size content endpoint.
### Key Entities *(include if feature involves data)*
- **Image**: Gains a new optional attribute indicating whether a thumbnail is
available and the key under which the thumbnail is stored.
- **Thumbnail**: A derived, smaller representation of an Image. Key
attributes: storage key, dimensions (width × height), format (WebP),
relationship to its source Image.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: The total data transferred to render a 20-image library grid is
reduced by at least 80% compared to fetching full-size originals for the
same images.
- **SC-002**: The library grid's first page loads in under 2 seconds on a
local network connection for a library of 1,000 images, with thumbnails
visible without a second load.
- **SC-003**: Thumbnails are available immediately after upload completes —
no polling or manual refresh is required.
- **SC-004**: The detail page continues to show the full-size original; no
regression in detail-page image quality is introduced.
- **SC-005**: Deleting an image removes both the original and its thumbnail;
no orphaned thumbnail objects remain in storage after deletion.
## Assumptions
- A single thumbnail size (longest side ≤ 400 px, WebP) is sufficient for
the library grid view in v1. Additional sizes or formats are out of scope.
- Thumbnail generation happens synchronously during the upload request. Async
background processing is not required for v1.
- Existing images uploaded before this feature are not automatically
backfilled with thumbnails in v1; the grid falls back to the original for
those entries.
- Animated GIF thumbnails capture only the first frame; animation is not
preserved in the thumbnail.
- The thumbnail storage key is derived deterministically from the image's
existing content hash, so no additional database column is strictly required
to locate it — however the Image record will track thumbnail availability
for correctness.
- No change is required to tag management, duplicate detection, or any other
upload behaviour beyond adding thumbnail generation.

View File

@@ -0,0 +1,186 @@
# Tasks: Upload Thumbnails
**Input**: Design documents from `specs/003-upload-thumbnails/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
**TDD**: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
---
## Phase 1: Setup
- [X] T001 Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`; rebuild the Docker API image (`docker compose build api`) so Pillow is available inside the container for all subsequent test runs
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Thumbnail generation logic and the DB schema change that all three user stories depend on.
**⚠️ CRITICAL**: All tasks in this phase must complete before any user story work begins.
### Tests for thumbnail utility (write FIRST — must FAIL before T005) ⚠️
- [X] T002 Write 6 unit tests in `api/tests/unit/test_thumbnail.py``test_thumbnail_is_webp` (output begins with WebP magic bytes `RIFF...WEBP`), `test_thumbnail_fits_within_400px` (both dimensions ≤ 400), `test_thumbnail_preserves_aspect_ratio` (ratio within 1% of original), `test_thumbnail_handles_gif_first_frame` (GIF input → static WebP, no animation), `test_thumbnail_handles_png_with_alpha` (RGBA PNG → valid WebP output), `test_thumbnail_does_not_upscale` (100×100 input stays ≤ 100×100); to confirm the TDD red state, first create an empty stub `api/app/thumbnail.py` (so pytest can collect tests), then run `pytest api/tests/unit/test_thumbnail.py` and confirm all 6 **fail** with assertion errors (not import errors)
### Thumbnail utility implementation
- [X] T003 Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`: open bytes with `PIL.Image.open(BytesIO(data))`, call `.seek(0)` to target frame 0 (GIF support), convert mode to RGB or RGBA as needed for WebP, call `.thumbnail((400, 400), PIL.Image.LANCZOS)` (never upscales), save to a `BytesIO` buffer as WebP quality=80, return bytes; run `pytest api/tests/unit/test_thumbnail.py` and confirm all 6 pass
### Database migration
- [X] T004 [P] Create `api/alembic/versions/002_add_thumbnail_key.py` with `upgrade()` calling `op.add_column("images", sa.Column("thumbnail_key", sa.String(70), nullable=True))` and `downgrade()` calling `op.drop_column("images", "thumbnail_key")`; set `revision="002"`, `down_revision="001"`
- [X] T005 [P] Add `thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)` to the `Image` class in `api/app/models.py`
- [X] T006 Add `thumbnail_key: str | None = None` keyword argument to `ImageRepository.create()` in `api/app/repositories/image_repo.py`; include it in the `Image(...)` constructor call; run `docker compose run --rm api alembic upgrade head` inside the container and confirm migration applies cleanly; run `pytest api/` to confirm all 46 existing tests still pass
**Checkpoint**: Pillow available, thumbnail.py works, schema migrated, all existing tests green.
---
## Phase 3: User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1) 🎯
**Goal**: Every new upload triggers thumbnail generation and storage as part of the same request. No extra step required from the user.
**Independent Test**: Upload any supported image. Immediately check the upload response — it includes a non-null `thumbnail_key`. Call `GET /api/v1/images/{id}/thumbnail` — it returns 200 with `content-type: image/webp`.
### Tests for User Story 3 (write FIRST — must FAIL before T008) ⚠️
- [X] T007 [US3] Add three tests to `api/tests/integration/test_upload.py`: `test_upload_returns_thumbnail_key` (upload a JPEG/PNG/WebP, assert response JSON contains `thumbnail_key` ending in `-thumb`), `test_duplicate_upload_reuses_thumbnail_key` (upload same file twice, assert both responses have equal, non-null `thumbnail_key`), and `test_upload_succeeds_when_thumbnail_fails` (patch `generate_thumbnail` to raise an exception, upload an image, assert response is 200/201 with `thumbnail_key: null` — upload must not be blocked by thumbnail failure); run `pytest api/tests/integration/test_upload.py` and confirm all three new tests **fail**
### Implementation for User Story 3
- [X] T008 [US3] In `api/app/routers/images.py` `upload_image()`: import `asyncio` and `generate_thumbnail` from `app.thumbnail`; after `await storage.put(hash_hex, data, mime_type)`, wrap thumbnail generation in a try/except — call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)` (runs CPU-bound Pillow work off the async event loop), store result via `await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")`, set `thumbnail_key = f"{hash_hex}-thumb"`; on any exception log a warning and leave `thumbnail_key = None`; pass `thumbnail_key=thumbnail_key` to `image_repo.create()`
- [X] T009 [US3] Add `"thumbnail_key": image.thumbnail_key` to the dict returned by `_image_to_dict()` in `api/app/routers/images.py`
- [X] T010 [US3] Run `pytest api/tests/integration/test_upload.py` to confirm both new tests pass; then run `pytest api/` to confirm no regressions
**Checkpoint**: Upload generates and stores a thumbnail. Duplicate uploads reuse the existing thumbnail. `thumbnail_key` appears in all image metadata responses.
---
## Phase 4: User Story 1 — Fast Library Load (Priority: P1)
**Goal**: The library grid fetches compact WebP thumbnails instead of full-size originals, dramatically reducing page-load bandwidth.
**Independent Test**: Open the library in a browser with DevTools network tab open. All grid `<img>` elements request `/api/v1/images/{id}/thumbnail`. Total bytes transferred for a 20-image grid is a small fraction of what the originals would cost.
### Tests for User Story 1 (write FIRST — must FAIL before T013) ⚠️
- [X] T011 [US1] Add `test_thumbnail_returns_webp` (upload image, GET `/thumbnail`, assert 200, `content-type: image/webp`, ETag header matches `f'"{image_hash}"'`, `"immutable"` in `cache-control`, non-empty content), `test_thumbnail_fallback_returns_original` (manually set `thumbnail_key=None` on a DB record via the session fixture, GET `/thumbnail`, assert 200 with original `mime_type` in content-type), and `test_thumbnail_unknown_id_returns_404` (unknown UUID, assert 404 `image_not_found`) to `api/tests/integration/test_serving.py`; run and confirm all three **fail**
- [X] T012 [P] [US1] In `ui/src/app/services/image.service.ts`: add `thumbnail_key: string | null` field to the `ImageRecord` interface; add `getThumbnailUrl(id: string): string { return \`${this.base}/images/${id}/thumbnail\`; }` method to `ImageService`
### Implementation for User Story 1
- [X] T013 [US1] Add `GET /api/v1/images/{image_id}/thumbnail` route to `api/app/routers/images.py`: look up image (404 if missing), select `key = image.thumbnail_key or image.storage_key` and `media_type = "image/webp" if image.thumbnail_key else image.mime_type`, call `await storage.get(key)` in a try/except (500 `storage_error` on failure), return `Response(content=data, media_type=media_type, headers={"ETag": f'"{image.hash}"', "Cache-Control": "public, max-age=31536000, immutable"})`
- [X] T014 [US1] In `ui/src/app/library/library.component.ts` and its HTML template: replace every use of `imageService.getFileUrl(image.id)` (or equivalent) with `imageService.getThumbnailUrl(image.id)` for grid cell `<img src>` bindings
- [X] T015 [US1] Add `thumbnail_key: null` to every `ImageRecord` mock/stub object in `ui/src/app/services/image.service.spec.ts`, `ui/src/app/library/library.component.spec.ts`, `ui/src/app/detail/detail.component.spec.ts`, and `ui/src/app/upload/upload.component.spec.ts`
- [X] T016 [US1] Run `pytest api/tests/integration/test_serving.py` to confirm all three new thumbnail tests pass and no existing serving tests regress
**Checkpoint**: `GET /api/v1/images/{id}/thumbnail` serves WebP with caching headers. Falls back to original for legacy images. Library grid `<img>` elements all use the thumbnail endpoint.
---
## Phase 5: User Story 2 — Full-Size Image on Detail Page (Priority: P1)
**Goal**: The detail page continues to display the full-size original. No regression introduced by the thumbnail work.
**Independent Test**: Navigate to any image detail page. The image displayed is full-resolution. Browser DevTools shows the detail `<img>` requests `/api/v1/images/{id}/file`, not `/thumbnail`.
### Verification for User Story 2
- [X] T017 [US2] Confirm `ui/src/app/detail/detail.component.ts` still calls `imageService.getFileUrl(image.id)` (not `getThumbnailUrl`) for its `<img src>` — no code change expected; if the file was accidentally updated in T014 or T015, revert the detail component to `getFileUrl`
- [X] T018 [US2] Run `ng test` (inside the UI container or locally) and confirm all Angular unit tests pass including the detail component spec; run `ng build` to confirm the Angular build succeeds
**Checkpoint**: Detail page verified unchanged. Angular build and tests clean.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Delete cleanup, final test run, linting.
### Delete thumbnail on image deletion
- [X] T019 Write `test_delete_removes_thumbnail` in `api/tests/integration/test_delete.py`: upload an image, delete it, then `GET /api/v1/images/{id}/thumbnail` and assert 404; run and confirm it **fails** (currently delete does not remove the thumbnail object)
- [X] T020 In `api/app/routers/images.py` `delete_image()`: capture `thumbnail_key = image.thumbnail_key` before `image_repo.delete(image)`; after deleting the original via `await storage.delete(storage_key)`, add `if thumbnail_key: await storage.delete(thumbnail_key)`; run `pytest api/tests/integration/test_delete.py` to confirm new test and all existing delete tests pass
### Final validation
- [X] T021 [P] Run `~/.local/bin/ruff check api/app/thumbnail.py api/app/routers/images.py api/app/models.py api/app/repositories/image_repo.py api/tests/unit/test_thumbnail.py` and fix any lint issues in the changed files
- [X] T022 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: 46 existing + ~10 new = ~56 total)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 (Pillow must be installed before tests can import it)
- **US3 (Phase 3)**: Depends on Phase 2 complete (T002T006 all done)
- **US1 (Phase 4)**: Depends on Phase 3 complete (upload must set `thumbnail_key` before the endpoint can serve one)
- **US2 (Phase 5)**: Depends on Phase 4 complete (Angular changes in T014/T015 must be done before verifying no regression)
- **Polish (Phase 6)**: Depends on Phases 3, 4, and 5 complete
### Within Each Phase
- T002 (write failing tests) MUST precede T003 (implement thumbnail.py)
- T004 and T005 can run in parallel (different files)
- T006 (repo change) depends on T005 (model must compile first)
- T007 (write failing upload tests) MUST precede T008 (implement upload change)
- T011 (write failing serving tests) and T012 (UI service) can run in parallel
- T011 MUST precede T013 (implement thumbnail route)
- T019 (write failing delete test) MUST precede T020 (implement delete cleanup)
---
## Parallel Example: Phase 2 (Foundational)
```bash
# After T003 is done, run T004 and T005 together:
Task: "Create 002_add_thumbnail_key.py migration in api/alembic/versions/"
Task: "Add thumbnail_key column to Image ORM in api/app/models.py"
```
## Parallel Example: Phase 4 (US1)
```bash
# T011 and T012 touch different layers — run together:
Task: "Write 3 failing thumbnail serving tests in api/tests/integration/test_serving.py"
Task: "Add getThumbnailUrl() and thumbnail_key field to ui/src/app/services/image.service.ts"
```
---
## Implementation Strategy
### MVP (All three user stories — tightly coupled)
All three user stories are P1 and interdependent: US3 (generation) enables US1 (grid) which proves US2 (detail unchanged) by contrast. Complete all phases in order.
1. Phase 1: T001 (Pillow setup)
2. Phase 2: T002T006 (core infrastructure)
3. Phase 3: T007T010 (upload generates thumbnail)
4. Phase 4: T011T016 (thumbnail endpoint + UI)
5. Phase 5: T017T018 (detail page verification)
6. Phase 6: T019T022 (delete cleanup + polish)
7. **STOP and VALIDATE**: Open library in browser; DevTools shows `/thumbnail` requests; bandwidth used is a fraction of original file sizes
### Total tasks: 22 (T001T022)
---
## Notes
- [P] tasks touch different files and have no mutual dependencies within their phase
- T002 must be run **before** T003 and confirmed failing — this is the TDD red step
- T007, T011, T019 are all "write failing test" steps — confirm failure before implementing
- `thumbnail_key` in the API response is informational; the UI always calls `/thumbnail` and lets the endpoint handle the fallback — no client-side conditional logic needed
- Existing images (pre-dating this feature) will have `thumbnail_key: null`; the `/thumbnail` endpoint serves their original transparently
- The backfill migration for existing images is explicitly out of scope for this feature

View File

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: JWT Bearer Token Authentication
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
All items pass. Spec is ready for `/speckit-plan`.

View File

@@ -0,0 +1,120 @@
# API Contracts: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
All routes remain under `/api/v1/`. Error responses use the existing envelope:
`{ "detail": "<human message>", "code": "<machine code>" }`.
---
## New Endpoint
### `POST /api/v1/auth/token`
Issues a bearer token for the owner after verifying credentials.
**Request**
```
Content-Type: application/json
{
"username": "<string>",
"password": "<string>"
}
```
Both fields are required. A missing or empty field returns `422`.
**Success response**`200 OK`
```json
{
"access_token": "<jwt-string>",
"token_type": "bearer",
"expires_in": 86400
}
```
`expires_in` reflects the configured `JWT_EXPIRY_SECONDS` value.
**Failure responses**
| Status | Code | When |
|---|---|---|
| `401` | `invalid_credentials` | Username or password is wrong |
| `422` | (FastAPI default) | Missing or malformed request body |
---
## Changed Endpoints — Access Control
The following endpoints now require a valid bearer token. Requests without
a token, or with an invalid/expired token, receive a `401`.
| Method | Path | Was | Now |
|---|---|---|---|
| `POST` | `/api/v1/images` | Public | **Protected** |
| `DELETE` | `/api/v1/images/{id}` | Public | **Protected** |
| `PATCH` | `/api/v1/images/{id}/tags` | Public | **Protected** |
**Bearer token transmission**
The client MUST include the token in the `Authorization` header:
```
Authorization: Bearer <access_token>
```
**401 response shape** (returned by all three protected endpoints when
authentication fails):
```json
{
"detail": "Authentication required",
"code": "unauthorized"
}
```
---
## Unchanged Endpoints — Remain Public
The following endpoints require no token and must continue to accept requests
without an `Authorization` header:
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/v1/images` | List / filter images |
| `GET` | `/api/v1/images/{id}` | Get image metadata |
| `GET` | `/api/v1/images/{id}/file` | Serve original image |
| `GET` | `/api/v1/images/{id}/thumbnail` | Serve image thumbnail |
| `GET` | `/api/v1/tags` | List / search tags |
| `GET` | `/api/v1/health` | Health check |
Sending a token on these endpoints is harmless (the server ignores it) but
is not required.
---
## Token Validation Rules
The API validates tokens using the following rules, in order:
1. The `Authorization` header value MUST begin with `Bearer ` (case-sensitive).
2. The token MUST be a valid HS256-signed JWT (verified against `JWT_SECRET_KEY`).
3. The `exp` claim MUST be in the future (at time of request receipt).
4. Any failure in steps 13 returns `401 unauthorized`.
---
## UI Route Contracts
These are Angular SPA routes affected by this feature.
| Route | Guard | Behaviour |
|---|---|---|
| `/login` | None | Login form; redirects to `returnUrl` or `/` on success |
| `/upload` | `authGuard` | Redirects to `/login?returnUrl=/upload` if not authenticated |
| `/images/:id` | None | Always accessible; tag-edit and delete controls visible only when authenticated |
| `/` | None | Always accessible (library) |

View File

@@ -0,0 +1,187 @@
# Data Model: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
---
## Database Changes
**None.** JWTs are stateless bearer tokens. The API validates them by
cryptographic signature and embedded expiry claim on each request. No token
storage, session table, or blocklist is introduced in Phase 2.
---
## Configuration Schema (new env vars)
Four new environment variables are added to `api/app/config.py` and
`.env.example`.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
| `JWT_SECRET_KEY` | `str` | Yes | — | HMAC-SHA256 signing secret. Must be a long random string; no default (startup fails if absent). |
| `JWT_EXPIRY_SECONDS` | `int` | No | `86400` | Token lifetime in seconds (24 h). |
| `OWNER_USERNAME` | `str` | Yes | — | Login username for the single owner account. |
| `OWNER_PASSWORD` | `str` | Yes | — | Login password for the single owner account. |
These values are loaded via `pydantic-settings` (`BaseSettings`) alongside the
existing database and S3 settings. `JWT_SECRET_KEY`, `OWNER_USERNAME`, and
`OWNER_PASSWORD` have no defaults and will raise a validation error at startup
if absent, providing a clear "misconfigured" failure rather than a silent
security hole.
---
## Token Structure
A JWT issued by the login endpoint carries the following claims.
| Claim | Type | Value |
|---|---|---|
| `sub` | string | `"owner"` — fixed identifier for the single owner |
| `iat` | integer | Unix epoch seconds at time of issuance |
| `exp` | integer | `iat + JWT_EXPIRY_SECONDS` |
Algorithm: `HS256` (HMAC-SHA256). Secret: `JWT_SECRET_KEY` setting.
The token is opaque to the client. The Angular SPA stores it in
`sessionStorage` and transmits it as `Authorization: Bearer <token>` on every
request.
---
## Module and Interface Changes
### `api/app/auth/provider.py` — updated interface
The `get_identity()` method gains an `authorization` parameter — the raw value
of the `Authorization` HTTP header (or `None` if the header is absent).
```
AuthProvider (abstract)
get_identity(authorization: str | None) -> Identity
Identity (dataclass)
id: str
anonymous: bool = True
```
### `api/app/auth/noop.py` — no behavioural change
`NoOpAuthProvider.get_identity()` continues to return the static anonymous
identity regardless of the `authorization` argument. The signature is updated
to match the new interface.
### `api/app/auth/jwt_provider.py` — new module
```
JWTAuthProvider (AuthProvider)
__init__(secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str)
get_identity(authorization: str | None) -> Identity
- Parses "Bearer <token>" from authorization header
- Decodes and validates the JWT (signature + exp)
- Returns Identity(id="owner", anonymous=False) on success
- Raises HTTPException 401 with code "unauthorized" on any failure
create_token() -> str
- Mints a new HS256 JWT with sub="owner", iat=now, exp=now+expiry_seconds
- Returns the encoded token string
verify_credentials(username: str, password: str) -> bool
- Compares username and password against OWNER_USERNAME / OWNER_PASSWORD
- Uses secrets.compare_digest to prevent timing attacks
- Returns True on match, False otherwise
```
### `api/app/dependencies.py` — new `require_auth` dependency
```
require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth)
) -> Identity
- Calls auth.get_identity(authorization)
- Raises HTTPException 401 if identity.anonymous is True
- Returns the Identity on success
```
Protected routes inject `identity: Identity = Depends(require_auth)` and do
not need to perform any additional auth checks — the dependency raises before
the route body executes if authentication fails.
### `api/app/routers/auth.py` — new router
```
POST /api/v1/auth/token
Request body: LoginRequest { username: str, password: str }
Success (200): TokenResponse { access_token: str, token_type: "bearer", expires_in: int }
Failure (401): { detail: "Invalid credentials", code: "invalid_credentials" }
```
---
## Angular Module Changes
### `ui/src/app/auth/auth.service.ts` — new service
```
AuthService
TOKEN_KEY = 'auth_token' (sessionStorage key)
login(username: string, password: string): Observable<void>
- POST /api/v1/auth/token
- On success: stores access_token in sessionStorage, emits completion
- On 401: propagates error for the component to handle
logout(): void
- Removes token from sessionStorage
getToken(): string | null
- Returns stored token or null
isAuthenticated(): boolean
- Returns true if getToken() is non-null
```
### `ui/src/app/auth/auth.interceptor.ts` — new functional interceptor
Attaches `Authorization: Bearer <token>` to every outbound `HttpRequest` if
`AuthService.getToken()` returns a non-null value. Requests without a token
are passed through unmodified.
### `ui/src/app/auth/auth.guard.ts` — new route guard
Functional `CanActivateFn`. If `AuthService.isAuthenticated()` is `false`,
redirects to `/login?returnUrl=<current-url>`. Otherwise allows navigation.
### `ui/src/app/login/login.component.ts` — new component
Route: `/login`
```
LoginComponent
Fields: username (required), password (required)
On submit:
- Calls AuthService.login()
- On success: navigates to returnUrl query param, or '/' if absent
- On 401: displays inline error "Invalid username or password"
- On other error: displays generic error message
Shows loading state while request is in flight
```
### `ui/src/app/detail/detail.component.ts` — updated
Injects `AuthService`. Hides tag-edit input and delete button when
`auth.isAuthenticated()` is `false`. Shows them when authenticated.
Read-only view (image display, tag chips) is always visible.
### `ui/src/app/app.routes.ts` — updated
`/upload` route gains `canActivate: [authGuard]`. `/login` route is added
(unguarded). All other routes are unchanged.
### `ui/src/app/app.config.ts` — updated
`provideHttpClient()` becomes
`provideHttpClient(withInterceptors([authInterceptor]))`.

View File

@@ -0,0 +1,355 @@
# Implementation Plan: JWT Bearer Token Authentication
**Branch**: `004-jwt-bearer-auth` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/004-jwt-bearer-auth/spec.md`
## Summary
Implement Phase 2 of the progressive auth plan (constitution §2.4): replace
the no-op `AuthProvider` with a `JWTAuthProvider` that issues and validates
HS256 bearer tokens. Upload, delete, and tag-update endpoints become
protected; all read endpoints stay public. The Angular SPA gains a login page,
a session-scoped token store, an HTTP interceptor, and a route guard for the
upload page.
No database migration is required — tokens are stateless. A single new PyJWT
dependency is added to the API. The `AuthProvider` interface gains a parameter
so the JWT provider can access the `Authorization` header; `NoOpAuthProvider`
is updated to match but its behaviour is unchanged.
Changes span: `api/app/config.py` (4 new settings), `api/app/auth/provider.py`
(interface update), `api/app/auth/jwt_provider.py` (new), `api/app/routers/auth.py`
(new), `api/app/dependencies.py` (new `require_auth` dependency), three route
updates in `api/app/routers/images.py`, and on the UI side: a new `AuthService`,
`AuthInterceptor`, `AuthGuard`, and `LoginComponent`, plus small updates to
`app.routes.ts`, `app.config.ts`, and `detail.component.ts`.
## Technical Context
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, PyJWT (new), pydantic-settings, Angular
**Storage**: PostgreSQL (no schema changes); S3-compatible (no changes)
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
**Project Type**: Web application — FastAPI API + Angular SPA
**Performance Goals**: Login round-trip under 15 s on local network (well within
reach; no database lookup, only in-memory credential comparison + JWT signing)
**Constraints**: Stateless tokens — no server-side session storage; single owner
account; no token revocation in v1
**Scale/Scope**: Single-user personal application; auth is a deployment-time
configuration concern, not a runtime management concern
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
| Principle | Check | Status |
|---|---|---|
| §2.1 Separation of concerns | JWT logic lives only in `JWTAuthProvider`; routes orchestrate; UI knows nothing about signing | ✅ |
| §2.2 Dependency direction | UI → API only; no upward imports introduced | ✅ |
| §2.3 Storage abstraction | No change to storage layer | ✅ |
| §2.4 Auth abstraction | `JWTAuthProvider` is a second `AuthProvider` implementation — exactly the pattern §2.4 designed for | ✅ |
| §2.5 DB abstraction | No DB changes; stateless JWTs require no session table | ✅ |
| §2.6 No speculative abstraction | No new interfaces; only a second concrete implementation of an already-planned interface | ✅ |
| §3.1 API versioning | New route at `/api/v1/auth/token` | ✅ |
| §3.3 Error shape | `401` uses `{"detail": "...", "code": "unauthorized"}` / `"invalid_credentials"` | ✅ |
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
| §5.2 Test pyramid | Unit tests for JWT logic; integration tests for all changed routes | ✅ |
| §5.3 Test colocation | API tests in `api/tests/`; Angular specs colocated with components | ✅ |
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
| §7.2 Env configuration | `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`, `OWNER_USERNAME`, `OWNER_PASSWORD` added as env vars | ✅ |
| §8 Scope boundaries | §8 lists "Username/password auth (planned Phase 2)" as deferred. This feature IS Phase 2; the deferral is now lifted. All other §8 items remain deferred. | ✅ |
**Post-design re-check**: All gates still pass after Phase 1 design.
## Project Structure
### Documentation (this feature)
```text
specs/004-jwt-bearer-auth/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 decisions
├── data-model.md # Module and interface changes
├── contracts/
│ └── api.md # New endpoint + changed access control
├── checklists/
│ └── requirements.md # Spec quality checklist
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
```
### Files changed or created
```text
api/
├── pyproject.toml # Add PyJWT dependency
├── app/
│ ├── config.py # Add 4 new settings
│ ├── dependencies.py # Add require_auth dependency
│ ├── auth/
│ │ ├── provider.py # get_identity(authorization) signature
│ │ ├── noop.py # Updated signature, same behaviour
│ │ └── jwt_provider.py # NEW — JWTAuthProvider
│ └── routers/
│ ├── auth.py # NEW — POST /auth/token
│ └── images.py # Protect upload, delete, patch-tags
├── main.py # Register auth router
└── .env.example # Add 4 new vars
ui/
└── src/
└── app/
├── auth/
│ ├── auth.service.ts # NEW
│ ├── auth.service.spec.ts # NEW
│ ├── auth.interceptor.ts # NEW
│ ├── auth.interceptor.spec.ts # NEW
│ └── auth.guard.ts # NEW
├── login/
│ ├── login.component.ts # NEW
│ ├── login.component.html # NEW
│ └── login.component.spec.ts # NEW
├── detail/
│ └── detail.component.ts # Conditionally show edit/delete
├── app.routes.ts # Add /login; guard /upload
├── app.config.ts # Register authInterceptor
└── app.component.ts # Add logout button (visible when authenticated)
```
## Milestones
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
> the failing test(s) first, confirm they fail, then implement until they pass.
---
### M1 — JWT provider: token signing and validation
**Goal**: A tested `JWTAuthProvider` that can mint tokens and validate bearer
tokens from an `Authorization` header. The `AuthProvider` interface is updated;
`NoOpAuthProvider` is kept compatible.
**Deliverables**:
- Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`
- Update `api/app/config.py`:
- `jwt_secret_key: str` (required — no default; validated by pydantic)
- `jwt_expiry_seconds: int = 86400`
- `owner_username: str` (required)
- `owner_password: str` (required)
- Update `api/app/auth/provider.py`: `get_identity(self, authorization: str | None) -> Identity`
- Update `api/app/auth/noop.py`: match new signature; behaviour unchanged
- Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider`:
- `create_token() -> str` — mint HS256 JWT with `sub="owner"`, `iat`, `exp`
- `verify_credentials(username, password) -> bool``secrets.compare_digest`
- `get_identity(authorization) -> Identity` — parse `"Bearer <token>"`,
decode JWT, return `Identity(id="owner", anonymous=False)` on success, or
raise `HTTPException(401, {"detail": "...", "code": "unauthorized"})` on
any failure (missing header, invalid format, bad signature, expired)
**Unit tests** in `api/tests/unit/test_jwt_auth.py` (write first, confirm fail):
- `test_create_token_is_valid_jwt` — minted token decodes with PyJWT without error
- `test_get_identity_returns_owner` — valid token → non-anonymous Identity
- `test_get_identity_raises_on_expired_token` — token with past `exp` → 401
- `test_get_identity_raises_on_wrong_key` — token signed with different key → 401
- `test_get_identity_raises_on_garbage` — random string as token → 401
- `test_get_identity_raises_on_missing_header``authorization=None` → 401
- `test_get_identity_raises_on_missing_bearer_prefix``"token-without-prefix"` → 401
- `test_verify_credentials_true` — correct username + password → True
- `test_verify_credentials_false_wrong_password` — wrong password → False
- `test_verify_credentials_false_wrong_username` — wrong username → False
**Done criterion**: All 10 unit tests pass; `ruff check api/` passes; existing
tests unaffected.
---
### M2 — Login endpoint
**Goal**: `POST /api/v1/auth/token` issues a token for valid credentials and
rejects invalid ones.
**Deliverables**:
- Create `api/app/routers/auth.py`:
- `LoginRequest` Pydantic model: `username: str`, `password: str`
- `POST /auth/token` route: call `auth_provider.verify_credentials()`; on
success call `auth_provider.create_token()` and return `TokenResponse`
`{access_token, token_type="bearer", expires_in}`; on failure raise
`401 invalid_credentials`
- Update `api/app/dependencies.py`: instantiate `JWTAuthProvider` (reading
settings) instead of `NoOpAuthProvider` in `get_auth()`
- Update `api/app/main.py`: register `auth.router` under `/api/v1`
**Integration tests** in `api/tests/integration/test_auth.py` (write first):
- `test_login_success` — POST valid creds → 200, response contains
`access_token` (non-empty string), `token_type="bearer"`, `expires_in > 0`
- `test_login_wrong_password` — correct username, wrong password → 401,
code `invalid_credentials`
- `test_login_wrong_username` — wrong username → 401, code `invalid_credentials`
- `test_login_missing_password` — body `{"username": "x"}` → 422
- `test_login_missing_username` — body `{"password": "x"}` → 422
**Test infrastructure note**: The integration test `conftest.py` currently
overrides `get_auth` with `NoOpAuthProvider`. Tests for the auth endpoint
need to override with a test `JWTAuthProvider` (a real provider with test
credentials). Add a `jwt_auth_provider` fixture and an `authed_client` fixture
(with a bearer token) to `conftest.py` for use in M3.
**Done criterion**: All 5 new tests pass; all existing tests pass (the
`NoOpAuthProvider` override in `conftest.py` means existing tests are
unaffected by switching the production `get_auth()` to `JWTAuthProvider`).
---
### M3 — Protected endpoints
**Goal**: Upload, delete, and patch-tags reject unauthenticated requests.
All public endpoints remain accessible without a token.
**Deliverables**:
- Add `require_auth` to `api/app/dependencies.py`:
```python
async def require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth),
) -> Identity:
identity = await auth.get_identity(authorization)
if identity.anonymous:
raise HTTPException(
status_code=401,
detail={"detail": "Authentication required", "code": "unauthorized"},
)
return identity
```
- In `api/app/routers/images.py`:
- `upload_image()`: add `_: Identity = Depends(require_auth)` parameter
- `delete_image()`: add `_: Identity = Depends(require_auth)` parameter
- `update_image_tags()`: add `_: Identity = Depends(require_auth)` parameter
- Remove the now-redundant `auth: AuthProvider = Depends(get_auth)` from
`upload_image()` (it was injected but never called; `require_auth` subsumes it)
**Integration tests** — add to existing test files (write failing tests first):
In `api/tests/integration/test_upload.py`:
- `test_upload_without_token_returns_401` — POST without `Authorization` → 401, code `unauthorized`
- `test_upload_with_valid_token_succeeds` — POST with valid bearer token → 200/201
In `api/tests/integration/test_delete.py`:
- `test_delete_without_token_returns_401` — DELETE without token → 401
- `test_delete_with_valid_token_succeeds` — DELETE with valid token → 204
In `api/tests/integration/test_serving.py` (tag update lives here conceptually
but the route is in images; add to `test_upload.py` or a new `test_tags.py`):
- `test_patch_tags_without_token_returns_401` — PATCH without token → 401
- `test_patch_tags_with_valid_token_succeeds` — PATCH with valid token → 200
Public endpoint regression tests (confirm no 401 regression):
- `test_list_images_without_token_is_200` — GET /images → 200 (no auth)
- `test_get_image_without_token_is_200` — GET /images/{id} → 200
- `test_serve_file_without_token_is_200` — GET /images/{id}/file → 200
- `test_serve_thumbnail_without_token_is_200` — GET /images/{id}/thumbnail → 200
- `test_list_tags_without_token_is_200` — GET /tags → 200
**conftest.py update**: The `client` fixture already overrides `get_auth` with
`NoOpAuthProvider`, so all existing tests (which do not send tokens) continue
to pass without modification. The new `authed_client` fixture (from M2) uses
a `JWTAuthProvider` override and injects a valid token via the `Authorization`
header.
**Done criterion**: All new tests pass; all existing tests continue to pass.
---
### M4 — UI: `AuthService`, `AuthInterceptor`, `AuthGuard`, `LoginComponent`
**Goal**: Angular has a working login flow. The upload page is protected.
The detail page shows/hides write controls based on auth state.
**Deliverables**:
`ui/src/app/auth/auth.service.ts`:
- `TOKEN_KEY = 'auth_token'`
- `login(username, password): Observable<void>` — POST to `/api/v1/auth/token`,
store `access_token` in `sessionStorage` on success
- `logout(): void` — `sessionStorage.removeItem(TOKEN_KEY)`
- `getToken(): string | null`
- `isAuthenticated(): boolean`
`ui/src/app/auth/auth.interceptor.ts` (functional interceptor):
- If `AuthService.getToken()` returns non-null, clone request with
`Authorization: Bearer <token>` header; otherwise pass through
`ui/src/app/auth/auth.guard.ts` (`CanActivateFn`):
- If not authenticated: `router.createUrlTree(['/login'], {queryParams: {returnUrl: state.url}})`
- If authenticated: `true`
`ui/src/app/login/login.component.ts`:
- Reactive form with `username` (required) and `password` (required) controls
- `onSubmit()`: calls `AuthService.login()`; on success navigates to `returnUrl`
query param (default `/`); on error displays inline "Invalid username or password"
- Loading state while request is in flight; button disabled during loading
`ui/src/app/detail/detail.component.ts` (update):
- Inject `AuthService`
- In template: `*ngIf="auth.isAuthenticated()"` wraps the tag-edit input and
the delete button
`ui/src/app/app.routes.ts` (update):
- Add `{ path: 'login', loadComponent: () => import('./login/login.component').then(...) }`
- Add `canActivate: [authGuard]` to the `/upload` route
`ui/src/app/app.config.ts` (update):
- `provideHttpClient(withInterceptors([authInterceptor]))`
**Angular unit tests** (write first, confirm fail):
`ui/src/app/auth/auth.service.spec.ts`:
- `test_login_stores_token` — mock HTTP, verify `sessionStorage` has token after login
- `test_logout_clears_token` — store a token, logout, verify `sessionStorage` empty
- `test_isAuthenticated_true_when_token_present` — set token, assert true
- `test_isAuthenticated_false_when_no_token` — clear sessionStorage, assert false
`ui/src/app/auth/auth.interceptor.spec.ts`:
- `test_adds_auth_header_when_authenticated` — authenticated state, outbound
request has `Authorization: Bearer <token>` header
- `test_no_auth_header_when_not_authenticated` — unauthenticated, outbound
request has no `Authorization` header
`ui/src/app/login/login.component.spec.ts`:
- `test_submit_calls_auth_service_login` — spy on `AuthService.login`, submit
form, verify called with correct username/password
- `test_navigates_on_success` — mock successful login, verify router navigate called
- `test_shows_error_on_failure` — mock 401, verify error message visible
**Done criterion**: Angular build clean; all Angular tests pass.
---
### M5 — `.env.example` update and final validation
**Goal**: New env vars documented; full test suite green.
**Deliverables**:
- Update `.env.example`: add `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`,
`OWNER_USERNAME`, `OWNER_PASSWORD` with example values and comments
- Run `pytest api/ -v` — confirm all tests pass (expected: existing ~57 tests
+ ~20 new tests ≈ 77 total)
- Run `ruff check api/ && ruff format --check api/` — zero violations
- Run `ng test` (inside UI container) — all Angular tests pass
- Run `ng build` — Angular build succeeds
**Done criterion**: All tests pass; both linters pass; `docker compose up`
starts the full stack and the login flow works end-to-end in the browser.
## Post-design Constitution Re-check
| Principle | Verdict |
|---|---|
| §2.4 Auth abstraction | `JWTAuthProvider` is a drop-in second implementation; business logic in routes is unchanged except for the added `require_auth` dependency | ✅ |
| §2.6 No speculative abstraction | No new interfaces; `JWTAuthProvider` is concrete and implements an already-planned interface | ✅ |
| §3.3 Error shape | `401` envelope uses `code` field throughout | ✅ |
| §5.1 TDD | Failing tests precede every implementation milestone | ✅ |
| §7.2 Env config | All four new settings come from env vars; no hardcoded credentials | ✅ |
All gates pass. Feature is ready for `/speckit-tasks`.

Some files were not shown because too many files have changed in this diff Show More