62 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
147 changed files with 6011 additions and 219 deletions

View File

@@ -11,6 +11,10 @@ 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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

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/012-api-docs-gate"
}
{"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: 1.2.0 → 1.3.0
Ratified: 2026-05-01 | Last amended: 2026-05-06
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)
@@ -171,11 +171,14 @@ 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
@@ -194,10 +197,15 @@ 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.
---
@@ -214,6 +222,9 @@ NOT be marked complete while CI is failing.
| 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 |
---
@@ -251,6 +262,15 @@ revised:
- Mobile-native app
- 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
@@ -289,7 +309,8 @@ Phase 1 design is complete.
| 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.3.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-06
**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/012-api-docs-gate/plan.md`.
shell commands, and other important information, read the current plan
<!-- SPECKIT END -->

View File

@@ -1,4 +1,4 @@
.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod
.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
@@ -18,3 +18,8 @@ build-ui-prod:
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/

138
README.md
View File

@@ -2,3 +2,141 @@
_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 |

View File

@@ -34,7 +34,11 @@ 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

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

@@ -14,20 +14,30 @@ def get_client_ip(
request: Request,
trusted_networks: list[IPv4Network | IPv6Network],
) -> str:
"""Return the resolved client IP, honouring X-Forwarded-For when the
TCP peer is a trusted upstream proxy. Falls back to the TCP peer address
when no trusted networks are configured or the peer is not in the list."""
"""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):
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if xff:
return xff
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
@@ -82,9 +92,7 @@ class LoginRateLimiter:
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
)
logger.warning("Login blocked for %s after %d failures", ip, rec.failures)
def record_success(self, ip: str) -> None:
with self._lock:

View File

@@ -14,6 +14,7 @@ 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

View File

@@ -22,6 +22,7 @@ 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)
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(

View File

@@ -27,6 +27,14 @@ class ImageRepository:
)
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()
async def create(
self,
*,
@@ -37,6 +45,7 @@ class ImageRepository:
width: int,
height: int,
storage_key: str,
short_id: str,
thumbnail_key: str | None = None,
) -> Image:
image = Image(
@@ -47,6 +56,7 @@ class ImageRepository:
width=width,
height=height,
storage_key=storage_key,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
self._session.add(image)

View File

@@ -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))
@@ -88,7 +86,7 @@ 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)
@@ -102,7 +100,6 @@ class TagRepository:
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

View File

@@ -1,7 +1,7 @@
import asyncio
import logging
import re
import struct
import uuid
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
@@ -15,7 +15,7 @@ from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository
from app.storage.backend import StorageBackend
from app.thumbnail import generate_thumbnail
from app.utils import compute_sha256
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__)
@@ -23,13 +23,35 @@ 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,
@@ -38,6 +60,8 @@ def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str,
"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,
}
@@ -133,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",
)
@@ -155,35 +182,55 @@ async def upload_image(
)
width, height = _read_image_dimensions(data, mime_type)
await storage.put(hash_hex, data, mime_type)
thumbnail_key: str | None = None
try:
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{hash_hex}-thumb"
except Exception:
logger.warning(
"Thumbnail generation failed for %s; upload will proceed without thumbnail", 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"},
)
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,
thumbnail_key=thumbnail_key,
)
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")
@@ -192,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,
@@ -250,14 +303,15 @@ async def serve_image_file(
)
@router.get("/images/{image_id}/thumbnail")
@router.get("/i/{short_id}/thumbnail")
async def serve_image_thumbnail(
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,
@@ -282,15 +336,18 @@ async def serve_image_thumbnail(
)
@router.patch("/images/{image_id}/tags")
@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,
@@ -309,18 +366,19 @@ 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,

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

@@ -30,6 +30,7 @@ dev = [
[tool.ruff]
line-length = 100
target-version = "py312"
exclude = ["alembic/"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

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())

View File

@@ -1,10 +1,9 @@
"""
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
@@ -28,12 +27,12 @@ async def test_delete_removes_record(authed_client):
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}", headers=headers)
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"
@@ -49,13 +48,13 @@ async def test_delete_removes_storage_object(authed_client):
headers=headers,
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
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
@@ -63,7 +62,7 @@ async def test_delete_removes_storage_object(authed_client):
async def test_delete_unknown_id_returns_404(authed_client):
client, token = authed_client
response = await client.delete(
f"/api/v1/images/{uuid.uuid4()}",
"/api/v1/i/NotFound",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404
@@ -85,12 +84,12 @@ async def test_delete_removes_thumbnail(authed_client):
headers=headers,
)
assert upload.status_code == 201
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
assert upload.json()["thumbnail_key"] is not None
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
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/images/{image_id}/thumbnail")
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

@@ -3,7 +3,6 @@ Tests that write endpoints require authentication (US2).
These use the authed_client fixture which wires JWTAuthProvider.
"""
import io
import uuid
import pytest
@@ -42,8 +41,7 @@ async def test_upload_with_valid_token_succeeds(authed_client):
@pytest.mark.asyncio
async def test_delete_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/images/{fake_id}")
response = await client.delete("/api/v1/i/NotFound")
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@@ -57,9 +55,9 @@ async def test_delete_with_valid_token_succeeds(authed_client):
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.delete(
f"/api/v1/images/{image_id}",
f"/api/v1/i/{image_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
@@ -68,9 +66,8 @@ async def test_delete_with_valid_token_succeeds(authed_client):
@pytest.mark.asyncio
async def test_patch_tags_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/images/{fake_id}/tags",
"/api/v1/i/NotFound/tags",
json={"tags": ["a"]},
)
assert response.status_code == 401
@@ -86,9 +83,9 @@ async def test_patch_tags_with_valid_token_succeeds(authed_client):
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["protected-tag"]},
headers={"Authorization": f"Bearer {token}"},
)

View File

@@ -30,8 +30,8 @@ async def test_get_image_without_token_is_200(authed_client):
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}")
assert response.status_code == 200
@@ -44,8 +44,8 @@ async def test_serve_file_without_token_is_200(authed_client):
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
@@ -58,8 +58,8 @@ async def test_serve_thumbnail_without_token_is_200(authed_client):
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200

View File

@@ -1,10 +1,9 @@
"""
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
@@ -39,10 +38,10 @@ async def test_file_returns_200_with_content(authed_client):
)
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}"'
@@ -52,7 +51,7 @@ async def test_file_returns_200_with_content(authed_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"
@@ -68,9 +67,9 @@ async def test_file_response_exposes_no_storage_details(authed_client):
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()
@@ -89,10 +88,10 @@ async def test_thumbnail_returns_webp(authed_client):
)
assert upload.status_code == 201
body = upload.json()
image_id = body["id"]
image_id = body["short_id"]
image_hash = body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
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}"'
@@ -110,15 +109,15 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
await db_session.execute(
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
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/images/{image_id}/thumbnail")
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
@@ -126,7 +125,7 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
@pytest.mark.asyncio
async def test_thumbnail_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
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

@@ -81,10 +81,10 @@ async def test_patch_replaces_tag_set(authed_client):
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,
)
@@ -104,10 +104,10 @@ async def test_patch_invalid_tag_returns_422(authed_client):
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,
)

View File

@@ -3,10 +3,10 @@ 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 uuid
import re
from unittest.mock import patch
import pytest
@@ -111,13 +111,81 @@ async def test_upload_oversized_file_returns_422(authed_client):
@pytest.mark.asyncio
async def test_get_unknown_image_returns_404_with_envelope(client):
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
@@ -132,6 +200,10 @@ async def test_upload_returns_thumbnail_key(authed_client):
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
@@ -172,3 +244,6 @@ async def test_upload_succeeds_when_thumbnail_fails(authed_client):
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,5 +1,3 @@
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"S3_ENDPOINT_URL": "http://localhost:9000",
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -43,6 +42,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -55,6 +55,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -67,6 +68,7 @@ def test_api_docs_enabled_default(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -79,6 +81,7 @@ def test_api_docs_enabled_false(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -91,6 +94,7 @@ def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()

View File

@@ -1,6 +1,6 @@
import hashlib
from app.utils import compute_sha256
from app.utils import compute_sha256, generate_short_id
def test_sha256_known_bytes():
@@ -19,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,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

@@ -80,10 +80,17 @@ def test_get_client_ip_no_trusted_networks_returns_peer():
assert get_client_ip(req, []) == "203.0.113.1"
def test_get_client_ip_trusted_peer_uses_xff():
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
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.5"
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():
@@ -92,7 +99,7 @@ def test_get_client_ip_untrusted_peer_ignores_xff():
assert get_client_ip(req, nets) == "8.8.8.8"
def test_get_client_ip_trusted_peer_falls_back_to_real_ip():
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
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.9"
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,17 +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

@@ -1,4 +1,5 @@
"""Unit tests for thumbnail generation utility."""
import io
from PIL import Image as PILImage

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"

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

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Kubernetes Production Manifests
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-07
**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
- FR-014 (migration files in production image) is a prerequisite code change to `Dockerfile.prod`, not a manifest. Included in scope as it is required for the init container to function.
- Image tag placeholder strategy is documented in Assumptions; the specifics of tag substitution (kustomize, sed, etc.) are left to planning.

View File

@@ -0,0 +1,59 @@
# Contract: Operator Deployment Interface
The manifests in `k8s/` define the operator's deployment interface — the inputs required before applying and the observable outputs after applying.
## Pre-deployment Prerequisites (Operator-supplied)
| Prerequisite | Details |
|---|---|
| Vault KV v2 secret at `reactbin/api/config` | Must contain keys: `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` |
| Vault KV v2 secret at `reactbin/minio/credentials` | Must contain keys: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` |
| Vault Kubernetes auth role | A role in the Vault Kubernetes auth mount bound to the `default` service account in the `reactbin` namespace with read access to both paths above |
| `VaultConnection` resource | Named `default` in the operator's VSO namespace pointing to the Vault server address |
| External PostgreSQL database | A dedicated database and user created; `DATABASE_URL` in Vault reflects the credentials |
| DNS | The production domain resolves to the cluster ingress IP |
| `ClusterIssuer` | A cert-manager `ClusterIssuer` named `letsencrypt-prod` exists in the cluster |
| Image tags | The operator substitutes the `latest` placeholder in `k8s/api/deployment.yaml` and `k8s/ui/deployment.yaml` with the real image tag before applying |
## Apply Command
```bash
# Substitute image tags
sed -i 's|reactbin-api:latest|reactbin-api:<tag>|g' k8s/api/deployment.yaml
sed -i 's|reactbin-ui:latest|reactbin-ui:<tag>|g' k8s/ui/deployment.yaml
# Apply all manifests
kubectl apply -f k8s/
```
Applying is idempotent — safe to re-run on every deployment.
## Observable Outputs (Post-apply)
| Resource | Expected State |
|---|---|
| `Namespace/reactbin` | Active |
| `Deployment/api` in `reactbin` | 1/1 Ready (init container completes first) |
| `Deployment/ui` in `reactbin` | 1/1 Ready |
| `StatefulSet/minio` in `reactbin` | 1/1 Ready |
| `Job/minio-init-bucket` in `reactbin` | Completed |
| `Secret/api-env` in `reactbin` | Created by VSO, populated with all API env keys |
| `Secret/minio-credentials` in `reactbin` | Created by VSO, populated with MinIO root keys |
| `Certificate/reactbin-tls` in `reactbin` | Issued (may take up to 2 minutes on first apply) |
| `Ingress/reactbin` in `reactbin` | Address populated with cluster ingress IP |
## Verification Commands
```bash
# All pods running
kubectl get pods -n reactbin
# API health
curl -sf https://<domain>/api/v1/health
# UI reachable
curl -sf https://<domain>/
# Docs correctly gated (should return 404)
curl -o /dev/null -w "%{http_code}" https://<domain>/docs
```

View File

@@ -0,0 +1,238 @@
# Implementation Plan: Kubernetes Production Manifests
**Branch**: `013-k8s-manifests` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/013-k8s-manifests/spec.md`
## Summary
Write Kubernetes manifests deploying Reactbin to k3s: a `Namespace`, API `Deployment` (with Alembic init container) + `Service`, UI `Deployment` + `Service`, a shared `Ingress` with Let's Encrypt TLS, a MinIO `StatefulSet` + `Service` + bucket-init `Job`, and three VSO CRDs (`VaultConnection`, `VaultAuth`, `VaultStaticSecret` × 2) to sync secrets from Vault. A small update to `api/Dockerfile.prod` includes Alembic migration files in the production image so the init container can run them.
## Technical Context
**Language/Version**: YAML (Kubernetes manifests); Python 3.12 (Dockerfile.prod touch)
**Primary Dependencies**: Kubernetes 1.29+ API, nginx Ingress controller, cert-manager (ClusterIssuer `letsencrypt-prod`), Vault Secrets Operator (`secrets.hashicorp.com/v1beta1`), MinIO
**Storage**: MinIO StatefulSet with ReadWriteOnce PVC (cluster default storage class); external PostgreSQL (operator-provisioned)
**Testing**: `kubectl apply --dry-run=client` for schema validation; `yamllint` for formatting
**Target Platform**: k3s cluster (Kubernetes 1.29+, Linux)
**Performance Goals**: No measurable impact — manifests are declarative config, not runtime code
**Constraints**: All secrets must come from Vault (no plaintext in manifests); all containers run non-root; MinIO is ClusterIP-only (no external Ingress)
**Scale/Scope**: 11 YAML files across `k8s/`; one Dockerfile.prod change; one Makefile target
## Constitution Check
| Principle | Requirement | Status |
|-----------|-------------|--------|
| §5.1 TDD | Failing tests before implementation | ✅ Dry-run validation script written before manifests |
| §5.4 CI before done | All tests pass before task marked done | ✅ kubectl dry-run + yamllint gate |
| §7.2 Env config | No hardcoded secrets or hostnames | ✅ All secrets via VSO; domain is operator-substituted placeholder |
| §7.3 Linting | `ruff` / linting passes | ✅ `yamllint` on all manifests |
| §2.6 No speculative abstraction | No Kustomize overlays or Helm chart | ✅ Plain YAML, single environment |
| §8 Scope boundaries | No multi-user, no OIDC, no OR/NOT tags | ✅ Not affected |
**No violations. All gates pass.**
*Post-design re-check*: The Dockerfile.prod change (FR-014) adds `alembic/` to the runtime stage only — no builder-stage change, no new dependencies, no behaviour change to the running API. Constitution unchanged.
## Project Structure
### Documentation (this feature)
```text
specs/013-k8s-manifests/
├── plan.md ← this file
├── research.md ← 8 decisions
├── contracts/
│ └── operator-deploy.md ← prerequisites + verification commands
├── quickstart.md ← deploy + verify + scenario walkthroughs
└── tasks.md ← generated by /speckit-tasks
```
### Source Code Changes
```text
k8s/ ← NEW directory
├── namespace.yaml ← Namespace: reactbin
├── api/
│ ├── deployment.yaml ← Deployment: api (with alembic init container)
│ └── service.yaml ← Service: api (ClusterIP, port 8000)
├── ui/
│ ├── deployment.yaml ← Deployment: ui
│ └── service.yaml ← Service: ui (ClusterIP, port 8080)
├── ingress.yaml ← Ingress: /api/ → api, / → ui, TLS via cert-manager
├── minio/
│ ├── statefulset.yaml ← StatefulSet: minio (volumeClaimTemplates)
│ ├── service.yaml ← Service: minio (ClusterIP, port 9000)
│ └── init-job.yaml ← Job: minio-init-bucket (mc mb --ignore-existing)
└── vault/
├── vault-auth.yaml ← VaultAuth: kubernetes method, reactbin SA
├── api-secret.yaml ← VaultStaticSecret → K8s Secret: api-env
└── minio-secret.yaml ← VaultStaticSecret → K8s Secret: minio-credentials
api/Dockerfile.prod ← MODIFIED: add alembic/ and alembic.ini to runtime stage
Makefile ← MODIFIED: add dry-run validation target
```
## Implementation Design
### `api/Dockerfile.prod` — runtime stage addition
```dockerfile
# In the runtime stage, after copying app/:
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup alembic.ini .
```
No builder-stage change. No new base image. The init container uses the same image and `workingDir: /app`.
### `k8s/namespace.yaml`
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: reactbin
```
### `k8s/vault/vault-auth.yaml`
```yaml
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: reactbin-auth
namespace: reactbin
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: reactbin
serviceAccount: default
audiences:
- https://kubernetes.default.svc
```
Note: `VaultConnection` is not included in the `k8s/` tree — it lives in the VSO operator's namespace and is operator-managed infrastructure, not application manifests.
### `k8s/vault/api-secret.yaml`
```yaml
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: api-secret
namespace: reactbin
spec:
vaultAuthRef: reactbin-auth
mount: secret
type: kv-v2
path: reactbin/api/config
refreshAfter: 1h
destination:
name: api-env
create: true
```
The API Deployment then uses `envFrom: [{secretRef: {name: api-env}}]`.
### `k8s/vault/minio-secret.yaml`
Same pattern, path `reactbin/minio/credentials`, destination `minio-credentials`.
### `k8s/api/deployment.yaml` — init container
```yaml
initContainers:
- name: alembic-migrate
image: reactbin-api:latest # same tag as main container
command: ["alembic", "upgrade", "head"]
workingDir: /app
envFrom:
- secretRef:
name: api-env
containers:
- name: api
image: reactbin-api:latest
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
```
### `k8s/ingress.yaml`
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: reactbin
namespace: reactbin
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts: [<your-domain>]
secretName: reactbin-tls
rules:
- host: <your-domain>
http:
paths:
- path: /api/
pathType: Prefix
backend:
service: {name: api, port: {number: 8000}}
- path: /
pathType: Prefix
backend:
service: {name: ui, port: {number: 8080}}
```
`/api/` must be listed before `/`.
### `k8s/minio/statefulset.yaml` — StatefulSet (not Deployment)
StatefulSet gives stable pod name `minio-0` and automatic PVC reattachment via `volumeClaimTemplates`. ReadWriteOnce, default storage class.
Health probes: `GET /minio/health/live:9000` (liveness), `GET /minio/health/ready:9000` (readiness).
### `k8s/minio/init-job.yaml`
```yaml
command: ["sh", "-c", "mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/reactbin"]
```
`restartPolicy: OnFailure`. `--ignore-existing` makes the job idempotent.
### Makefile addition
```makefile
validate-k8s:
yamllint k8s/
kubectl apply --dry-run=client -f k8s/
```
## Dependencies & Risks
| Item | Risk | Mitigation |
|------|------|------------|
| `VaultConnection` not in `k8s/` | Operator may not have it pre-created | Documented as prerequisite in contracts/operator-deploy.md |
| `letsencrypt-prod` ClusterIssuer name | May differ in operator's cluster | Documented as prerequisite; easy to sed-replace |
| Image tag placeholder `latest` | Operator forgets to substitute | `validate-k8s` dry-run will succeed but notes in quickstart.md and task descriptions warn explicitly |
| MinIO PVC storage class | Default may be unsuitable (e.g., ephemeral) | Noted in Assumptions; operator can patch `storageClassName` |
| `<your-domain>` placeholder in Ingress | `kubectl apply --dry-run=client` validates everything except host value | Noted in quickstart; hostname must be substituted before applying |

View File

@@ -0,0 +1,92 @@
# Quickstart: Kubernetes Production Deployment
## Before You Apply
1. Store API secrets in Vault at `reactbin/api/config` (KV v2):
```
DATABASE_URL = postgresql+asyncpg://reactbin:<pw>@<host>:5432/reactbin
JWT_SECRET_KEY = <long-random-string>
OWNER_USERNAME = <your-username>
OWNER_PASSWORD = <your-password>
S3_ENDPOINT_URL = http://minio.reactbin.svc.cluster.local:9000
S3_BUCKET_NAME = reactbin
S3_ACCESS_KEY_ID = <same as MINIO_ROOT_USER>
S3_SECRET_ACCESS_KEY = <same as MINIO_ROOT_PASSWORD>
API_BASE_URL = https://<your-domain>
API_DOCS_ENABLED = false
```
2. Store MinIO credentials in Vault at `reactbin/minio/credentials` (KV v2):
```
MINIO_ROOT_USER = <choose a strong username>
MINIO_ROOT_PASSWORD = <choose a strong password>
```
3. Create a Vault Kubernetes auth role bound to the `default` service account in the `reactbin` namespace with read access to both paths above.
4. Confirm DNS resolves to the cluster ingress IP and the `letsencrypt-prod` ClusterIssuer exists.
## Deploy
```bash
# Substitute the real image tags
sed -i 's|reactbin-api:latest|reactbin-api:v1.0.0|g' k8s/api/deployment.yaml
sed -i 's|reactbin-ui:latest|reactbin-ui:v1.0.0|g' k8s/ui/deployment.yaml
# Apply everything
kubectl apply -f k8s/
```
## Verify
```bash
# Watch pods come up (init container runs first on the API pod)
kubectl get pods -n reactbin -w
# API health
curl -sf https://<your-domain>/api/v1/health && echo "API OK"
# UI reachable
curl -sf -o /dev/null -w "%{http_code}\n" https://<your-domain>/
# Docs correctly gated
curl -o /dev/null -w "%{http_code}\n" https://<your-domain>/docs # → 404
curl -o /dev/null -w "%{http_code}\n" https://<your-domain>/redoc # → 404
# Check migration init container ran
kubectl logs -n reactbin -l app=api -c alembic-migrate
```
## Scenario: Migration fails on deploy
```bash
# Pod will be stuck in Init state
kubectl get pods -n reactbin
# NAME READY STATUS RESTARTS
# api-xxx-yyy 0/1 Init:CrashLoopBackOff 2
# See why
kubectl logs -n reactbin <pod-name> -c alembic-migrate
# Fix the issue (e.g. correct DATABASE_URL in Vault, wait for VSO to resync)
# Then delete the pod to force a fresh rollout
kubectl rollout restart deployment/api -n reactbin
```
## Scenario: Update to a new image version
```bash
kubectl set image deployment/api api=reactbin-api:v1.1.0 -n reactbin
kubectl set image deployment/ui ui=reactbin-ui:v1.1.0 -n reactbin
# Kubernetes rolls out new pods; init container runs migrations before traffic switches
```
## Scenario: Restore after MinIO pod restart
MinIO uses a PersistentVolumeClaim. Pod restarts do not affect stored data. Verify:
```bash
kubectl delete pod -n reactbin minio-0
kubectl get pods -n reactbin -w # minio-0 restarts, PVC reattaches
# Previously uploaded images should still be accessible via the API
```

View File

@@ -0,0 +1,63 @@
# Research: Kubernetes Production Manifests
## Decision 1: VSO CRD chain (VaultConnection → VaultAuth → VaultStaticSecret)
**Decision**: Use three CRDs — `VaultConnection`, `VaultAuth`, and `VaultStaticSecret` — all under `apiVersion: secrets.hashicorp.com/v1beta1`.
**Rationale**: This is the required VSO resource chain. `VaultConnection` points to the Vault server address. `VaultAuth` declares the Kubernetes auth method (role, service account, mount path). `VaultStaticSecret` references a `VaultAuth` via `vaultAuthRef` and declares the Vault KV path and the destination K8s Secret name. VSO syncs all Vault keys to the K8s Secret 1:1 by default — no explicit key mapping needed.
**Alternatives considered**: `VaultAuthGlobal` for cross-namespace sharing — not needed; all resources are in the same `reactbin` namespace.
Key fields:
- `VaultStaticSecret.spec.type`: `kv-v2` (standard for modern Vault)
- `VaultStaticSecret.spec.refreshAfter`: `1h` (Go duration string)
- `VaultStaticSecret.spec.destination.create: true` — VSO creates the K8s Secret if absent
- `VaultAuth.spec.kubernetes.role` — a Vault role the operator must pre-create and bind to the `reactbin` namespace service account
## Decision 2: MinIO as StatefulSet (not Deployment)
**Decision**: Run MinIO as a `StatefulSet` with `volumeClaimTemplates`.
**Rationale**: StatefulSet gives the pod a stable name (`minio-0`) and automatically reattaches its PVC on pod recreation. A Deployment would require a manually-created PVC and is prone to PVC binding issues on reschedule. The marginal complexity of a StatefulSet over a Deployment is acceptable. `ReadWriteOnce` PVC is correct for single-replica MinIO.
**Alternatives considered**: Deployment with explicit PVC — works but PVC lifecycle is decoupled from the pod, creating operational risk.
MinIO health probes:
- Liveness: `GET /minio/health/live:9000`
- Readiness: `GET /minio/health/ready:9000`
MinIO env vars: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` (injected from a K8s Secret synced by VSO).
## Decision 3: Bucket initialisation via Kubernetes Job with `minio/mc`
**Decision**: A one-off `Job` using `minio/mc:latest` runs `mc mb --ignore-existing` to create the bucket idempotently.
**Rationale**: This is the standard in-cluster pattern. `--ignore-existing` makes the job safe to re-apply (exits 0 if bucket already exists). `restartPolicy: OnFailure` retries transient failures (e.g. MinIO not yet ready).
**Alternatives considered**: Init container on the API pod — tightly couples bucket creation to API startup; a Job is cleaner and independently rerunnable.
## Decision 4: Ingress — single resource, `/api/` path before `/`
**Decision**: One `Ingress` resource with `ingressClassName: nginx`, two path entries in a single rule: `/api/` (Prefix) → API Service, `/` (Prefix) → UI Service; `/api/` must be listed first.
**Rationale**: nginx ingress evaluates paths in declaration order; the more specific `/api/` prefix must appear before `/` or all traffic is routed to the UI. No path rewriting annotation is needed — the API already handles full `/api/v1/...` paths.
**TLS**: cert-manager annotation `cert-manager.io/cluster-issuer: letsencrypt-prod` triggers automatic certificate provisioning into a K8s Secret named in `spec.tls[].secretName`. HTTP→HTTPS redirect is on by default when TLS is configured (`nginx.ingress.kubernetes.io/ssl-redirect: "true"` is explicit but redundant).
**Alternatives considered**: Two separate Ingress resources (one per service) — works but harder to reason about routing order; single Ingress is canonical.
## Decision 5: Alembic init container — same image, workdir `/app`
**Decision**: The API Deployment includes an init container with the same image as the main container, `command: ["alembic", "upgrade", "head"]`, and `workingDir: /app`. It shares the API's env secret via `envFrom` so it can read `DATABASE_URL`.
**Rationale**: Alembic needs `DATABASE_URL` to connect and `alembic.ini` + `alembic/` to find migrations. Both are available in the production image once `Dockerfile.prod` is updated. Using the same image guarantees the migration files match the running version.
**Dockerfile.prod update required**: Add `COPY --chown=appuser:appgroup alembic/ ./alembic/` and `COPY --chown=appuser:appgroup alembic.ini .` in the runtime stage (not the builder stage — no compilation needed).
**Alternatives considered**: Separate migration image — adds a second image to build and push on every release; unnecessary when the source image already has everything.
## Decision 6: Image tag strategy — placeholder `latest`, substituted at deploy time
**Decision**: Manifests reference image tags using `latest` as a documented placeholder. The operator substitutes the real tag with `kubectl set image` or a `sed` one-liner before applying.
**Rationale**: Kustomize's `images` transformer is the clean alternative, but introduces a tooling dependency. For a personal single-operator deployment, `sed` or `kubectl set image` after `kubectl apply` is simpler and requires no additional setup. The placeholder is documented in the operator guide (quickstart.md).
**Alternatives considered**: Kustomize overlays — appropriate for multi-environment setups; over-engineered for one environment.
## Decision 7: Two VaultStaticSecrets (API env and MinIO credentials)
**Decision**: Separate VaultStaticSecret resources for API env vars and MinIO root credentials, syncing into `api-env` and `minio-credentials` K8s Secrets respectively.
**Rationale**: The API's env secret contains database, JWT, and S3 access credentials. MinIO's root credentials are a different concern with a different rotation lifecycle. Keeping them separate makes Vault policies simpler (least privilege) and avoids giving the API pod access to MinIO's root password.
**Vault paths assumed**: `reactbin/api/config` (KV v2) for API env; `reactbin/minio/credentials` (KV v2) for MinIO root credentials.
## Decision 8: Namespace manifest included in `k8s/`
**Decision**: `k8s/namespace.yaml` creates the `reactbin` namespace as part of the manifest set.
**Rationale**: Makes the full deployment self-contained — operator runs `kubectl apply -f k8s/` without a prerequisite namespace creation step.
**Note**: If the namespace already exists, `kubectl apply` is idempotent.

View File

@@ -0,0 +1,124 @@
# Feature Specification: Kubernetes Production Manifests
**Feature Branch**: `013-k8s-manifests`
**Created**: 2026-05-07
**Status**: Draft
**Input**: User description: "Kubernetes manifests for production deployment to k3s: Deployment, Service, and Ingress for the API and UI; VaultStaticSecret CRDs to sync secrets from HashiCorp Vault; Alembic init container on the API Deployment for schema migrations. The cluster uses an nginx ingress controller with Let's Encrypt TLS, a shared external Postgres instance, MinIO running in-cluster, and VSO (Vault Secrets Operator) for secret management."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Application Reachable in Production (Priority: P1)
As an operator, I can apply the manifests to my k3s cluster and have both the API and UI reachable at the production domain over HTTPS, with all health checks passing.
**Why this priority**: This is the core deployment goal. Nothing else matters if the application is not reachable.
**Independent Test**: Apply the API and UI manifests with a manually-created K8s Secret (bypassing Vault). Confirm the UI loads at the domain root and the API health endpoint returns 200 at `/api/v1/health`. Confirm HTTPS is enforced and HTTP redirects to HTTPS.
**Acceptance Scenarios**:
1. **Given** the manifests are applied to the cluster, **When** a browser navigates to `https://<domain>/`, **Then** the UI loads successfully with a valid TLS certificate.
2. **Given** the manifests are applied, **When** a request is made to `https://<domain>/api/v1/health`, **Then** a 200 response is returned.
3. **Given** the API docs flag is disabled, **When** a request is made to `https://<domain>/docs`, **Then** a 404 is returned.
4. **Given** the API pod is restarted, **When** it comes back up, **Then** it passes readiness checks before receiving traffic.
5. **Given** a request for an unknown path, **When** it is made to the UI, **Then** the SPA serves the index page (client-side routing is preserved).
---
### User Story 2 — Secrets Sourced from Vault (Priority: P2)
As an operator, no secrets are stored in version-controlled manifest files. All sensitive values are declared in Vault and synced automatically into the cluster as Kubernetes Secrets by the Vault Secrets Operator.
**Why this priority**: Security prerequisite for production. Hardcoded secrets in manifests are a material risk.
**Independent Test**: Run `git grep` for known secret patterns across `k8s/` and confirm zero matches. Confirm VaultStaticSecret CRDs reference a Vault path and that the synced K8s Secret is created and the API pod's environment is populated from it.
**Acceptance Scenarios**:
1. **Given** Vault contains the required secret values at the declared path, **When** VSO is running, **Then** a K8s Secret is created in the cluster namespace with the declared keys.
2. **Given** the K8s Secret exists, **When** the API pod starts, **Then** its environment variables are populated from that secret.
3. **Given** a `git grep` for plaintext credentials across `k8s/`, **When** run against the committed manifests, **Then** no plaintext secrets are found.
---
### User Story 3 — Schema Migrations Run Before API Starts (Priority: P3)
As an operator, every time the API is deployed, database migrations run automatically in an init container before the main application container starts. A failed migration prevents the pod from starting, protecting against schema drift.
**Why this priority**: Prevents the API from serving requests against a stale or incompatible schema. Safe deployment ordering is essential for production.
**Independent Test**: Deploy with the init container pointing at a valid database. Confirm migrations run and the API starts. Simulate a failing migration by pointing the init container at an unreachable database and confirm the pod stays in init state and does not serve traffic.
**Acceptance Scenarios**:
1. **Given** the API Deployment is applied, **When** the pod starts, **Then** the init container completes `alembic upgrade head` before the main container starts.
2. **Given** the schema is already current, **When** the pod starts, **Then** the migration init container exits successfully with no changes applied.
3. **Given** the migration fails, **When** the pod starts, **Then** the init container exits non-zero, the main container does not start, and the pod enters a visible error state.
---
### User Story 4 — MinIO Runs In-Cluster with Persistent Storage (Priority: P4)
As an operator, MinIO runs inside the cluster with a PersistentVolumeClaim for durable storage, is not externally reachable, and has the required bucket initialised on first deployment.
**Why this priority**: Required for image storage, but decoupled from the other manifests — the S3 endpoint is just a config value the API reads.
**Independent Test**: Confirm the MinIO pod is running and has no external Ingress. Confirm the required bucket exists. Restart the MinIO pod and confirm previously stored objects are still accessible.
**Acceptance Scenarios**:
1. **Given** the MinIO manifests are applied, **When** the MinIO pod starts, **Then** the required bucket is created and the API can store and retrieve images.
2. **Given** the MinIO pod is restarted, **When** it comes back up, **Then** all previously stored objects remain accessible (PVC-backed storage persists).
3. **Given** no Ingress is defined for MinIO, **When** a connection is attempted from outside the cluster, **Then** MinIO is not reachable.
---
### Edge Cases
- What if Vault is unavailable when VSO tries to sync? VSO retries on a configurable interval; the pod will not start until the K8s Secret exists.
- What if the database is unreachable during migration? The init container exits non-zero; the pod does not start and Kubernetes retries with backoff.
- What if the MinIO PVC runs out of space? MinIO will fail writes; the API will return upload errors. Capacity monitoring is out of scope for this feature.
- What if migrations and the main container use different image tags? They use the same tag in the same Deployment spec, so they are always in sync.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: All manifests MUST target a single configurable namespace (default: `reactbin`).
- **FR-002**: The API MUST be deployed as a Deployment with liveness and readiness probes on `/api/v1/health`.
- **FR-003**: The API Deployment MUST include an init container using the same image that runs database schema migrations before the main container starts.
- **FR-004**: The API Deployment MUST set `API_DOCS_ENABLED=false`.
- **FR-005**: The UI MUST be deployed as a Deployment with a liveness probe confirming the nginx process is serving.
- **FR-006**: A single Ingress MUST route `https://<domain>/api/` to the API Service and all other paths to the UI Service, with TLS termination via a cert-manager Let's Encrypt certificate.
- **FR-007**: HTTP requests MUST be redirected to HTTPS via the Ingress.
- **FR-008**: All API secrets MUST be declared in a VaultStaticSecret CRD and synced into a K8s Secret; no secret value MUST appear as plaintext in any manifest file.
- **FR-009**: The API Deployment MUST source all environment variables from the synced K8s Secret via `envFrom`.
- **FR-010**: MinIO MUST be deployed as a StatefulSet with a PersistentVolumeClaim using the cluster's default storage class.
- **FR-011**: A Kubernetes Job MUST create the required S3 bucket in MinIO on first deployment and MUST be idempotent on re-apply.
- **FR-012**: MinIO MUST have no Ingress; it MUST only be accessible within the cluster via ClusterIP.
- **FR-013**: All containers MUST run as non-root users.
- **FR-014**: The API production image MUST include migration files so the init container can run migrations without a separate image.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: The application is accessible at the production domain within 120 seconds of `kubectl apply`.
- **SC-002**: Schema migrations complete and the API begins serving traffic without manual operator intervention on every deployment.
- **SC-003**: A `git grep` across `k8s/` finds zero plaintext secret values in committed files.
- **SC-004**: A simulated migration failure holds the pod in init state and the application never serves traffic.
- **SC-005**: Restarting the MinIO pod does not result in data loss — previously uploaded images remain accessible.
## Assumptions
- The k3s cluster is running with the nginx ingress controller installed.
- cert-manager is installed and a `ClusterIssuer` named `letsencrypt-prod` is already configured.
- The Vault Secrets Operator is installed in the cluster.
- A HashiCorp Vault instance is accessible from the cluster and the required secret values are stored at the declared Vault path before deployment.
- A shared external PostgreSQL instance is available; the operator creates a dedicated database and user before deploying.
- DNS for the production domain is already pointing at the cluster ingress IP.
- Manifests are stored in a `k8s/` directory at the repository root.
- The cluster's default storage class supports ReadWriteOnce (sufficient for single-replica MinIO).
- All Deployments run a single replica (personal tool, no HA requirement).
- Image tags are managed externally; manifests use a placeholder tag that the operator substitutes at deploy time.
- The `API_DOCS_ENABLED` flag exists on the API (implemented in feature 012).

View File

@@ -0,0 +1,174 @@
# Tasks: Kubernetes Production Manifests
**Input**: Design documents from `specs/013-k8s-manifests/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/operator-deploy.md ✅, quickstart.md ✅
**Tests**: K8s manifests have no unit test framework. Validation is via `yamllint` (format) and `kubectl apply --dry-run=client` (schema). Each phase ends with a validation step. The TDD analogue is: write the validate-k8s Makefile target (Phase 1) before any manifest exists, so it immediately fails — then manifests are written to make it pass.
**Organization**: Phase 1 creates the directory structure and validation target. Phase 2 creates the namespace and Vault CRDs (foundational — required by all user story deployments). Phases 36 implement user stories. Phase 7 polishes.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel with other [P] tasks in the same phase
- **[Story]**: Which user story this task belongs to
- Exact file paths included in every task description
---
## Phase 1: Setup
**Goal**: Create the `k8s/` directory structure and the validation Makefile target before any manifests exist.
- [X] T001 Create the `k8s/` directory tree: `mkdir -p k8s/api k8s/ui k8s/minio k8s/vault` from the repository root; confirm the four subdirectories exist
- [X] T002 Add a `validate-k8s` target to `Makefile` immediately after the existing `verify-ui-prod` target: the target MUST run `yamllint -d relaxed k8s/` then `kubectl apply --dry-run=client -f k8s/`; add `validate-k8s` to the `.PHONY` line; note in a comment that `kubectl apply --dry-run=client` requires a kubeconfig with cluster access — offline validation uses `yamllint` only
---
## Phase 2: Foundational (Namespace + Vault CRDs)
**Goal**: Namespace and Vault secret-sync resources that every other manifest depends on.
**⚠️ CRITICAL**: No user story manifest can be applied until this phase is complete — the namespace must exist before any namespaced resource, and the Vault CRDs must exist before the API or MinIO pods can start.
- [X] T003 Create `k8s/namespace.yaml`: a single `Namespace` resource with `name: reactbin` and no additional labels
- [X] T004 [P] Create `k8s/vault/vault-auth.yaml`: a `VaultAuth` resource (`apiVersion: secrets.hashicorp.com/v1beta1`) with `name: reactbin-auth`, `namespace: reactbin`, `spec.method: kubernetes`, `spec.mount: kubernetes`, `spec.kubernetes.role: reactbin`, `spec.kubernetes.serviceAccount: default`, `spec.kubernetes.audiences: [https://kubernetes.default.svc]`; add a comment noting the operator must create the Vault role and bind it to the `default` SA in the `reactbin` namespace with read access to both secret paths
- [X] T005 [P] Create `k8s/vault/api-secret.yaml`: a `VaultStaticSecret` resource with `name: api-secret`, `namespace: reactbin`, `spec.vaultAuthRef: reactbin-auth`, `spec.mount: secret`, `spec.type: kv-v2`, `spec.path: reactbin/api/config`, `spec.refreshAfter: 1h`, `spec.destination.name: api-env`, `spec.destination.create: true`; add a comment listing all required Vault keys: `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`
- [X] T006 [P] Create `k8s/vault/minio-secret.yaml`: same structure as T005 but `name: minio-secret`, `spec.path: reactbin/minio/credentials`, `spec.destination.name: minio-credentials`; comment listing required Vault keys: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`
**Checkpoint**: Foundational resources complete. User story implementation can now begin.
---
## Phase 3: User Story 1 — Application Reachable in Production (Priority: P1) 🎯 MVP
**Goal**: API and UI are deployed and reachable at the production domain via HTTPS with TLS from cert-manager.
**Independent Test**: Apply all Phase 2 + Phase 3 manifests. Confirm `kubectl get pods -n reactbin` shows api and ui pods Running. Confirm `curl https://<domain>/api/v1/health` returns 200 and `curl https://<domain>/` returns 200.
- [X] T007 [P] [US1] Create `k8s/api/service.yaml`: `Service`, `name: api`, `namespace: reactbin`, `type: ClusterIP`, `selector: {app: api}`, `ports: [{port: 8000, targetPort: 8000, name: http}]`
- [X] T008 [P] [US1] Create `k8s/ui/service.yaml`: `Service`, `name: ui`, `namespace: reactbin`, `type: ClusterIP`, `selector: {app: ui}`, `ports: [{port: 8080, targetPort: 8080, name: http}]`
- [X] T009 [P] [US1] Create `k8s/ui/deployment.yaml`: `Deployment`, `name: ui`, `namespace: reactbin`, 1 replica, `selector.matchLabels: {app: ui}`; container `name: ui`, `image: reactbin-ui:latest` (placeholder — operator substitutes real tag), `ports: [{containerPort: 8080}]`; `livenessProbe: {httpGet: {path: /, port: 8080}, initialDelaySeconds: 10, periodSeconds: 30}`; `securityContext: {runAsNonRoot: true, runAsUser: 101}` (UID 101 is the nginxinc/nginx-unprivileged user); add comment: `# Replace 'latest' with the real image tag before applying`
- [X] T010 [US1] Create `k8s/api/deployment.yaml`: `Deployment`, `name: api`, `namespace: reactbin`, 1 replica, `selector.matchLabels: {app: api}`; container `name: api`, `image: reactbin-api:latest` (placeholder), `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}`; add comment: `# initContainers block added in US3 (T015)`; add comment: `# Replace 'latest' with the real image tag before applying`
- [X] T011 [US1] Create `k8s/ingress.yaml`: `Ingress`, `name: reactbin`, `namespace: reactbin`; `annotations: {"cert-manager.io/cluster-issuer": "letsencrypt-prod", "nginx.ingress.kubernetes.io/ssl-redirect": "true"}`; `spec.ingressClassName: nginx`; `spec.tls: [{hosts: ["<your-domain>"], secretName: reactbin-tls}]`; `spec.rules: [{host: "<your-domain>", http: {paths: [{path: /api/, pathType: Prefix, backend: {service: {name: api, port: {number: 8000}}}}, {path: /, pathType: Prefix, backend: {service: {name: ui, port: {number: 8080}}}}]}}]`; IMPORTANT — `/api/` path entry MUST appear before `/` in the YAML (nginx evaluates in declaration order); add comment: `# Replace <your-domain> with the real domain before applying`
- [X] T012 [US1] Verify US1: run `yamllint -d relaxed k8s/` from the repository root and confirm no errors; run `kubectl apply --dry-run=client -f k8s/` (requires cluster kubeconfig) and confirm all resources in namespace.yaml, vault/, api/, ui/, and ingress.yaml are accepted; if no cluster is available, yamllint passing is sufficient for this checkpoint
**Checkpoint**: US1 complete. API and UI manifests are schema-valid and ready to apply.
---
## Phase 4: User Story 2 — Secrets Sourced from Vault (Priority: P2)
**Goal**: Confirm that no plaintext secret values appear in any committed manifest file. The implementation (VaultAuth + VaultStaticSecret × 2) was completed in Phase 2.
**Independent Test**: `git grep` across `k8s/` finds no plaintext credential values.
- [X] T013 [US2] Verify US2: run `git grep -rn "password\|secret_key\|access_key\|DATABASE_URL" k8s/` and confirm that only key names (in comments) and Vault path references appear — no actual values; also confirm that `k8s/vault/api-secret.yaml` and `k8s/vault/minio-secret.yaml` reference Vault paths under `spec.path` and that `spec.destination.create: true` is set so VSO creates the K8s Secrets
**Checkpoint**: US2 complete. Zero plaintext secrets in manifests; all secrets flow through Vault.
---
## Phase 5: User Story 3 — Schema Migrations Run Before API Starts (Priority: P3)
**Goal**: The API Deployment includes an Alembic init container. `api/Dockerfile.prod` is updated to include migration files.
**Independent Test**: `docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test` succeeds and `docker run --rm reactbin-api-prod:test ls /app/alembic` shows migration files. `make validate-k8s` confirms the init container spec is accepted by the Kubernetes schema.
- [X] T014 [US3] Update `api/Dockerfile.prod`: in the **runtime stage** (the `FROM python:3.12-slim` stage), after the line `COPY --chown=appuser:appgroup app/ ./app/`, add two new lines: `COPY --chown=appuser:appgroup alembic/ ./alembic/` and `COPY --chown=appuser:appgroup alembic.ini .`; the builder stage is unchanged; verify with `docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test && docker run --rm reactbin-api-prod:test ls /app/alembic /app/alembic.ini`
- [X] T015 [US3] Update `k8s/api/deployment.yaml`: add an `initContainers` block to the pod spec (before the `containers` block) containing one init container: `name: alembic-migrate`, `image: reactbin-api:latest` (same placeholder tag as the main container), `command: ["alembic", "upgrade", "head"]`, `workingDir: /app`, `envFrom: [{secretRef: {name: api-env}}]`, `securityContext: {runAsNonRoot: true, runAsUser: 1001}`; remove the `# initContainers block added in US3 (T015)` comment added in T010
- [X] T016 [US3] Verify US3: run `make validate-k8s` (or `yamllint -d relaxed k8s/`) and confirm the updated deployment.yaml with the init container passes validation; run `docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test` and confirm it succeeds; run `docker run --rm reactbin-api-prod:test ls /app/alembic.ini` and confirm the file is present
**Checkpoint**: US3 complete. API Deployment includes Alembic init container; production image includes migration files.
---
## Phase 6: User Story 4 — MinIO In-Cluster with Persistent Storage (Priority: P4)
**Goal**: MinIO runs as a StatefulSet with a PVC, is accessible only within the cluster, and has the required bucket created by a Job.
**Independent Test**: `make validate-k8s` confirms all MinIO manifests pass schema validation. On a live cluster: MinIO pod reaches Running state, bucket exists, no external Ingress for MinIO.
- [X] T017 [P] [US4] Create `k8s/minio/service.yaml`: `Service`, `name: minio`, `namespace: reactbin`, `type: ClusterIP`, `selector: {app: minio}`, `ports: [{port: 9000, targetPort: 9000, name: s3}]`; add comment: `# No Ingress for MinIO — internal access only (FR-012)`
- [X] T018 [US4] Create `k8s/minio/statefulset.yaml`: `StatefulSet` (NOT Deployment — StatefulSet ensures stable PVC binding on pod recreation), `name: minio`, `namespace: reactbin`, `replicas: 1`, `selector.matchLabels: {app: minio}`, `serviceName: minio`; pod `securityContext: {runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000}`; container `name: minio`, `image: minio/minio:latest`, `args: ["server", "/data", "--console-address", ":9001"]`, `ports: [{containerPort: 9000, name: s3}]`; `env: [{name: MINIO_ROOT_USER, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_USER}}}, {name: MINIO_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_PASSWORD}}}]`; `livenessProbe: {httpGet: {path: /minio/health/live, port: 9000}, initialDelaySeconds: 30, periodSeconds: 20}`; `readinessProbe: {httpGet: {path: /minio/health/ready, port: 9000}, initialDelaySeconds: 15, periodSeconds: 10}`; `volumeMounts: [{name: minio-data, mountPath: /data}]`; `volumeClaimTemplates: [{metadata: {name: minio-data}, spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 10Gi}}}}]`; add comment: `# storageClassName omitted — uses cluster default; override if needed`
- [X] T019 [US4] Create `k8s/minio/init-job.yaml`: `Job`, `name: minio-init-bucket`, `namespace: reactbin`; `spec.template.spec.restartPolicy: OnFailure`; container `name: mc`, `image: minio/mc:latest`, `command: ["sh", "-c"]`, `args: ["mc alias set local http://minio.reactbin.svc.cluster.local:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/reactbin"]`; `env: [{name: MINIO_ROOT_USER, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_USER}}}, {name: MINIO_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_PASSWORD}}}]`; `securityContext: {runAsNonRoot: false}` with comment `# minio/mc runs as root by default; FR-013 exception for this one-off init Job`; add comment: `# --ignore-existing makes this Job idempotent — safe to re-apply`
- [X] T020 [US4] Verify US4: run `make validate-k8s` (or `yamllint -d relaxed k8s/`) and confirm all three MinIO manifests (statefulset.yaml, service.yaml, init-job.yaml) pass validation; confirm no Ingress resource references MinIO
**Checkpoint**: All four user stories complete.
---
## Phase 7: Polish & Cross-Cutting Concerns
- [X] T021 [P] Run `yamllint -d relaxed k8s/` from the repository root and fix any YAML formatting violations across all 12 manifest files; confirm output shows no errors
- [X] T022 [P] Add `.yamllint.yml` at the repository root (if not already present) with `extends: relaxed` and `rules: {line-length: {max: 120}}` to keep line length reasonable for verbose K8s YAML
- [X] T023 Run `make build-prod` to confirm `api/Dockerfile.prod` still builds cleanly after the T014 addition; run `docker run --rm reactbin-api-prod:latest ls /app/alembic.ini /app/alembic/` and confirm both are present in the production image
---
## Dependencies & Execution Order
- T001 and T002 can run in parallel (directory creation vs Makefile edit)
- T003, T004, T005, T006 can run in parallel after T001 (different files, same phase)
- T007, T008, T009 can run in parallel after Phase 2 completes
- T010 after T007 (deployment references service name, easier to write with service done) — but they're different files so technically parallel; keep sequential for clarity
- T011 after T007 and T008 (Ingress references both service names)
- T012 after T007T011
- T013 after Phase 2 (Vault CRDs exist to inspect)
- T014 and T015 can run in parallel (different files: Dockerfile.prod vs deployment.yaml)
- T016 after T014 and T015
- T017, T018, T019 can run in parallel after Phase 2 completes
- T020 after T017T019
- T021, T022, T023 can run in parallel
### Execution Order Summary
```
Step 1: T001 ∥ T002 (setup)
Step 2: T003 ∥ T004 ∥ T005 ∥ T006 (foundational: namespace + Vault CRDs)
Step 3: T007 ∥ T008 ∥ T009 (US1: services + UI deployment)
Step 4: T010 (US1: API deployment)
Step 5: T011 (US1: Ingress)
Step 6: T012 (US1: validate)
Step 7: T013 (US2: verify no plaintext secrets)
Step 8: T014 ∥ T015 (US3: Dockerfile.prod + init container)
Step 9: T016 (US3: verify)
Step 10: T017 ∥ T018 ∥ T019 (US4: MinIO manifests)
Step 11: T020 (US4: validate MinIO)
Step 12: T021 ∥ T022 ∥ T023 (polish)
```
---
## Implementation Strategy
### MVP (US1 + US2 — application is reachable with Vault-backed secrets)
1. Phase 1 (Setup) + Phase 2 (Foundational)
2. Phase 3 (US1 — API, UI, Ingress)
3. Phase 4 (US2 — verify no plaintext secrets)
4. **STOP and VALIDATE**: apply to cluster, confirm `https://<domain>/` and `/api/v1/health` return 200
5. Deploy MVP
### Incremental Delivery
1. Setup + Foundational → Apply → namespace and Vault sync ready
2. Add US1 (API + UI + Ingress) → Deploy → application reachable at domain
3. Add US3 (Alembic init container) → Deploy → migrations run automatically on rollout
4. Add US4 (MinIO) → Deploy → persistent image storage in-cluster
5. Polish → clean YAML, confirmed builds

View File

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: CDN Image Serving
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-08
**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. Ready for `/speckit-plan`.

View File

@@ -0,0 +1,54 @@
# Contract: Image Metadata Response
**Version**: 2.0 (adds `file_url`, `thumbnail_url`)
**Endpoints affected**: `GET /api/v1/images`, `GET /api/v1/images/{id}`, `POST /api/v1/images`, `PATCH /api/v1/images/{id}/tags`
## Response Schema
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"filename": "reaction.gif",
"mime_type": "image/gif",
"size_bytes": 204800,
"width": 480,
"height": 270,
"storage_key": "e3b0c44298fc1c149afbf4c8996fb924",
"thumbnail_key": "e3b0c44298fc1c149afbf4c8996fb924.thumb",
"file_url": "https://cdn.reactbin.juggalol.com/e3b0c44298fc1c149afbf4c8996fb924",
"thumbnail_url": "https://cdn.reactbin.juggalol.com/e3b0c44298fc1c149afbf4c8996fb924.thumb",
"created_at": "2026-05-08T12:00:00.000000",
"tags": ["funny", "reaction"]
}
```
## Field Descriptions
| Field | Type | Nullable | Notes |
|-------|------|----------|-------|
| `id` | string (UUID) | No | Stable image identifier |
| `hash` | string (hex) | No | SHA-256 of file content; deduplication key |
| `filename` | string | No | Original upload filename |
| `mime_type` | string | No | One of: `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
| `size_bytes` | integer | No | File size in bytes |
| `width` | integer | No | Image width in pixels |
| `height` | integer | No | Image height in pixels |
| `storage_key` | string | No | Object storage key (retained for backward compat) |
| `thumbnail_key` | string | Yes | Thumbnail object storage key; null if generation failed |
| `file_url` | string | No | Full URL to fetch the image file — CDN URL in production, API proxy path in local dev |
| `thumbnail_url` | string | Yes | Full URL to fetch the thumbnail — CDN URL in production, API proxy path in local dev; null if no thumbnail |
| `created_at` | string (ISO 8601) | No | Upload timestamp |
| `tags` | string[] | No | Lowercase normalised tag list |
| `duplicate` | boolean | Yes | Present only on upload responses; true if hash matched an existing image |
## URL Behaviour
| Configuration | `file_url` example | `thumbnail_url` example |
|---------------|--------------------|------------------------|
| `S3_PUBLIC_BASE_URL` set | `https://cdn.reactbin.juggalol.com/{storage_key}` | `https://cdn.reactbin.juggalol.com/{thumbnail_key}` |
| `S3_PUBLIC_BASE_URL` not set | `/api/v1/images/{id}/file` | `/api/v1/images/{id}/thumbnail` |
## UI Contract
The UI MUST use `file_url` and `thumbnail_url` from the response to render images. The UI MUST NOT construct image URLs from `id`, `storage_key`, or `thumbnail_key` directly. The UI MUST treat `thumbnail_url: null` as "no thumbnail available" and fall back to `file_url` for display.

View File

@@ -0,0 +1,137 @@
# Implementation Plan: CDN Image Serving
**Branch**: `014-r2-cdn-serving` | **Date**: 2026-05-08 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/014-r2-cdn-serving/spec.md`
## Summary
Extend the image metadata API response to include `file_url` and `thumbnail_url` fields. When `S3_PUBLIC_BASE_URL` is configured, these fields contain CDN URLs pointing directly to Cloudflare R2. When unconfigured, they fall back to the existing API proxy paths so local development requires no setup changes. The UI is updated to use these response fields instead of constructing proxy URLs client-side. Proxy endpoints are retained unchanged.
## Technical Context
**Language/Version**: Python 3.12 (API), TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Angular (latest stable), pydantic-settings
**Storage**: PostgreSQL (image metadata), S3-compatible object storage (R2 in production, MinIO in dev)
**Testing**: pytest (unit + integration), Angular component tests
**Target Platform**: Linux (k3s), local Docker Compose
**Project Type**: Web service (API) + SPA (UI)
**Performance Goals**: No additional latency on API responses; image load latency reduced by eliminating API proxy hop in production
**Constraints**: No breaking changes to existing API response fields; proxy endpoints must remain functional
**Scale/Scope**: Single-owner app; ~100 existing images migrated to R2 prior to this feature
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation of concerns | PASS | URL construction stays in router layer; storage backend unchanged |
| §2.3 Storage abstraction | PASS | No changes to `StorageBackend` interface or `S3StorageBackend` |
| §2.6 No speculative abstraction | PASS | No new interfaces introduced; URL logic is a simple helper |
| §3.1 API versioning (`/api/v1/`) | PASS | Adding fields to response is non-breaking per §3.1 |
| §3.2 OpenAPI as contract | PASS | New fields documented in contracts/image-response.md |
| §5.1 Tests alongside implementation | REQUIRED | Unit tests for URL construction; integration tests for response fields |
| §7.2 Environment configuration | PASS | `S3_PUBLIC_BASE_URL` via env var; no hardcoded URLs |
No constitution violations. All gates pass.
## Project Structure
### Documentation (this feature)
```text
specs/014-r2-cdn-serving/
├── plan.md # This file
├── research.md # Technical decisions
├── contracts/
│ └── image-response.md # Updated image response schema
├── quickstart.md # Integration test scenarios
└── tasks.md # Phase 2 output (speckit-tasks)
```
### Source Code Changes
```text
api/
├── app/
│ ├── config.py # Add: s3_public_base_url: str | None = None
│ └── routers/
│ └── images.py # Update: _image_to_dict gains cdn_base param;
│ # add file_url + thumbnail_url to response;
│ # pass cdn_base from get_settings() at endpoint level
├── tests/
│ ├── unit/
│ │ └── test_url_construction.py # New: pure unit tests for URL logic
│ └── integration/
│ └── test_images.py # Update: assert file_url + thumbnail_url present in responses
ui/src/app/
├── services/
│ └── image.service.ts # Update: add file_url/thumbnail_url to ImageRecord;
│ # remove getFileUrl()/getThumbnailUrl() methods
├── library/
│ └── library.component.ts # Update: use img.thumbnail_url instead of getThumbnailUrl(img.id)
└── detail/
└── detail.component.ts # Update: use img.file_url instead of getFileUrl(img.id)
.env.example # Add: S3_PUBLIC_BASE_URL= (empty = local dev proxy fallback)
```
## Key Implementation Details
### URL construction logic (`api/app/routers/images.py`)
`_image_to_dict` gains a `cdn_base: str | None` parameter:
```python
def _image_to_dict(image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None):
base = cdn_base.rstrip("/") if cdn_base else None
file_url = f"{base}/{image.storage_key}" if base else f"/api/v1/images/{image.id}/file"
thumbnail_url = (
(f"{base}/{image.thumbnail_key}" if base else f"/api/v1/images/{image.id}/thumbnail")
if image.thumbnail_key else None
)
return {
..., # existing fields unchanged
"file_url": file_url,
"thumbnail_url": thumbnail_url,
}
```
Each endpoint calls `get_settings()` once and passes `settings.s3_public_base_url` as `cdn_base`.
### Config addition (`api/app/config.py`)
```python
s3_public_base_url: str | None = None
```
No validator needed — `None` is the valid "not configured" state.
### UI changes (`ui/src/app/services/image.service.ts`)
`ImageRecord` gains two new fields:
```typescript
file_url: string;
thumbnail_url: string | null;
```
`getFileUrl(id)` and `getThumbnailUrl(id)` methods are removed. Components use `image.file_url` and `image.thumbnail_url` directly.
## Phase Breakdown
### Phase 1: API — config + URL construction (US1 foundation)
- Add `s3_public_base_url` to config
- Update `_image_to_dict` with `cdn_base` parameter
- Update all call sites to pass `cdn_base` from settings
- Unit tests for URL construction (both CDN and fallback paths)
- Integration tests verifying `file_url`/`thumbnail_url` in all image responses
### Phase 2: UI — consume response URLs (US1 + US2)
- Update `ImageRecord` interface
- Remove `getFileUrl`/`getThumbnailUrl` methods from service
- Update library component
- Update detail component
- Update service tests
### Phase 3: Config + docs
- Add `S3_PUBLIC_BASE_URL` to `.env.example`
- Manual end-to-end verification (local dev + production)

View File

@@ -0,0 +1,66 @@
# Quickstart: CDN Image Serving
## Local development (no CDN)
No configuration change required. `S3_PUBLIC_BASE_URL` is unset by default.
```bash
docker compose up
```
Upload an image and inspect the API response:
```bash
curl -s http://localhost:8000/api/v1/images | jq '.items[0] | {file_url, thumbnail_url}'
```
Expected (local dev — relative proxy paths):
```json
{
"file_url": "/api/v1/images/550e8400-.../file",
"thumbnail_url": "/api/v1/images/550e8400-.../thumbnail"
}
```
The UI loads images via these relative paths, which hit the API proxy as before.
---
## Production (CDN configured)
Add `S3_PUBLIC_BASE_URL` to the Vault secret bundle at `reactbin/api/config`:
```
S3_PUBLIC_BASE_URL = https://cdn.reactbin.juggalol.com
```
Force VSO sync and restart:
```bash
kubectl annotate vaultstaticsecret api-secret -n reactbin \
secrets.hashicorp.com/force-sync=$(date +%s) --overwrite
kubectl rollout restart deployment/api -n reactbin
```
Upload a test image and inspect the response:
```bash
curl -s https://reactbin.juggalol.com/api/v1/images | jq '.items[0] | {file_url, thumbnail_url}'
```
Expected (production — CDN URLs):
```json
{
"file_url": "https://cdn.reactbin.juggalol.com/e3b0c44...",
"thumbnail_url": "https://cdn.reactbin.juggalol.com/e3b0c44....thumb"
}
```
Open the browser network panel on the library page and confirm image requests go to `cdn.reactbin.juggalol.com`, not `/api/`.
---
## Verifying existing images after migration
All existing images were migrated to R2 with the same object keys before this feature was deployed. Once `S3_PUBLIC_BASE_URL` is configured, the API will return CDN URLs for all images immediately — no per-image migration step is needed.

View File

@@ -0,0 +1,51 @@
# Research: CDN Image Serving
## Decision 1: Where does URL construction logic live?
**Decision**: In the image router's `_image_to_dict` helper, not in the `StorageBackend`.
**Rationale**: The `StorageBackend` interface is responsible for put/get/delete of object bytes. Adding URL construction there conflates two concerns — storage operations and HTTP URL generation — and would require the storage abstraction to know about CDN configuration. The router already has access to application settings via `get_settings()` and knows the image ID and storage key, making it the natural place to construct URLs.
**Alternatives considered**: Adding a `get_url(key)` method to `StorageBackend` — rejected because it leaks HTTP/CDN concerns into the storage abstraction, violating §2.3.
---
## Decision 2: Fallback URL format in local development
**Decision**: Relative paths (`/api/v1/images/{id}/file`, `/api/v1/images/{id}/thumbnail`) when `S3_PUBLIC_BASE_URL` is not set.
**Rationale**: Relative paths work regardless of the host the app is running on, require no additional configuration, and match how the UI currently constructs these URLs via `getFileUrl(id)` and `getThumbnailUrl(id)`. An absolute fallback would require `API_BASE_URL` to be set in local dev, adding unnecessary setup friction.
**Alternatives considered**: Absolute URL fallback using `API_BASE_URL` — rejected because it adds a mandatory config dependency where none exists today.
---
## Decision 3: Trailing slash normalisation
**Decision**: Strip trailing slash from `S3_PUBLIC_BASE_URL` at construction time using `rstrip('/')` in the config validator or at point of use.
**Rationale**: Prevents double-slash URLs (`https://cdn.example.com//key`) if the operator includes a trailing slash in the configured value. Simple, defensive, zero-cost.
---
## Decision 4: Proxy endpoints retained or removed?
**Decision**: Retained, fully functional, unchanged.
**Rationale**: Spec FR-005 explicitly requires them. They serve as the local dev fallback and a safety net if the CDN is temporarily unavailable or misconfigured. Removing them would break local development immediately.
---
## Decision 5: `storage_key` and `thumbnail_key` in API response
**Decision**: Keep both fields in the response alongside the new `file_url` and `thumbnail_url`.
**Rationale**: Removing them is a breaking API change. The UI currently reads `thumbnail_key` to decide whether a thumbnail exists. After this change the UI will use `thumbnail_url` (null when no thumbnail), but the keys remain in the response for backward compatibility with any tooling.
---
## Decision 6: Settings access in `_image_to_dict`
**Decision**: Pre-compute the CDN base URL string once per request at the endpoint level and pass it into `_image_to_dict` as a parameter, rather than calling `get_settings()` inside the helper.
**Rationale**: Keeps `_image_to_dict` a pure function (easier to test), avoids calling `get_settings()` inside a helper that is called in a loop (image list endpoint), and makes the dependency explicit.

View File

@@ -0,0 +1,93 @@
# Feature Specification: CDN Image Serving
**Feature Branch**: `014-r2-cdn-serving`
**Created**: 2026-05-08
**Status**: Draft
**Input**: User description: "R2 CDN image serving with local dev fallback to API proxy"
## Overview
Images and thumbnails are currently served by proxying bytes through the API. This feature changes image delivery so that clients receive direct URLs pointing to a CDN edge network, eliminating the API as a middleman for image content. In local development, where no CDN is available, the API proxy endpoints remain as a fallback so the developer experience is unchanged.
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Images Load Directly from CDN (Priority: P1)
When a visitor views the image library or opens an image detail page, images and thumbnails are fetched directly from the CDN rather than through the application server. The page loads faster because image bytes no longer pass through the API.
**Why this priority**: Core value of the feature. Reduces API load and improves image load speed for all users.
**Independent Test**: Upload an image, open the library page, and inspect the network requests. Image and thumbnail requests should go directly to the CDN domain, not to `/api/`. The API response for the image list should include direct CDN URLs for each image and thumbnail.
**Acceptance Scenarios**:
1. **Given** a published image, **When** the visitor loads the image library, **Then** each thumbnail `src` URL points to the CDN domain and loads without passing through the API
2. **Given** a published image, **When** the visitor opens the detail page, **Then** the full image `src` URL points to the CDN domain
3. **Given** the API returns image metadata, **When** the response is inspected, **Then** it includes a `file_url` and `thumbnail_url` field containing full CDN URLs
---
### User Story 2 - Local Development Works Without CDN (Priority: P2)
In local development, where no CDN is configured, images continue to load via the existing API proxy endpoints. No additional setup is required to run the application locally.
**Why this priority**: Developer experience must not regress. The proxy endpoints must remain functional and be used automatically when no CDN is configured.
**Independent Test**: Run the application locally without setting a public base URL. Upload an image. Verify the library and detail pages load images correctly via the API proxy endpoints, with no errors or broken images.
**Acceptance Scenarios**:
1. **Given** no CDN base URL is configured, **When** the API returns image metadata, **Then** `file_url` and `thumbnail_url` point to the API proxy paths (e.g. `/api/v1/images/{id}/file`)
2. **Given** no CDN base URL is configured, **When** a visitor views the library, **Then** thumbnails load via the API proxy with no broken images
3. **Given** a CDN base URL is configured, **When** the application starts, **Then** all image URLs use the CDN domain instead of the proxy paths
---
### Edge Cases
- What happens when the CDN base URL is set but the object does not exist in CDN storage? The browser receives a 404 from the CDN — the API does not re-proxy the content.
- What happens if an image has no thumbnail (thumbnail generation failed)? The `thumbnail_url` field is absent or null; the UI falls back to the full image URL as it does today.
- What happens if the CDN base URL has a trailing slash? The system normalises the URL to avoid double slashes in constructed paths.
---
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The API MUST include a `file_url` field in all image metadata responses, containing the full URL from which the image file can be fetched
- **FR-002**: The API MUST include a `thumbnail_url` field in all image metadata responses when a thumbnail exists, containing the full URL from which the thumbnail can be fetched
- **FR-003**: When a CDN base URL is configured, `file_url` and `thumbnail_url` MUST point to the CDN domain
- **FR-004**: When no CDN base URL is configured, `file_url` and `thumbnail_url` MUST point to the existing API proxy endpoints so local development continues to work without additional setup
- **FR-005**: The existing API proxy endpoints (`/images/{id}/file`, `/images/{id}/thumbnail`) MUST remain functional regardless of whether a CDN base URL is configured
- **FR-006**: The UI MUST use `file_url` and `thumbnail_url` from the API response to render images, rather than constructing proxy URLs client-side
- **FR-007**: The CDN base URL MUST be configurable via environment variable; no value is required in local development
- **FR-008**: A trailing slash in the configured CDN base URL MUST NOT result in double slashes in constructed image URLs
- **FR-009**: When `thumbnail_url` is null, the UI MUST fall back to `file_url` for thumbnail display rather than rendering a broken image
### Key Entities
- **Image metadata response**: Extended to include `file_url` and `thumbnail_url` fields alongside existing fields (`id`, `filename`, `tags`, `width`, `height`, `mime_type`, etc.)
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In production, zero image or thumbnail requests pass through the API server — all are served directly by the CDN
- **SC-002**: Local development requires no additional configuration beyond what is already required — `docker compose up` continues to work with images loading correctly
- **SC-003**: All existing image-related API integration tests continue to pass after the change
- **SC-004**: Image metadata responses include `file_url` and `thumbnail_url` fields for 100% of images that have been successfully stored
---
## Assumptions
- The CDN storage bucket and public domain are already configured and operational before this feature is deployed — this feature only changes how URLs are constructed and served, not how objects are stored
- Object keys in CDN storage are identical to those used in the existing storage backend — no key remapping is needed
- The CDN serves objects publicly without authentication — no signed URL generation is required
- The existing API proxy endpoints are retained as functional fallbacks; the UI stops calling them in production but they are not removed
- Local development uses the existing MinIO-backed proxy and does not require a locally running CDN

View File

@@ -0,0 +1,116 @@
# Tasks: CDN Image Serving
**Input**: Design documents from `specs/014-r2-cdn-serving/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/image-response.md ✅, quickstart.md ✅
**Tests**: Unit tests for URL construction logic; integration tests asserting `file_url` and `thumbnail_url` in all image responses. Tests accompany each implementation task per §5.1.
**Organization**: Phase 1 adds the config value (foundational — blocks everything). Phase 2 implements US1 (CDN URL serving in API + UI consumption). Phase 3 verifies US2 (local dev fallback). Polish runs the full suite and manual end-to-end check.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to
---
## Phase 1: Foundational (Config)
**Goal**: Add `s3_public_base_url` to config and `.env.example`. All US1 and US2 tasks depend on this.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [X] T001 Add `s3_public_base_url: str | None = None` to the `Settings` class in `api/app/config.py` (after `api_base_url`); add `S3_PUBLIC_BASE_URL=` with comment "# CDN base URL for serving images (e.g. https://cdn.example.com). Leave empty in local dev to use API proxy fallback." to `.env.example` after the `API_BASE_URL` line
**Checkpoint**: Config in place — user story work can begin.
---
## Phase 2: User Story 1 — Images Load Directly from CDN (Priority: P1) 🎯 MVP
**Goal**: API returns `file_url` and `thumbnail_url` in all image responses; UI uses those fields to render images rather than constructing proxy URLs client-side.
**Independent Test**: With `S3_PUBLIC_BASE_URL=https://cdn.reactbin.juggalol.com` set, call `GET /api/v1/images` and confirm each item has `file_url` starting with `https://cdn.reactbin.juggalol.com/` and `thumbnail_url` starting with `https://cdn.reactbin.juggalol.com/` (or null). Open the library page in a browser and confirm image requests go to the CDN domain in the network panel.
- [X] T002 [US1] Write unit tests in `api/tests/unit/test_url_construction.py` covering four cases: (1) CDN base set, image has thumbnail — `file_url` and `thumbnail_url` are CDN URLs; (2) CDN base set, image has no thumbnail — `thumbnail_url` is None; (3) CDN base not set, image has thumbnail — `file_url` is `/api/v1/images/{id}/file` and `thumbnail_url` is `/api/v1/images/{id}/thumbnail`; (4) CDN base not set, no thumbnail — `thumbnail_url` is None. Test the trailing-slash normalisation case (CDN base with trailing slash produces no double-slash). Import and call `_image_to_dict` directly with a mock `Image` object.
- [X] T003 [US1] Update `_image_to_dict` in `api/app/routers/images.py`: add `cdn_base: str | None = None` keyword parameter; compute `_base = cdn_base.rstrip("/") if cdn_base else None`; set `file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/images/{image.id}/file"`; set `thumbnail_url = (f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/images/{image.id}/thumbnail") if image.thumbnail_key else None`; add `"file_url": file_url` and `"thumbnail_url": thumbnail_url` to the returned dict. Run `make test-unit` and confirm T002 tests pass.
- [X] T004 [US1] Update every `_image_to_dict(...)` call site in `api/app/routers/images.py`: at the top of each endpoint function that calls `_image_to_dict`, add `_cdn_base = get_settings().s3_public_base_url` (import `get_settings` is already present); pass `cdn_base=_cdn_base` to every `_image_to_dict` call in that endpoint. Affected endpoints: `upload_image`, `list_images`, `get_image`, `patch_image_tags`. Confirm `get_settings()` is called once per endpoint, not once per image in a loop (for `list_images`, call it before the list comprehension).
- [X] T005 [US1] Update integration tests: in `api/tests/integration/test_upload.py`, add assertions after existing response checks that `"file_url"` is present in the response body and starts with `/api/v1/images/` (since no CDN is configured in test env); add the same assertion for `"thumbnail_url"` in `test_upload_returns_thumbnail_key`; add assertion that `thumbnail_url` is None in the test that expects `thumbnail_key` to be None. Run `make test-integration` and confirm all pass.
- [X] T006 [P] [US1] Update `ui/src/app/services/image.service.ts`: add `file_url: string` and `thumbnail_url: string | null` to the `ImageRecord` interface; remove the `getFileUrl(id: string): string` method; remove the `getThumbnailUrl(id: string): string` method.
- [X] T007 [P] [US1] Update `ui/src/app/library/library.component.ts`: replace `[src]="imageService.getThumbnailUrl(img.id)"` (line 77) with `[src]="img.thumbnail_url ?? img.file_url"` — fall back to `file_url` when thumbnail is absent (FR-009); update `ui/src/app/library/library.component.spec.ts` to add `file_url` and `thumbnail_url` to any mock `ImageRecord` objects and remove any references to `getThumbnailUrl()`.
- [X] T008 [P] [US1] Update `ui/src/app/detail/detail.component.ts`: replace `[src]="imageService.getFileUrl(image.id)"` (line 52) with `[src]="image.file_url"`; update `ui/src/app/detail/detail.component.spec.ts` to add `file_url` and `thumbnail_url` to any mock `ImageRecord` objects and remove any references to `getFileUrl()`.
- [X] T009 [US1] Update `ui/src/app/services/image.service.spec.ts`: add `file_url` and `thumbnail_url` fields to any mock `ImageRecord` objects used in tests; remove any test cases that test `getFileUrl()` or `getThumbnailUrl()` (these methods no longer exist). Run UI tests and confirm they pass.
**Checkpoint**: US1 complete. API returns CDN URLs when configured; UI uses response fields to render images.
---
## Phase 3: User Story 2 — Local Development Works Without CDN (Priority: P2)
**Goal**: Confirm that with no `S3_PUBLIC_BASE_URL` configured, `file_url` and `thumbnail_url` fall back to API proxy paths and images load correctly in local dev.
**Independent Test**: Run `make test-unit && make test-integration` with no `S3_PUBLIC_BASE_URL` set (the default). Confirm all tests pass and that `file_url` values in integration test responses begin with `/api/v1/images/`.
- [X] T010 [US2] Verify US2: run `make test-unit` and confirm the url-construction unit tests for the "no CDN base" case (T002 cases 3 and 4) pass; run `make test-integration` and confirm the updated upload tests (T005) pass — they already assert relative proxy paths since the test environment has no `S3_PUBLIC_BASE_URL`. Confirm `docker compose up` starts cleanly and images load in the browser via the proxy paths with no console errors.
**Checkpoint**: US2 verified. Local development requires no additional configuration.
---
## Phase 4: Polish & Cross-Cutting Concerns
- [X] T011 [P] Run `ruff check api/app/routers/images.py api/app/config.py` and fix any lint issues; run `ruff format --check` and format if needed.
- [X] T012 Run end-to-end verification per `specs/014-r2-cdn-serving/quickstart.md`: in production with `S3_PUBLIC_BASE_URL` set, call `GET /api/v1/images` and confirm `file_url` and `thumbnail_url` begin with `https://cdn.reactbin.juggalol.com/`; open the library page in a browser and confirm image requests in the network panel go to `cdn.reactbin.juggalol.com`, not `/api/`.
---
## Dependencies & Execution Order
- T001 must complete before any other task
- T002 before T003 (tests before implementation — unit test first)
- T003 before T004 (update helper before call sites)
- T004 before T005 (implementation before integration tests)
- T006, T007, T008 can run in parallel after T001 (different files)
- T009 after T006 (spec depends on updated interface)
- T010 after T003T009 (verification requires full implementation)
- T011 after T003T004 (lint the changed files)
- T012 last (manual end-to-end)
### Execution Order Summary
```
Step 1: T001 (foundational: config)
Step 2: T002 (US1: unit tests first)
Step 3: T003 (US1: implement _image_to_dict)
Step 4: T004 ∥ T006 ∥ T007 ∥ T008 (US1: call sites + UI in parallel)
Step 5: T005 ∥ T009 (US1: integration tests + service spec)
Step 6: T010 (US2: verify local dev fallback)
Step 7: T011 (polish: lint)
Step 8: T012 (polish: manual end-to-end)
```
---
## Implementation Strategy
### MVP (US1 only — CDN URLs in API + UI)
1. T001 — config
2. T002T005 — API implementation and tests
3. T006T009 — UI updates
4. **STOP and VALIDATE**: `make test-unit && make test-integration`, check browser network panel
### Incremental Delivery
1. T001T005 (API only) → deploy → verify CDN URLs appear in API responses
2. T006T009 (UI) → deploy → verify browser fetches images from CDN
3. T010 (local dev verification) → confirm fallback intact
4. T011T012 (polish + end-to-end) → ship

View File

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Library Pagination UI
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-09
**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. Ready for `/speckit-plan`.

View File

@@ -0,0 +1,52 @@
# Contract: Image List Pagination Query
No new API endpoints are introduced. This document records the existing API contract the UI relies on for pagination.
## Endpoint
```
GET /api/v1/images?limit={limit}&offset={offset}&tags={tags}
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|--------------------------------------------------|
| `limit` | integer | No | Images per page. UI sends `24`. Max is 100. |
| `offset` | integer | No | Number of images to skip. UI computes `(page-1) * 24`. |
| `tags` | string | No | Comma-separated tag names for AND-filter. |
## Response
```json
{
"items": [ /* ImageRecord[] */ ],
"total": 143,
"limit": 24,
"offset": 48
}
```
| Field | Type | Description |
|----------|---------|--------------------------------------------------|
| `total` | integer | Total images matching the filter (all pages). |
| `limit` | integer | Page size echoed back. |
| `offset` | integer | Offset echoed back. |
| `items` | array | Images for this page only. |
## UI-Computed Values
```
totalPages = Math.ceil(total / limit) // e.g. ceil(143 / 24) = 6
currentPage = offset / limit + 1 // e.g. 48 / 24 + 1 = 3
offset = (page - 1) * limit // e.g. (3 - 1) * 24 = 48
```
## URL State
| Query Param | Source | Example |
|-------------|---------------------|------------------|
| `page` | current page number | `?page=3` |
| `tags` | active tag filters | `?tags=cat,funny` |
Both params coexist: `/?page=3&tags=cat,funny`

View File

@@ -0,0 +1,82 @@
# Implementation Plan: Library Pagination UI
**Branch**: `015-library-pagination` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/015-library-pagination/spec.md`
## Summary
Replace the current "Load more" append-on-scroll pattern in the library with discrete page navigation (Previous/Next buttons, page indicator, total count). Page state is persisted to the URL query string for bookmarkability. No API or backend changes required — the API already supports `limit` and `offset` parameters.
## Technical Context
**Language/Version**: TypeScript (strict), Angular latest stable
**Primary Dependencies**: Angular Router (query params for URL state), Angular HttpClient (existing)
**Storage**: N/A — UI-only change
**Testing**: Angular TestBed / Jasmine (existing test suite)
**Target Platform**: Browser SPA
**Project Type**: UI feature within existing Angular standalone component
**Performance Goals**: Page load of 24 images replaces 50-image Load More; no regression
**Constraints**: Must preserve existing tag filter query param (`?tags=`) when updating page param; must not break existing spec tests
**Scale/Scope**: Single component change (`library.component.ts`) + its spec file
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation | ✅ PASS | UI communicates with API only via HTTP; no storage or DB knowledge in component |
| §2.6 No speculative abstraction | ✅ PASS | No new abstractions introduced; pagination is a concrete change to one component |
| §3.2 OpenAPI as contract | ✅ PASS | Uses existing `GET /api/v1/images?limit=&offset=` contract; no new endpoints |
| §3.4 Pagination | ✅ PASS | This feature is the UI surface for the API pagination already in place |
| §5.1 Tests alongside implementation | ✅ REQUIRED | Component spec must be updated alongside each changed behaviour |
| §5.4 Test gate | ✅ REQUIRED | UI tests must pass; `make test-unit` passes before task marked done |
| §6 Tech stack | ✅ PASS | Angular + TypeScript strict — no new dependencies needed |
| §7.3 Linting | ✅ REQUIRED | ESLint + Prettier enforced; no lint regressions |
| §8 Scope boundaries | ✅ PASS | Pagination is explicitly required (§3.4); no out-of-scope additions |
**Post-Phase-1 re-check**: No contracts or data model introduced; no new violations.
## Project Structure
### Documentation (this feature)
```text
specs/015-library-pagination/
├── plan.md ← this file
├── research.md ← Phase 0 output
├── quickstart.md ← Phase 1 output
├── contracts/
│ └── pagination-query.md ← Phase 1 output
└── tasks.md ← Phase 2 output (/speckit-tasks)
```
### Source Code (changes only)
```text
ui/src/app/library/
├── library.component.ts ← primary change
└── library.component.spec.ts ← tests updated alongside
```
No other files change. No new files added to source tree.
## Key Design Decisions
### Page size: 24
Fixed at 24 images per page (spec FR-011). Fits common grid widths (2/3/4/6 columns), is a meaningful reduction from the current silent 50-image cap, and divides cleanly. Not user-configurable.
### Replace, don't append
Current `loadMore()` appends items to the array. The new `goToPage(n)` replaces `this.images` entirely. The `offset` field becomes derived from page: `offset = (page - 1) * limit`.
### URL state via Angular Router query params
- `?page=2` added alongside existing `?tags=cat,funny`
- Use `queryParamsHandling: 'merge'` when updating page to preserve tag params
- Use `queryParamsHandling: 'merge'` when updating tags to preserve page reset (page always resets to 1 on filter change, so page param is removed or set to 1)
- On `ngOnInit`, read `page` from `snapshot.queryParamMap`; clamp to valid range
### Out-of-page-range handling
If URL `?page=99` is requested but only 3 pages exist: silently load page 1. No error state.
### Pagination controls visibility
Only shown when `totalPages > 1`. Total pages = `Math.ceil(total / limit)`.

View File

@@ -0,0 +1,41 @@
# Quickstart: Library Pagination UI
## Happy Path — Navigating Pages
**Setup**: Library contains more than 24 images.
1. Open `http://localhost:4200/`
2. Confirm the image grid shows 24 images (not 50).
3. Confirm "Page 1 of N" indicator and total count are visible above or below the grid.
4. Confirm "Previous" button is absent or disabled.
5. Click "Next" → grid replaces with the next 24 images; indicator updates to "Page 2 of N".
6. Click "Previous" → first 24 images return; indicator shows "Page 1 of N".
7. Navigate to the last page → "Next" is absent or disabled.
## Happy Path — URL State
1. Navigate to page 3 via "Next" button twice.
2. Copy URL from address bar (should contain `?page=3`).
3. Open URL in a new tab → page 3 loads directly.
4. Press browser Back → page 2 loads.
## Happy Path — Tag Filter Resets Page
1. Navigate to page 2.
2. Add a tag filter.
3. Confirm page resets to 1; URL shows `?page=1&tags=<tag>` (or just `?tags=<tag>`).
## Edge Case — Single Page
1. Filter to a tag with fewer than 25 images.
2. Confirm no pagination controls are rendered.
## Edge Case — Out-of-Range URL
1. Manually enter `/?page=9999` in the address bar.
2. Confirm page 1 loads with no error message.
## Edge Case — Empty Library
1. With no images uploaded, open `/`.
2. Confirm the existing empty state is shown; no pagination controls visible.

View File

@@ -0,0 +1,33 @@
# Research: Library Pagination UI
## Decision: Angular Router query params for URL state
**Decision**: Use `this.router.navigate([], { queryParams: { page: n }, queryParamsHandling: 'merge' })` for page navigation and `snapshot.queryParamMap.get('page')` on init.
**Rationale**: The library component already uses Angular Router for `?tags=` query params (added in feature 007). Extending the same pattern to `?page=` is the natural fit and keeps a single source of truth in the URL. The `queryParamsHandling: 'merge'` flag ensures that navigating to a new page does not erase the active `?tags=` filter, and vice versa.
**Alternatives considered**:
- Component-local state only (no URL): rejected — FR-008 requires bookmarkable URLs
- `queryParamsHandling: ''` (replace): rejected — would erase `?tags=` param when changing pages
---
## Decision: Replace `loadMore()` with `goToPage(page: number)`
**Decision**: Remove `loadMore()`, `hasMore`, and the append pattern. Replace with `goToPage(n)` that sets `this.images = []` and loads from `offset = (page - 1) * limit`.
**Rationale**: The spec requires discrete pages (FR-001, FR-006). Keeping `loadMore()` alongside pagination would create conflicting UX. Clean removal is simpler and avoids two code paths.
**Alternatives considered**:
- Keep `loadMore()` as a fallback: rejected — two navigation patterns in one view is confusing
---
## Decision: No new dependencies
**Decision**: Implement using existing Angular Router, HttpClient, and CDR. No pagination library imported.
**Rationale**: The pagination logic is trivial (previous/next buttons, a counter, clamped page index). Pulling in a library for two buttons and a text label adds bundle weight and a dependency for no meaningful gain.
**Alternatives considered**:
- `ngx-pagination`: rejected — overkill for two-button prev/next pattern

View File

@@ -0,0 +1,84 @@
# Feature Specification: Library Pagination UI
**Feature Branch**: `015-library-pagination`
**Created**: 2026-05-09
**Status**: Draft
**Input**: User description: "Pagination UI for the image library"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Navigate Pages of Images (Priority: P1)
A user with a large image library currently sees at most 50 images and a "Load more" button that appends more images below. There is no way to jump to a specific point in the library or know how many images exist in total. This story replaces the append-on-load pattern with page-by-page navigation: Previous/Next buttons and a "Page N of M" indicator so the user always knows where they are.
**Why this priority**: The core usability gap — a library of any meaningful size is effectively unnavigable today. Without this, the feature has no value.
**Independent Test**: Load the library page. Confirm a page indicator ("Page 1 of N") is visible and the total image count is shown. Click "Next" — confirm the next set of images loads and the indicator updates. Click "Previous" — confirm the first set returns. On the first page, "Previous" is absent or disabled. On the last page, "Next" is absent or disabled. Changing a tag filter resets to page 1.
**Acceptance Scenarios**:
1. **Given** the library has more images than fit on one page, **When** the page loads, **Then** only the first page of images is shown with a "Next" button and "Page 1 of N" indicator visible.
2. **Given** the user is on page 1, **When** they click "Next", **Then** the next page of images replaces the current grid (not appended) and the indicator updates to "Page 2 of N".
3. **Given** the user is on the last page, **When** they view the page, **Then** the "Next" button is absent or disabled and "Previous" is present.
4. **Given** the user is on page 1, **When** they view the page, **Then** the "Previous" button is absent or disabled.
5. **Given** the library has fewer images than one page, **When** the page loads, **Then** no pagination controls are shown.
6. **Given** active tag filters are applied, **When** the user changes the filter, **Then** the page resets to 1 and the indicator updates.
---
### User Story 2 - Page State Reflected in URL (Priority: P2)
The current library URL is always `/`. After implementing page navigation, a user who shares or bookmarks a URL should land on the same page they were viewing, not always page 1.
**Why this priority**: Useful for bookmarking and sharing a specific point in the library, but the library is fully functional without it.
**Independent Test**: Navigate to page 3 of the library. Copy the URL from the browser address bar. Open it in a new tab. Confirm page 3 loads directly. Confirm the Back button in the browser returns to page 2.
**Acceptance Scenarios**:
1. **Given** the user navigates to page 3, **When** the page URL is copied and opened in a new tab, **Then** page 3 loads directly without navigating through prior pages.
2. **Given** the user navigates Next through several pages, **When** they press the browser Back button, **Then** the previous page is restored.
3. **Given** the URL includes a page number beyond the total pages available, **When** the page loads, **Then** page 1 is shown rather than an error.
---
### Edge Cases
- What happens when the total drops below the current page (e.g., images deleted in another session)? → Display page 1.
- What happens when the library is empty? → No pagination controls shown; existing empty state displayed.
- What happens when only one page of results exists? → No pagination controls shown.
- What happens when a filter change results in fewer pages than the current page? → Reset to page 1.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The library MUST display images in discrete pages rather than an appending list.
- **FR-002**: The library MUST show a page indicator displaying the current page number and total page count (e.g., "Page 2 of 7").
- **FR-003**: The library MUST show the total number of images matching the current filters (e.g., "143 images").
- **FR-004**: A "Next" control MUST be available on all pages except the last; a "Previous" control MUST be available on all pages except the first.
- **FR-005**: Pagination controls MUST NOT be shown when all images fit on a single page.
- **FR-006**: Navigating to a new page MUST replace the displayed images, not append to them.
- **FR-007**: Changing a tag filter MUST reset the current page to 1.
- **FR-008**: The current page number MUST be reflected in the URL query string so that the URL is bookmarkable and shareable.
- **FR-009**: Loading a URL with a page parameter MUST display the correct page directly.
- **FR-010**: A page parameter beyond the available range MUST silently fall back to page 1.
- **FR-011**: The page size (number of images per page) MUST be 24 images.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A user can navigate from page 1 to any other page using only Previous/Next controls within 2 clicks per page.
- **SC-002**: The total image count and current position are visible without scrolling on page load.
- **SC-003**: A bookmarked or shared page URL loads the correct page 100% of the time (within the valid range).
- **SC-004**: Changing a tag filter always resets to page 1 with no stale images from the previous page visible.
- **SC-005**: Pages with fewer images than the page size (the last page) display correctly without layout breakage.
## Assumptions
- Page size is fixed at 24 images; no user-configurable page size is required.
- The API already supports `limit` and `offset` parameters; no backend changes are needed.
- The existing "Load more" / infinite-scroll pattern is fully replaced by page navigation.
- Browser history integration (Back/Forward) is satisfied by URL query parameter updates.
- Mobile responsiveness of pagination controls is required to match the existing library layout.

View File

@@ -0,0 +1,82 @@
# Tasks: Library Pagination UI
**Input**: Design documents from `specs/015-library-pagination/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/pagination-query.md ✅, quickstart.md ✅
**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/library/library.component.ts` and its spec file.
**Organization**: No setup or foundational phase needed — the Angular project and library component already exist. Phase 1 implements US1 (page navigation controls). Phase 2 adds US2 (URL state). Polish runs lint and manual verification.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to
---
## Phase 1: User Story 1 — Previous/Next Page Navigation (Priority: P1) 🎯 MVP
**Goal**: Replace the "Load more" append pattern with discrete Previous/Next page navigation, a "Page N of M" indicator, and a total image count. Page size changes from 50 to 24.
**Independent Test**: With at least 25 images in the library, open `/`. Confirm 24 images are shown, a "Page 1 of N" indicator is visible, "Previous" is absent, and "Next" is present. Click "Next" — confirm the grid is replaced (not appended) with the next 24 images and the indicator updates. Click "Previous" — confirm the first page returns. Apply a tag filter — confirm the page resets to 1.
- [X] T001 [US1] Write tests in `ui/src/app/library/library.component.spec.ts` covering: (1) page indicator text "Page 1 of N" renders when totalPages > 1; (2) total count text renders (e.g. "143 images"); (3) "Next" button present when not on last page; (4) "Previous" button absent on first page; (5) "Previous" present and "Next" absent on last page; (6) no pagination controls rendered when all images fit on one page (total ≤ 24); (7) clicking "Next" calls `imageService.list` with offset=24; (8) clicking "Previous" from page 2 calls `imageService.list` with offset=0; (9) applying a filter resets to page 1 (offset=0). Run `ng test` and confirm the new tests FAIL (implementation pending).
- [X] T002 [US1] Update `ui/src/app/library/library.component.ts`: (a) change `private readonly limit = 50` to `private readonly limit = 24`; (b) remove `hasMore` property and `loadMore()` method; (c) add properties `currentPage = 1`, `totalPages = 1`, `total = 0`; (d) rename/replace `load()` to call `imageService.list(this.activeFilters, this.limit, (this.currentPage - 1) * this.limit)` and on success set `this.images = res.items` (replace, not append), `this.total = res.total`, `this.totalPages = Math.ceil(res.total / this.limit)`, clamp `currentPage` to `Math.max(1, Math.min(this.currentPage, this.totalPages))`; (e) add `nextPage()` that increments `currentPage` and calls `load()`; (f) add `prevPage()` that decrements `currentPage` and calls `load()`; (g) in `applyFilter()`, reset `this.currentPage = 1` before calling `load()`; (h) replace the `<button class="load-more">` element in the template with a pagination bar: a "Previous" button bound to `(click)="prevPage()"` disabled/hidden when `currentPage === 1`, a "Page {{ currentPage }} of {{ totalPages }}" span, a "Next" button bound to `(click)="nextPage()"` disabled/hidden when `currentPage === totalPages`, and place a total count element showing "{{ total }} images" **outside** the pagination bar and outside the `*ngIf="totalPages > 1"` guard so it always renders when images exist (FR-003, SC-002); wrap only the Previous button, page indicator span, and Next button inside `*ngIf="totalPages > 1"`. Run `ng test` and confirm T001 tests pass.
**Checkpoint**: US1 complete. Library shows paginated results with Previous/Next controls and page indicator.
---
## Phase 2: User Story 2 — Page State in URL (Priority: P2)
**Goal**: Persist the current page number in the URL query string (`?page=N`) so that the URL is bookmarkable and the browser Back button works.
**Independent Test**: Navigate to page 3. Copy the URL (should contain `?page=3`). Open in a new tab — confirm page 3 loads directly. Press browser Back — confirm page 2 is shown. Navigate to `/?page=9999` — confirm page 1 loads without error.
- [X] T003 [US2] Add tests to `ui/src/app/library/library.component.spec.ts` covering: (1) on init with `?page=2` in queryParamMap, `currentPage` is set to 2 and `list` is called with `offset=24`; (2) on init with `?page=9999` and total of 48 images, `currentPage` is clamped to page 1; (3) `nextPage()` calls `router.navigate` with `queryParams: { page: 2 }` and `queryParamsHandling: 'merge'`; (4) `applyFilter()` calls `router.navigate` with `queryParams: { page: 1 }` and `queryParamsHandling: 'merge'`. Run `ng test` and confirm new tests FAIL.
- [X] T004 [US2] Update `ui/src/app/library/library.component.ts`: (a) in `ngOnInit`, after reading the `tags` param, read `const pageParam = this.route.snapshot.queryParamMap.get('page')` and set `this.currentPage = pageParam ? Math.max(1, parseInt(pageParam, 10)) : 1` (out-of-range clamping happens after load when totalPages is known); (b) update `nextPage()` and `prevPage()` to call `this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' })` after updating `currentPage`; (c) update `applyFilter()` to call `this.router.navigate([], { queryParams: { page: 1, tags: tags.join(',') || null }, queryParamsHandling: 'merge' })` when resetting to page 1 (pass `null` for tags to remove param when empty); (d) after load resolves and `totalPages` is known, clamp `currentPage` to `Math.min(this.currentPage, Math.max(1, this.totalPages))` and if clamped, call navigate to correct the URL. Run `ng test` and confirm T003 tests pass.
**Checkpoint**: US2 complete. Page state persists in URL; Back button and direct links work.
---
## Phase 3: Polish & Cross-Cutting Concerns
- [X] T005 Run `ng lint` on `ui/src/app/library/library.component.ts` and fix any issues; confirm `ng test` passes with all existing and new tests green; manually verify all quickstart.md scenarios in a browser (pagination controls, URL state, tag filter reset, single-page no-controls, out-of-range URL, empty state).
---
## Dependencies & Execution Order
- T001 before T002 (write failing tests before implementation)
- T002 before T003 (US2 tests build on US1 implementation)
- T003 before T004 (write failing tests before implementation)
- T004 before T005 (polish after full implementation)
### Execution Order Summary
```
Step 1: T001 (US1: failing tests)
Step 2: T002 (US1: implementation — tests turn green)
Step 3: T003 (US2: failing tests)
Step 4: T004 (US2: implementation — tests turn green)
Step 5: T005 (polish: lint + manual verification)
```
---
## Implementation Strategy
### MVP (US1 only)
1. T001T002 — page navigation controls, limit change, replace append
2. **STOP and VALIDATE**: open browser, confirm pagination controls appear and work
3. Deploy if ready
### Full Delivery
1. T001T002 (US1) → validate
2. T003T004 (US2) → validate URL state
3. T005 (polish) → ship

View File

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Copy URL & Toast Notifications
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-09
**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. Ready to proceed to `/speckit-plan`.

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