Compare commits
39 Commits
010-api-pr
...
017-short-
| Author | SHA1 | Date | |
|---|---|---|---|
| c210978261 | |||
| a61c67614f | |||
| 27425889b3 | |||
| 61d923d5be | |||
| 87eb2703f5 | |||
| bc0f5173c0 | |||
| 309cfce71c | |||
| b094389131 | |||
| 7d49c12ce2 | |||
| 443887ea93 | |||
| e4bfe13072 | |||
| 0a76bb03b5 | |||
| 8cbf1e527a | |||
| a280d8c761 | |||
| 781be909bc | |||
| e5e1acb533 | |||
| c9bfdaf241 | |||
| 75a1449354 | |||
| 68881b30f1 | |||
| 9021f4816a | |||
| 35d21dafa4 | |||
| 34d8c3848b | |||
| aaacfae653 | |||
| 728efeaa48 | |||
| c858e47daa | |||
| 9db20fdf90 | |||
| 9b66fe1918 | |||
| e9a2e9f014 | |||
| 7b3d4a9257 | |||
| 7c57629941 | |||
| 4fe8b19d19 | |||
| e34c9f7b7f | |||
| 551ddbec3b | |||
| 666c32cd69 | |||
| bf27c97deb | |||
| ce279e6121 | |||
| b14508e4cf | |||
| 602648ef56 | |||
| 1b3468b72d |
@@ -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
|
||||
|
||||
@@ -27,3 +31,7 @@ LOGIN_COOLDOWN_SECONDS=900
|
||||
# Comma-separated IPs/CIDRs of trusted upstream proxies (e.g. nginx ingress pod CIDR).
|
||||
# Leave empty when not behind a reverse proxy.
|
||||
LOGIN_TRUSTED_PROXY_IPS=
|
||||
|
||||
# API documentation endpoints (Swagger UI, ReDoc, OpenAPI schema)
|
||||
# Set to false in production to avoid exposing the API surface publicly.
|
||||
API_DOCS_ENABLED=true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ venv/
|
||||
dist/
|
||||
build/
|
||||
!api/tests/build/
|
||||
!ui/tests/build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -1,3 +1 @@
|
||||
{
|
||||
"feature_directory": "specs/010-api-prod-dockerfile"
|
||||
}
|
||||
{"feature_directory":"specs/017-short-id-migration"}
|
||||
|
||||
@@ -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
|
||||
|
||||
4
.yamllint.yml
Normal file
4
.yamllint.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
extends: relaxed
|
||||
rules:
|
||||
line-length:
|
||||
max: 120
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/010-api-prod-dockerfile/plan.md`.
|
||||
`specs/017-short-id-migration/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
14
Makefile
14
Makefile
@@ -1,9 +1,10 @@
|
||||
.PHONY: test-unit test-integration build-prod verify-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
|
||||
|
||||
test-integration:
|
||||
docker compose -f docker-compose.test.yml build api-test
|
||||
docker compose -f docker-compose.test.yml run --rm api-test
|
||||
|
||||
build-prod:
|
||||
@@ -11,3 +12,14 @@ build-prod:
|
||||
|
||||
verify-prod:
|
||||
bash api/tests/build/verify_production_image.sh
|
||||
|
||||
build-ui-prod:
|
||||
docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
|
||||
verify-ui-prod:
|
||||
bash ui/tests/build/verify_production_image.sh
|
||||
|
||||
# Offline: yamllint only. Online (requires kubeconfig): kubectl apply --dry-run=client -f k8s/
|
||||
validate-k8s:
|
||||
yamllint -d relaxed k8s/
|
||||
kubectl apply --dry-run=client -f k8s/
|
||||
|
||||
138
README.md
138
README.md
@@ -2,3 +2,141 @@
|
||||
_Organize your reaction images._
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
|
||||
@@ -12,6 +12,3 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tests/
|
||||
alembic/
|
||||
alembic.ini
|
||||
|
||||
@@ -35,6 +35,9 @@ RUN groupadd --system --gid 1001 appgroup \
|
||||
|
||||
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||
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
|
||||
|
||||
|
||||
24
api/alembic/versions/003_add_short_id.py
Normal file
24
api/alembic/versions/003_add_short_id.py
Normal 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")
|
||||
24
api/alembic/versions/004_short_id_not_null.py
Normal file
24
api/alembic/versions/004_short_id_not_null.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -13,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
|
||||
@@ -22,6 +24,19 @@ class Settings(BaseSettings):
|
||||
login_window_seconds: int = 300
|
||||
login_cooldown_seconds: int = 900
|
||||
login_trusted_proxy_ips: str = ""
|
||||
api_docs_enabled: bool = True
|
||||
|
||||
@field_validator("api_docs_enabled", mode="before")
|
||||
@classmethod
|
||||
def coerce_docs_enabled(cls, v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
try:
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
return TypeAdapter(bool).validate_python(v)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -33,7 +33,16 @@ async def lifespan(application: FastAPI):
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
|
||||
_settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Reactbin API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if _settings.api_docs_enabled else None,
|
||||
redoc_url="/redoc" if _settings.api_docs_enabled else None,
|
||||
openapi_url="/openapi.json" if _settings.api_docs_enabled else None,
|
||||
)
|
||||
|
||||
# Defaults so app.state is populated even when lifespan doesn't run (e.g. tests)
|
||||
app.state.login_rate_limiter = LoginRateLimiter()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
0
api/scripts/__init__.py
Normal file
0
api/scripts/__init__.py
Normal file
107
api/scripts/migrate_to_short_ids.py
Normal file
107
api/scripts/migrate_to_short_ids.py
Normal 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())
|
||||
@@ -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"
|
||||
|
||||
48
api/tests/integration/test_docs_gate.py
Normal file
48
api/tests/integration/test_docs_gate.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import importlib
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_BASE_ENV = {
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"JWT_SECRET_KEY": "test-secret",
|
||||
"OWNER_USERNAME": "admin",
|
||||
"OWNER_PASSWORD": "password",
|
||||
"S3_ENDPOINT_URL": "http://localhost:9000",
|
||||
"S3_BUCKET_NAME": "test-bucket",
|
||||
"S3_ACCESS_KEY_ID": "key",
|
||||
"S3_SECRET_ACCESS_KEY": "secret",
|
||||
}
|
||||
|
||||
|
||||
def _set_env(monkeypatch, extra=None):
|
||||
for k, v in {**_BASE_ENV, **(extra or {})}.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
|
||||
|
||||
def test_docs_hidden_when_flag_disabled(monkeypatch):
|
||||
_set_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
|
||||
get_settings.cache_clear()
|
||||
import app.main as m
|
||||
|
||||
importlib.reload(m)
|
||||
client = TestClient(m.app, raise_server_exceptions=False)
|
||||
assert client.get("/docs").status_code == 404
|
||||
assert client.get("/redoc").status_code == 404
|
||||
assert client.get("/openapi.json").status_code == 404
|
||||
assert client.get("/api/v1/health").status_code == 200
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_docs_visible_when_flag_enabled(monkeypatch):
|
||||
_set_env(monkeypatch, {"API_DOCS_ENABLED": "true"})
|
||||
get_settings.cache_clear()
|
||||
import app.main as m
|
||||
|
||||
importlib.reload(m)
|
||||
client = TestClient(m.app, raise_server_exceptions=False)
|
||||
assert client.get("/docs").status_code == 200
|
||||
assert client.get("/redoc").status_code == 200
|
||||
assert client.get("/openapi.json").status_code == 200
|
||||
get_settings.cache_clear()
|
||||
@@ -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}"},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
T079 — GET /api/v1/images/{id} 404 → error envelope shape
|
||||
T013 — upload 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
|
||||
|
||||
@@ -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,7 +55,47 @@ def test_settings_jwt_expiry_override(monkeypatch):
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.jwt_expiry_seconds == 3600
|
||||
|
||||
|
||||
def test_api_docs_enabled_default(monkeypatch):
|
||||
_apply_env(monkeypatch)
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is True
|
||||
|
||||
|
||||
def test_api_docs_enabled_false(monkeypatch):
|
||||
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is False
|
||||
|
||||
|
||||
def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
|
||||
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "not-a-bool"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is True
|
||||
|
||||
@@ -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)
|
||||
|
||||
110
api/tests/unit/test_migration.py
Normal file
110
api/tests/unit/test_migration.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
59
api/tests/unit/test_short_id.py
Normal file
59
api/tests/unit/test_short_id.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Unit tests for thumbnail generation utility."""
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image as PILImage
|
||||
|
||||
72
api/tests/unit/test_url_construction.py
Normal file
72
api/tests/unit/test_url_construction.py
Normal 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
52
k8s/api/deployment.yaml
Normal 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.0
|
||||
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.0
|
||||
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
13
k8s/api/service.yaml
Normal 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
34
k8s/ingress.yaml
Normal 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
24
k8s/minio/init-job.yaml
Normal 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
16
k8s/minio/service.yaml
Normal 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
|
||||
59
k8s/minio/statefulset.yaml
Normal file
59
k8s/minio/statefulset.yaml
Normal 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
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: reactbin
|
||||
29
k8s/ui/deployment.yaml
Normal file
29
k8s/ui/deployment.yaml
Normal 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.0
|
||||
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
13
k8s/ui/service.yaml
Normal 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
18
k8s/vault/api-secret.yaml
Normal 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
|
||||
16
k8s/vault/minio-secret.yaml
Normal file
16
k8s/vault/minio-secret.yaml
Normal 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
22
k8s/vault/vault-auth.yaml
Normal 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
67
scripts/test_lockout.sh
Normal 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"
|
||||
34
specs/011-ui-prod-dockerfile/checklists/requirements.md
Normal file
34
specs/011-ui-prod-dockerfile/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Production-Grade UI Container Image
|
||||
|
||||
**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
|
||||
|
||||
- All items pass. Spec is ready for `/speckit-plan`.
|
||||
90
specs/011-ui-prod-dockerfile/contracts/container.md
Normal file
90
specs/011-ui-prod-dockerfile/contracts/container.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Container Interface Contract: UI Production Image
|
||||
|
||||
## Image Identity
|
||||
|
||||
| Property | Value |
|
||||
|-------------|------------------------------|
|
||||
| Image name | `reactbin-ui-prod` |
|
||||
| Runtime | nginx-unprivileged (Alpine) |
|
||||
| Listen port | `8080` |
|
||||
| Run user | non-root (UID ≠ 0) |
|
||||
|
||||
## Runtime Inputs
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The UI container is a static file server. It has **no required environment variables at runtime** — all configuration is compiled into the static assets at build time by the Angular build toolchain.
|
||||
|
||||
> Note: The API base URL is baked in at build time via Angular's environment configuration. A future iteration may introduce runtime environment injection via a served `config.json`, but this is out of scope for v1.
|
||||
|
||||
## Runtime Outputs
|
||||
|
||||
### HTTP Interface
|
||||
|
||||
| Route pattern | Behaviour |
|
||||
|--------------------|-------------------------------------------------------------------|
|
||||
| `/` | Returns `index.html` with HTTP 200 |
|
||||
| `/` (any SPA path) | Returns `index.html` with HTTP 200 (SPA fallback via `try_files`)|
|
||||
| `/main.*.js` | Returns fingerprinted JS bundle with long-lived cache headers |
|
||||
| `/styles.*.css` | Returns fingerprinted CSS with long-lived cache headers |
|
||||
| `/assets/*` | Returns static assets |
|
||||
| Any path not found | Returns `index.html` with HTTP 200 (Angular router handles 404) |
|
||||
|
||||
### Cache Headers
|
||||
|
||||
| Asset type | Cache-Control header |
|
||||
|-------------------------------------|-----------------------------------------------|
|
||||
| Fingerprinted bundles (`.js`, `.css`, fonts) | `public, max-age=31536000, immutable` |
|
||||
| `index.html` | `no-store, no-cache, must-revalidate` |
|
||||
|
||||
### Process Exit
|
||||
|
||||
| Signal | Expected exit code | Maximum wait |
|
||||
|----------|--------------------|--------------|
|
||||
| SIGTERM | 0 | 30 seconds |
|
||||
| SIGKILL | non-zero | immediate |
|
||||
|
||||
## Health Check
|
||||
|
||||
| Property | Value |
|
||||
|-----------------|--------------------------------|
|
||||
| Command | `wget -qO- http://localhost:8080/` |
|
||||
| Interval | 30 seconds |
|
||||
| Timeout | 5 seconds |
|
||||
| Start period | 15 seconds |
|
||||
| Retries | 3 |
|
||||
|
||||
The health check passes when nginx responds with any 2xx status on the root path.
|
||||
|
||||
## Image Constraints
|
||||
|
||||
| Constraint | Requirement |
|
||||
|-------------------------|-----------------------------------------------|
|
||||
| Node.js runtime present | MUST NOT be present in runtime image |
|
||||
| `node_modules/` present | MUST NOT be present in runtime image |
|
||||
| Source TypeScript files | MUST NOT be present in runtime image |
|
||||
| Secrets in layer history| MUST NOT appear in any `docker history` layer |
|
||||
| Run as root | MUST NOT — process UID MUST be non-zero |
|
||||
|
||||
## Build Interface
|
||||
|
||||
| Property | Value |
|
||||
|-----------------|----------------------------------------------|
|
||||
| Dockerfile path | `ui/Dockerfile.prod` |
|
||||
| Build context | `ui/` directory |
|
||||
| Build command | `docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest` |
|
||||
|
||||
### Build Context Exclusions (`.dockerignore`)
|
||||
|
||||
The following MUST be excluded from the build context to keep transfers fast and avoid leaking dev state:
|
||||
|
||||
- `node_modules/` — always rebuilt via `npm ci` in the build stage
|
||||
- `dist/` — always rebuilt; must not pollute the build stage
|
||||
- `.git/` — not needed for build
|
||||
- `*.spec.ts` — test files not compiled into production output
|
||||
- `.env*` — dev environment files
|
||||
- `src/**/*.spec.ts` — test specs
|
||||
|
||||
## Verification
|
||||
|
||||
The contract is verified end-to-end by `ui/tests/build/verify_production_image.sh`. Running `make verify-ui-prod` MUST pass all contract checks.
|
||||
152
specs/011-ui-prod-dockerfile/plan.md
Normal file
152
specs/011-ui-prod-dockerfile/plan.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Implementation Plan: Production-Grade UI Container Image
|
||||
|
||||
**Branch**: `011-ui-prod-dockerfile` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/011-ui-prod-dockerfile/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Build a production-grade multi-stage Docker image for the Angular UI. A `node:22-slim` build stage compiles the Angular app into static assets; an `nginxinc/nginx-unprivileged:alpine` runtime stage serves those assets on port 8080 as a non-root user with SPA fallback routing, long-lived cache headers for fingerprinted bundles, and clean SIGTERM handling. The image is verified by a TDD shell script that covers all three user stories (reliable service, security, build caching) in one `make verify-ui-prod` run.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Node.js 22 (build stage); no runtime language in the final image
|
||||
**Primary Dependencies**: Angular CLI 19 (`npm run build`); nginx-unprivileged (runtime web server)
|
||||
**Storage**: None — container serves pre-compiled static files
|
||||
**Testing**: `ui/tests/build/verify_production_image.sh` (shell script TDD artefact, same pattern as `api/tests/build/verify_production_image.sh`)
|
||||
**Target Platform**: Linux container (amd64); Docker 23+ with BuildKit enabled (default); `--mount=type=cache` used for npm cache layer
|
||||
**Project Type**: Static file server (SPA)
|
||||
**Performance Goals**: Cold build < 3 minutes; warm (source-only) rebuild < 30 seconds; health check response < 500ms
|
||||
**Constraints**: Non-root process (UID ≠ 0); Node.js absent from runtime image; no secrets in image layers
|
||||
**Scale/Scope**: Single container; no horizontal scaling concerns at this stage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
### Pre-research gates
|
||||
|
||||
| Principle | Requirement | Status |
|
||||
|-----------|-------------|--------|
|
||||
| §5.1 TDD | Failing test (verify script) must exist before `Dockerfile.prod` | ✅ Plan includes TDD-first task ordering |
|
||||
| §5.3 Tests next to code | `ui/tests/build/` mirrors `api/tests/build/` | ✅ Correct location |
|
||||
| §5.4 CI before done | All tasks marked done only after verify passes | ✅ Enforced in task ordering |
|
||||
| §7.1 One-command start | `docker compose up` must still work | ✅ Only adds prod Dockerfile; dev Dockerfile unchanged |
|
||||
| §7.2 Env config | No hardcoded credentials in Dockerfile | ✅ No runtime env vars needed; build-time config via Angular environment files |
|
||||
| §7.3 Linting | shellcheck on verify script | ✅ T011 in task plan |
|
||||
| §8 Scope | Server-side rendering, OIDC, multi-user — not addressed | ✅ Spec scoped to static asset serving only |
|
||||
|
||||
**No violations. All gates pass.**
|
||||
|
||||
### Post-design re-check
|
||||
|
||||
Same gates apply. No design decisions introduced in Phase 1 conflict with the constitution.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/011-ui-prod-dockerfile/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← technology decisions (10 decisions)
|
||||
├── contracts/
|
||||
│ └── container.md ← container interface contract
|
||||
├── quickstart.md ← build and verify scenarios
|
||||
└── tasks.md ← generated by /speckit-tasks
|
||||
```
|
||||
|
||||
### Source Code Changes
|
||||
|
||||
```text
|
||||
ui/
|
||||
├── Dockerfile.prod ← NEW (multi-stage production build)
|
||||
├── nginx.conf ← NEW (SPA routing + cache headers)
|
||||
├── .dockerignore ← NEW (does not exist yet; created for production build)
|
||||
└── tests/
|
||||
└── build/
|
||||
├── .gitkeep ← NEW (track directory in git)
|
||||
└── verify_production_image.sh ← NEW (TDD verification script)
|
||||
|
||||
Makefile ← MODIFIED (add build-ui-prod, verify-ui-prod targets)
|
||||
```
|
||||
|
||||
## Dockerfile Design
|
||||
|
||||
### Stage 1 — Builder (`node:22-slim`)
|
||||
|
||||
```
|
||||
COPY package.json package-lock.json ./ # layer: deps (cached until lockfile changes)
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci # reproducible install; npm cache mounted
|
||||
COPY . . # layer: source (invalidated on every change)
|
||||
RUN npm run build # ng build --configuration production
|
||||
```
|
||||
|
||||
Output of `npm run build`: `dist/reactbin-ui/browser/` (confirmed: Angular 19 application builder creates `browser/` subdirectory under `outputPath`).
|
||||
|
||||
### Stage 2 — Runtime (`nginxinc/nginx-unprivileged:alpine`)
|
||||
|
||||
- Runs as non-root by design (no manual `useradd` needed)
|
||||
- Listens on port 8080
|
||||
- `COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html`
|
||||
- `COPY nginx.conf /etc/nginx/conf.d/default.conf`
|
||||
- HEALTHCHECK via `wget` (curl not present in Alpine nginx-unprivileged)
|
||||
- No CMD override needed — the base image entrypoint starts nginx
|
||||
|
||||
### nginx.conf
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — unmatched paths return app shell
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Long-lived cache for fingerprinted assets
|
||||
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Never cache the entry point
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verification Script Design (`ui/tests/build/verify_production_image.sh`)
|
||||
|
||||
Mirrors `api/tests/build/verify_production_image.sh` structure:
|
||||
|
||||
| Check | Story | Description |
|
||||
|-------|-------|-------------|
|
||||
| Build | US1 | `docker build -f ui/Dockerfile.prod ui/` succeeds |
|
||||
| Health endpoint | US1 | `wget -q http://localhost:18080/` returns 200 within 30s |
|
||||
| SPA routing | US1 | `curl http://localhost:18080/library` returns 200 |
|
||||
| Graceful shutdown | US1 | `docker stop` → exit code 0 |
|
||||
| Non-root user | US2 | `docker exec id -u` ≠ 0 |
|
||||
| Node.js absent | US2 | `docker run node --version` exits non-zero |
|
||||
| No secrets in history | US2 | `docker history --no-trunc` contains no secret-like strings |
|
||||
| Dep layer cache hit | US3 | `touch ui/src/app/app.component.ts` + rebuild → output contains `CACHED` |
|
||||
|
||||
## Makefile Additions
|
||||
|
||||
```makefile
|
||||
build-ui-prod:
|
||||
docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
|
||||
verify-ui-prod:
|
||||
bash ui/tests/build/verify_production_image.sh
|
||||
```
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Item | Risk | Mitigation |
|
||||
|------|------|------------|
|
||||
| `dist/reactbin-ui/browser/` path | If Angular changes the output directory structure in a future version, the COPY path breaks | Path is verified in research; a test build during verify catches drift |
|
||||
| `nginxinc/nginx-unprivileged` UID | UID may vary between image versions | Check is `UID ≠ 0`, not a specific UID value |
|
||||
| `wget` availability | Alpine images may change toolset | HEALTHCHECK is tested as part of US1 verify |
|
||||
| Port 18080 collision | Another process may use 18080 during verify | Acceptable risk for a dev-time test; port is not a system service |
|
||||
100
specs/011-ui-prod-dockerfile/quickstart.md
Normal file
100
specs/011-ui-prod-dockerfile/quickstart.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Quickstart: UI Production Image
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with BuildKit enabled (default in Docker 23+)
|
||||
- `make` available in the shell
|
||||
|
||||
## Build the Image
|
||||
|
||||
```bash
|
||||
make build-ui-prod
|
||||
# Equivalent: docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
```
|
||||
|
||||
Expected: Build completes in ~2 minutes on first run (npm install), ~15 seconds on subsequent source-only changes.
|
||||
|
||||
## Run the Container
|
||||
|
||||
```bash
|
||||
docker run --rm -p 4200:8080 reactbin-ui-prod:latest
|
||||
```
|
||||
|
||||
Open http://localhost:4200 — the app shell loads. Navigate to `/library` or `/tags` — the page loads (SPA routing returns `index.html`).
|
||||
|
||||
## Verify All Production Checks
|
||||
|
||||
```bash
|
||||
make verify-ui-prod
|
||||
```
|
||||
|
||||
This runs `ui/tests/build/verify_production_image.sh`, which exercises all three user stories:
|
||||
|
||||
```
|
||||
[verify] Building reactbin-ui-prod:verify-<PID>...
|
||||
[verify] Build OK
|
||||
[verify] Polling health endpoint...
|
||||
[verify] Health check passed
|
||||
[verify] SPA routing OK (/library → 200)
|
||||
[verify] Non-root user OK (UID <n>)
|
||||
[verify] Stdout logging OK
|
||||
[verify] Graceful shutdown OK (exit 0)
|
||||
[verify] Node.js absent in runtime image OK
|
||||
[verify] No secrets in image layers OK
|
||||
[verify] Dep layer cache hit confirmed (US3 OK)
|
||||
[verify] All checks passed (US1 + US2 + US3).
|
||||
```
|
||||
|
||||
## Integration Test Scenarios
|
||||
|
||||
### Scenario 1: Initial Build (Cold Cache)
|
||||
|
||||
```bash
|
||||
docker rmi reactbin-ui-prod:latest 2>/dev/null || true
|
||||
make build-ui-prod
|
||||
```
|
||||
|
||||
Expected: `npm ci` runs fully (~30–90s depending on network). All packages installed from lockfile.
|
||||
|
||||
### Scenario 2: Source-Only Rebuild (Warm Cache)
|
||||
|
||||
```bash
|
||||
touch ui/src/app/app.component.ts
|
||||
make build-ui-prod
|
||||
```
|
||||
|
||||
Expected: `npm ci` step is CACHED (skipped). Only the Angular compilation runs (~10–20s).
|
||||
|
||||
### Scenario 3: Dependency Change (Cache Invalidation)
|
||||
|
||||
```bash
|
||||
# Simulate a lockfile change
|
||||
touch ui/package-lock.json
|
||||
make build-ui-prod
|
||||
```
|
||||
|
||||
Expected: `npm ci` runs fresh (cache miss is intentional and correct).
|
||||
|
||||
### Scenario 4: SPA Deep-Link Routing
|
||||
|
||||
```bash
|
||||
docker run --rm -d -p 4200:8080 --name ui-test reactbin-ui-prod:latest
|
||||
curl -sf http://localhost:4200/library # 200 + index.html
|
||||
curl -sf http://localhost:4200/tags # 200 + index.html
|
||||
curl -sf http://localhost:4200/nonexistent # 200 + index.html (Angular handles 404)
|
||||
docker stop ui-test
|
||||
```
|
||||
|
||||
### Scenario 5: Non-Root Assertion
|
||||
|
||||
```bash
|
||||
docker run --rm reactbin-ui-prod:latest id
|
||||
# Must NOT output uid=0(root)
|
||||
```
|
||||
|
||||
### Scenario 6: No Node.js in Runtime Image
|
||||
|
||||
```bash
|
||||
docker run --rm reactbin-ui-prod:latest node --version 2>&1
|
||||
# Must exit non-zero (node not found)
|
||||
```
|
||||
69
specs/011-ui-prod-dockerfile/research.md
Normal file
69
specs/011-ui-prod-dockerfile/research.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Research: Production-Grade UI Container Image
|
||||
|
||||
## Decision 1: Build-stage base image
|
||||
|
||||
**Decision**: `node:22-slim`
|
||||
**Rationale**: Matches the version in the existing dev `ui/Dockerfile`. Slim variant reduces the builder layer size and attack surface relative to the full Debian image.
|
||||
**Alternatives considered**: `node:22-alpine` — lighter, but can introduce musl/glibc compatibility issues with some native npm packages; `node:22-bookworm-slim` — functionally equivalent to `node:22-slim`, same image.
|
||||
|
||||
## Decision 2: Runtime base image
|
||||
|
||||
**Decision**: `nginxinc/nginx-unprivileged:alpine`
|
||||
**Rationale**: Runs fully as a non-root user on port 8080 out of the box — no manual user creation or privilege workarounds required. Alpine-based keeps the final image small. The official `nginx:alpine` image requires the master process to run as root to bind port 80; `nginx-unprivileged` avoids this by binding to 8080 instead.
|
||||
**Alternatives considered**:
|
||||
- `nginx:alpine` — master process must be root (violates FR-005); workers run as `nginx` user but `id -u` inside container still shows 0 for PID 1.
|
||||
- `caddy:alpine` — also supports non-root but adds Caddy's Go runtime footprint unnecessarily for pure static serving.
|
||||
|
||||
## Decision 3: Container port
|
||||
|
||||
**Decision**: Expose port `8080` in the container; external orchestrators (docker-compose, Kubernetes ingress) map it to port 80 or 4200 as needed.
|
||||
**Rationale**: `nginxinc/nginx-unprivileged` defaults to port 8080; deviating would require overriding nginx config with no benefit. Port remapping is standard practice — containers should not run as root just to bind to a privileged port.
|
||||
**Alternatives considered**: Running nginx on port 80 requires either root or Linux capabilities (`CAP_NET_BIND_SERVICE`), both of which increase the attack surface.
|
||||
|
||||
## Decision 4: Angular build output directory
|
||||
|
||||
**Decision**: COPY `dist/reactbin-ui/browser/` into the nginx document root.
|
||||
**Rationale**: The Angular 19 `@angular-devkit/build-angular:application` builder (esbuild-based) places browser assets in `dist/{projectName}/browser/` — confirmed by inspecting the existing `dist/reactbin-ui/browser/` directory in the repo. The parent `dist/reactbin-ui/` also contains `prerendered-routes.json` and `3rdpartylicenses.txt` which must not be served as the web root.
|
||||
**Alternatives considered**: Serving from `dist/reactbin-ui/` directly — would expose the `3rdpartylicenses.txt` file at the root and include the prerendering metadata file.
|
||||
|
||||
## Decision 5: Dependency install command
|
||||
|
||||
**Decision**: `npm ci` (not `npm install`)
|
||||
**Rationale**: `npm ci` installs exactly what `package-lock.json` specifies — reproducible, faster on CI, and fails loudly on lockfile mismatches. All dependencies (including `devDependencies`) are needed in the build stage because Angular CLI and build tools are `devDependencies`.
|
||||
**Alternatives considered**: `npm install` — non-deterministic across environments; `npm install --omit=dev` — would break the Angular build since `@angular/cli` is a devDependency.
|
||||
|
||||
## Decision 6: Layer cache strategy
|
||||
|
||||
**Decision**: Two COPY layers — lockfiles first, then source.
|
||||
```
|
||||
COPY package.json package-lock.json ./ # invalidated only on dep changes
|
||||
RUN npm ci # expensive step, cached when lockfiles unchanged
|
||||
COPY . . # invalidated on every source change
|
||||
RUN npm run build
|
||||
```
|
||||
**Rationale**: Mirrors the proven pattern used in the API's `Dockerfile.prod`. Dependency installation (30s–2min) is cached independently from source compilation.
|
||||
**Alternatives considered**: Single COPY of all source — trivial source changes would always re-run `npm ci`.
|
||||
|
||||
## Decision 7: SPA routing
|
||||
|
||||
**Decision**: nginx `try_files $uri $uri/ /index.html` fallback in a custom `nginx.conf`.
|
||||
**Rationale**: Angular is a single-page application. All non-asset routes (e.g., `/library`, `/tags`, `/login`) must return `index.html` so Angular's router can handle them client-side. Without this, direct navigation to any deep link returns 404.
|
||||
**Alternatives considered**: Redirect to `/` — would break deep linking; returning 404 — breaks client-side routing entirely.
|
||||
|
||||
## Decision 8: Cache-control headers
|
||||
|
||||
**Decision**: Long-lived `Cache-Control: public, max-age=31536000, immutable` for fingerprinted JS/CSS/font assets; `Cache-Control: no-store` for `index.html`.
|
||||
**Rationale**: Angular's production build fingerprints all bundles (e.g., `main.a1b2c3d4.js`). These are safe to cache indefinitely. `index.html` is never fingerprinted and must always be fresh so users pick up new deployments.
|
||||
**Alternatives considered**: No cache-control headers — acceptable for MVP but fails FR-008.
|
||||
|
||||
## Decision 9: Health check probe
|
||||
|
||||
**Decision**: Use `wget -qO- http://localhost:8080/` as the HEALTHCHECK command (no `curl` in `nginx-unprivileged:alpine`).
|
||||
**Rationale**: The `nginxinc/nginx-unprivileged:alpine` image is minimal and does not include `curl`. `wget` is available in Alpine. The health check tests that nginx is accepting connections and returning the app shell.
|
||||
**Alternatives considered**: Installing `curl` via `apk add` — adds package manager overhead and unnecessary tooling to the runtime image.
|
||||
|
||||
## Decision 10: TDD verification approach
|
||||
|
||||
**Decision**: Shell script `ui/tests/build/verify_production_image.sh` mirrors the approach used for the API in feature 010.
|
||||
**Rationale**: There is no pytest equivalent for Docker build artifacts. A shell script that fails because `Dockerfile.prod` does not exist satisfies §5.1 TDD (the script is the failing test; writing the Dockerfile turns it green).
|
||||
**Alternatives considered**: No TDD — violates §5.1; a Python test with subprocess — overkill when a shell script is simpler and already proven.
|
||||
110
specs/011-ui-prod-dockerfile/spec.md
Normal file
110
specs/011-ui-prod-dockerfile/spec.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Feature Specification: Production-Grade UI Container Image
|
||||
|
||||
**Feature Branch**: `011-ui-prod-dockerfile`
|
||||
**Created**: 2026-05-07
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Production-grade UI container image build"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - UI Serves Reliably in Production (Priority: P1)
|
||||
|
||||
A production deployment starts the UI container and it serves the compiled application correctly — returning the app shell for all routes, responding quickly, and shutting down cleanly when the orchestrator stops it.
|
||||
|
||||
**Why this priority**: A container that can't serve traffic is not deployable. All other properties (security, build speed) are meaningless without a running service.
|
||||
|
||||
**Independent Test**: Build the image, start the container, and verify the root path returns a 200 response. Stopping the container produces a clean exit. This alone constitutes a deployable MVP.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a built production image, **When** the container starts, **Then** it serves the application on port 8080 within 30 seconds.
|
||||
2. **Given** the container is running, **When** a request is made to any client-side route (e.g., `/library`, `/tags`), **Then** the server returns the app shell (200 OK) so client-side routing can take over.
|
||||
3. **Given** the container is running, **When** a static asset is requested, **Then** it is returned with appropriate caching headers.
|
||||
4. **Given** a running container, **When** the orchestrator sends a stop signal, **Then** the container exits with code 0 within a reasonable timeout.
|
||||
5. **Given** the production image, **When** a health probe is issued to a designated endpoint, **Then** the container reports healthy.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Minimal, Secure Container (Priority: P2)
|
||||
|
||||
The production image contains only what is needed to serve static files — no build tools, no source code, no `node_modules`. It runs as a non-privileged user.
|
||||
|
||||
**Why this priority**: Shipping build tools and source code in production images increases attack surface and image size. Running as root violates least-privilege principles.
|
||||
|
||||
**Independent Test**: Inspect the running container — confirm the process user is non-root; attempt to import or run a Node.js binary inside the image and confirm it is absent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the production image, **When** the running process user is inspected, **Then** it is not root (UID ≠ 0).
|
||||
2. **Given** the production image, **When** the image contents are inspected, **Then** `node_modules/`, source TypeScript files, and the Node.js runtime are absent.
|
||||
3. **Given** the production image, **When** image layer history is inspected, **Then** no secrets, API keys, or credentials appear in any layer command.
|
||||
4. **Given** the production image, **When** the image size is measured, **Then** it is substantially smaller than a single-stage image that includes the Node.js toolchain.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Fast, Reproducible Builds (Priority: P3)
|
||||
|
||||
Rebuilding the image after a source-only change (no dependency changes) reuses the dependency installation layer from cache, completing in seconds rather than minutes.
|
||||
|
||||
**Why this priority**: Slow builds impede the development feedback loop and CI pipeline throughput. Dependency installs are the dominant time cost.
|
||||
|
||||
**Independent Test**: Build once, then change a source file and build again — the build output confirms the dependency layer was served from cache.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the image has been built once, **When** only a source file is changed and the image is rebuilt, **Then** the dependency installation step is skipped (cache hit).
|
||||
2. **Given** a dependency file is changed, **When** the image is rebuilt, **Then** the dependency installation step runs fresh (cache miss is correct behaviour).
|
||||
3. **Given** two successive builds with identical inputs, **Then** both produce functionally identical output.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the container starts but the built assets are missing or corrupted?
|
||||
- How does the server handle requests for non-existent routes that should fall back to the app shell (SPA routing)?
|
||||
- What happens when the container receives a stop signal while actively serving requests?
|
||||
- What happens if the port is already in use at startup?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The production image MUST be built via a multi-stage process — a build stage compiles the application into static assets, and a separate runtime stage serves only those assets.
|
||||
- **FR-002**: The runtime stage MUST NOT contain the Node.js runtime, npm, source TypeScript, or `node_modules/`.
|
||||
- **FR-003**: The container MUST serve the application on port 8080. External orchestrators (docker-compose, Kubernetes ingress) map this to port 80 as needed.
|
||||
- **FR-004**: The container MUST handle SPA (single-page application) routing by returning the app shell for any unmatched path, so client-side routing works correctly.
|
||||
- **FR-005**: The container MUST run as a non-root user.
|
||||
- **FR-006**: The container MUST expose a health-check endpoint that returns success when the service is ready to accept traffic.
|
||||
- **FR-007**: The container MUST exit with code 0 when sent a graceful stop signal.
|
||||
- **FR-008**: Static assets MUST be served with cache-control headers that enable client-side caching for fingerprinted assets.
|
||||
- **FR-009**: The Dockerfile MUST structure layers so that dependency installation is cached independently from source code changes.
|
||||
- **FR-010**: The build MUST be reproducible — given the same source and lockfile, successive builds produce equivalent images.
|
||||
- **FR-011**: No credentials, secrets, or API keys MUST appear in any image layer.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Build Stage**: The intermediate container that installs dependencies and compiles source into static assets; discarded after build.
|
||||
- **Static Assets**: The compiled output (HTML, JS bundles, CSS, fonts, images) that the runtime stage serves.
|
||||
- **Runtime Stage**: The minimal final image containing only a web server and the compiled static assets.
|
||||
- **Production Image**: The tagged, distributable image produced by the build; used directly in deployment.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The container serves a 200 response on port 8080 within 30 seconds of starting.
|
||||
- **SC-002**: The production image is substantially smaller than a single-stage image that retains the Node.js toolchain. A manual size comparison after the initial build confirms the multi-stage approach delivers a meaningful reduction (expected: >60% reduction).
|
||||
- **SC-003**: A source-only rebuild completes in under 30 seconds (dependency layer served from cache).
|
||||
- **SC-004**: All 11 functional requirements pass automated verification on every build.
|
||||
- **SC-005**: The running container process has UID ≠ 0, confirmed by automated check.
|
||||
- **SC-006**: No existing integration tests regress after the Dockerfile and supporting files are introduced.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The Angular application is built for production using the standard build toolchain (`ng build --configuration production` or equivalent), producing a `dist/` output directory.
|
||||
- The production web server is responsible for SPA fallback routing (returning the app shell for unmatched paths).
|
||||
- Gzip or Brotli compression at the web server layer is desirable but not mandatory for the initial implementation.
|
||||
- The UI container does not need to proxy API requests — it communicates with the API directly from the browser (the Angular proxy config is only used in local development).
|
||||
- The container listens on port 8080 (non-privileged, enabling non-root operation). External load balancers or ingress controllers map this to port 80. TLS termination occurs upstream.
|
||||
- The build context is the `ui/` directory; files excluded from the build context (source maps in CI, `node_modules/` already present locally) are managed via `.dockerignore`.
|
||||
- The same verification approach used for the API image (a shell script as the TDD artefact) applies here.
|
||||
166
specs/011-ui-prod-dockerfile/tasks.md
Normal file
166
specs/011-ui-prod-dockerfile/tasks.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Tasks: Production-Grade UI Container Image
|
||||
|
||||
**Input**: Design documents from `specs/011-ui-prod-dockerfile/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/container.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: TDD is non-negotiable (§5.1). The "test" for a Docker build artefact is `ui/tests/build/verify_production_image.sh`, written before `ui/Dockerfile.prod` exists. Running the script immediately fails (red) because the build step cannot find the file; writing `Dockerfile.prod` turns it green.
|
||||
|
||||
**Organization**: Phase 1 sets up Makefile targets, `.dockerignore`, and supporting files; Phase 3 (US1) writes the verification script and the Dockerfile; Phase 4 (US2) extends the script with security checks; Phase 5 (US3) extends it with a cache-hit check; Phase 6 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
|
||||
|
||||
- [X] T001 Add `build-ui-prod` and `verify-ui-prod` targets (and their `.PHONY` entries) to the root `Makefile` at `/workspace/Makefile`: `build-ui-prod` runs `docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest`; `verify-ui-prod` runs `bash ui/tests/build/verify_production_image.sh`
|
||||
|
||||
- [X] T002 Create `ui/.dockerignore` at `/workspace/ui/.dockerignore` with the following exclusions (the file does not yet exist — create it fresh): `.git/`, `node_modules/`, `dist/`, `.angular/`, `coverage/`, `*.spec.ts`, `.env`, `.env.*`, `!.env.example`, `tests/`; these keep the build context transfer fast and prevent dev state from leaking into the production image
|
||||
|
||||
- [X] T003 Create directory `ui/tests/build/` at `/workspace/ui/tests/build/` with `mkdir -p` and add a `.gitkeep` so the directory is tracked in git
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint**: Directory structure is ready; Makefile and .dockerignore are created.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational
|
||||
|
||||
No blocking foundational prerequisites exist for this feature — the setup tasks in Phase 1 directly enable all user story phases. Phase 2 is intentionally omitted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — UI Serves Reliably in Production (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: The container builds, starts, serves the health endpoint and SPA routes, and exits cleanly on SIGTERM.
|
||||
|
||||
**Independent Test**: `make verify-ui-prod` — passes when `Dockerfile.prod` and `nginx.conf` exist and all US1 checks pass.
|
||||
|
||||
### Test for User Story 1 (TDD red — write first, confirm failure before T005)
|
||||
|
||||
- [X] T004 [US1] Create `ui/tests/build/verify_production_image.sh` as an executable bash script (`chmod +x`) with `#!/usr/bin/env bash` and `set -euo pipefail`; the script MUST:
|
||||
1. Set `IMAGE="reactbin-ui-prod:verify-$$"` and `IMAGE2="reactbin-ui-prod:verify-cache-$$"` and `APP_CONTAINER=""`;
|
||||
2. Define a `cleanup()` function that runs `docker rm -f "$APP_CONTAINER" 2>/dev/null || true`, `docker rmi "$IMAGE" 2>/dev/null || true`, and `docker rmi "$IMAGE2" 2>/dev/null || true`, then register it with `trap cleanup EXIT`;
|
||||
3. **[US1 check 1 — build]** Run `docker build -f ui/Dockerfile.prod ui/ -t "$IMAGE"` — this is the line that fails **red** because `ui/Dockerfile.prod` does not yet exist; print `[verify] Building $IMAGE...` before and `[verify] Build OK` after;
|
||||
4. **[US1 check 2 — start container]** Start the production container: `APP_CONTAINER=$(docker run -d -p 18080:8080 "$IMAGE")`; print `[verify] Starting production container...`;
|
||||
5. **[US1 check 3 — health endpoint]** Poll `curl -sf http://localhost:18080/` up to 30 × 1s, fail with `FAIL: health check timed out after 30s` if timeout; print `[verify] Health check passed` on success;
|
||||
6. **[US1 check 4 — SPA routing]** Run `curl -sf http://localhost:18080/library > /dev/null`; assert exit code is 0 (200 response); fail with `FAIL: SPA routing check failed (/library did not return 200)` if violated; print `[verify] SPA routing OK (/library → 200)`;
|
||||
7. **[US1 check 5 — SIGTERM → exit 0]** Run `docker stop "$APP_CONTAINER"` (sends SIGTERM); capture `EXIT_CODE=$(docker wait "$APP_CONTAINER")`; assert `"$EXIT_CODE" -eq 0`, fail with `FAIL: non-zero exit code $EXIT_CODE after SIGTERM` otherwise; print `[verify] Graceful shutdown OK (exit $EXIT_CODE)`;
|
||||
8. Print `[verify] US1 checks passed.`
|
||||
After writing the script, run `make verify-ui-prod` and confirm it **fails** with a Docker build error (red state — `ui/Dockerfile.prod` does not exist).
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T005 [US1] Create `ui/nginx.conf` at `/workspace/ui/nginx.conf` — an nginx server block that: listens on port `8080`; sets `root /usr/share/nginx/html` and `index index.html`; adds a `location /` block with `try_files $uri $uri/ /index.html` for SPA fallback routing; adds a `location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$` block with `expires 1y` and `add_header Cache-Control "public, immutable"` for fingerprinted assets; adds a `location = /index.html` block with `add_header Cache-Control "no-store, no-cache, must-revalidate"` so the entry point is never cached
|
||||
|
||||
- [X] T006 [US1] Create `ui/Dockerfile.prod` at `/workspace/ui/Dockerfile.prod` — a two-stage multi-stage build:
|
||||
**Stage 1 (builder)**: `FROM node:22-slim AS builder`; `WORKDIR /app`; `COPY package.json package-lock.json ./`; `RUN --mount=type=cache,target=/root/.npm npm ci`; `COPY . .`; `RUN npm run build`
|
||||
**Stage 2 (runtime)**: `FROM nginxinc/nginx-unprivileged:alpine`; `COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html`; `COPY nginx.conf /etc/nginx/conf.d/default.conf`; `EXPOSE 8080`; `HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:8080/ || exit 1`
|
||||
|
||||
- [X] T007 [US1] Verify TDD green for US1: run `make verify-ui-prod` and confirm all five US1 checks pass — build OK, health endpoint returns 200, SPA routing returns 200, SIGTERM produces exit code 0, and `[verify] US1 checks passed.` is printed.
|
||||
|
||||
**Checkpoint**: US1 is complete. Production container builds, starts, serves traffic (including SPA routes), and shuts down gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Minimal, Secure Container (Priority: P2)
|
||||
|
||||
**Goal**: The production image runs as non-root and contains no Node.js runtime, source, or embedded secrets.
|
||||
|
||||
**Independent Test**: US2 checks in `make verify-ui-prod` — the same script extended with non-root, node-absent, and secrets-free assertions.
|
||||
|
||||
### Tests for User Story 2 (TDD extension — add checks, confirm they pass against existing Dockerfile.prod)
|
||||
|
||||
- [X] T008 [US2] Extend `ui/tests/build/verify_production_image.sh` with US2 checks inserted after the health/SPA/SIGTERM checks (before the final `US1 checks passed` line) and update the final success message to `[verify] All checks passed (US1 + US2).`:
|
||||
**[US2 check 1 — non-root]** Before `docker stop`, run `UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)`; assert `"$UID_IN_CONTAINER" -ne 0`, fail with `FAIL: process running as root (UID 0)` if violated; print `[verify] Non-root user OK (UID $UID_IN_CONTAINER)`;
|
||||
**[C1 — stdout log capture]** Run `LOGS=$(docker logs "$APP_CONTAINER" 2>&1)`; assert `"$LOGS"` is non-empty, fail with `FAIL: no output on stdout/stderr` if empty; print `[verify] Stdout logging OK`; insert this check before `docker stop`;
|
||||
**[US2 check 2 — Node.js absent]** After SIGTERM cleanup, run `docker run --rm "$IMAGE" node --version 2>/dev/null`; assert the exit code is **non-zero** (node not present in runtime image); if it returns 0, fail with `FAIL: node runtime found in production image`; print `[verify] Node.js absent in runtime image OK`;
|
||||
**[C2 — no hardcoded secrets in layers]** Run `docker history --no-trunc "$IMAGE" 2>&1`; pipe through `grep -qiE "(password|secret_key|api_key|token)"`; assert zero matching lines; if any match, fail with `FAIL: potential secret found in image history`; print `[verify] No secrets in image layers OK`;
|
||||
**[FR-008 — cache-control headers on assets]** While APP_CONTAINER is running, find the first JS bundle filename: `JS_FILE=$(docker run --rm "$IMAGE" ls /usr/share/nginx/html | grep -E '\.js$' | head -1)`; run `curl -sI "http://localhost:18080/${JS_FILE}"`; assert the response contains `Cache-Control` with `immutable` or `max-age=31536000`, fail with `FAIL: cache-control header not set on fingerprinted asset` if absent; print `[verify] Cache-Control header OK`;
|
||||
Confirm `make verify-ui-prod` passes with the extended checks.
|
||||
|
||||
**Checkpoint**: US2 is verified. Image runs as a non-root user and contains no Node.js toolchain.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Fast, Reproducible Builds (Priority: P3)
|
||||
|
||||
**Goal**: Rebuilding after a source-only change reuses the `npm ci` dependency layer from cache.
|
||||
|
||||
**Independent Test**: US3 check in `make verify-ui-prod` — a second build after touching a source file asserts the dep layer was cached.
|
||||
|
||||
### Tests for User Story 3 (TDD extension)
|
||||
|
||||
- [X] T009 [US3] Extend `ui/tests/build/verify_production_image.sh` with a US3 cache check appended after all other checks (before the final success line):
|
||||
**[US3 check — dep layer cached on source-only rebuild]** Print `[verify] Testing cache hit on source-only rebuild...`; `touch ui/src/app/app.component.ts`; capture `BUILD2_OUTPUT=$(docker build --progress=plain -f ui/Dockerfile.prod ui/ -t "$IMAGE2" 2>&1)` (the `--progress=plain` flag ensures consistent `CACHED` output regardless of Docker version or TTY); assert the output contains the string `CACHED`; if absent, fail with `FAIL: dependency layer not reused on source-only rebuild`; print `[verify] Dep layer cache hit confirmed (US3 OK)`;
|
||||
Update the final success line to `[verify] All checks passed (US1 + US2 + US3).`
|
||||
|
||||
- [X] T010 [US3] Verify TDD green for US3: run `make verify-ui-prod` and confirm the full script passes including the cache check — the build output for the second image must contain `CACHED`, and `[verify] All checks passed (US1 + US2 + US3).` must print.
|
||||
|
||||
**Checkpoint**: All three user stories are verified end-to-end by `make verify-ui-prod`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T011 Run `make test-integration` from `/workspace` and confirm all 102 existing tests still pass — verifies that the new files (Makefile targets, ui/.dockerignore, ui/tests/build/) do not break the existing test Dockerfile build or any integration test (§5.4 regression gate)
|
||||
|
||||
- [X] T012 Confirm image size reduction (SC-002): run `docker images reactbin-ui-prod:latest --format "{{.Size}}"` and compare against a reference single-stage image built from `FROM node:22-slim` + `npm ci` + `npm run build` to confirm the production image is substantially smaller (expected >60% reduction); document the sizes in a comment or log line
|
||||
|
||||
- [X] T013 Run `shellcheck ui/tests/build/verify_production_image.sh` and fix any violations (common: unquoted variables, `[ ]` vs `[[ ]]`, missing `--` before arguments); also verify `make verify-ui-prod` still passes after any fixes
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: No external dependencies — start immediately
|
||||
- **Phase 3 (US1)**: Depends on Phase 1 (Makefile + .dockerignore must exist before `make verify-ui-prod` can run) and directory must exist (T003)
|
||||
- **Phase 4 (US2)**: Depends on Phase 3 (US1 script and Dockerfile must exist to extend)
|
||||
- **Phase 5 (US3)**: Depends on Phase 4 (full US2 script must exist to extend)
|
||||
- **Phase 6 (Polish)**: Depends on all prior phases; T011 before T012
|
||||
|
||||
### Within Phase 3
|
||||
|
||||
- T004 before T005/T006 (write test script before writing the nginx config and Dockerfile)
|
||||
- T005 and T006 can run in parallel (different files, no mutual dependency)
|
||||
- T007 after T005 and T006 (verify green after both implementation files exist)
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 ∥ T002 ∥ T003 (setup — parallel, different files)
|
||||
Step 2: T004 (write verification script — TDD red)
|
||||
Step 3: T005 ∥ T006 (write nginx.conf and Dockerfile.prod — parallel)
|
||||
Step 4: T007 (verify US1 green)
|
||||
Step 5: T008 (extend script with US2 checks, verify pass)
|
||||
Step 6: T009 (extend script with US3 check)
|
||||
Step 7: T010 (verify US3 green)
|
||||
Step 8: T011 (make test-integration — regression gate)
|
||||
Step 9: T012 (image size comparison — SC-002)
|
||||
Step 10: T013 (shellcheck polish)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 — reliable production run)
|
||||
|
||||
1. Complete T001–T003 (setup)
|
||||
2. Complete T004–T007 (core: write script → write nginx.conf + Dockerfile → verify green)
|
||||
3. **Validate**: `make verify-ui-prod` passes; `make test-integration` still passes
|
||||
4. US2 and US3 add explicit verification coverage for properties already implemented by the two-stage build
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- After Phase 3: Production image builds, starts, serves traffic with SPA routing — safe to deploy
|
||||
- After Phase 4: Security properties (non-root, no Node.js runtime) are explicitly verified
|
||||
- After Phase 5: Build efficiency (npm ci layer caching) is confirmed by automated check
|
||||
- After Phase 6: Script is lint-clean, ready for CI integration
|
||||
34
specs/012-api-docs-gate/checklists/requirements.md
Normal file
34
specs/012-api-docs-gate/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: API Documentation Visibility Gate
|
||||
|
||||
**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
|
||||
|
||||
- All items pass. Spec is ready for `/speckit-plan`.
|
||||
40
specs/012-api-docs-gate/contracts/docs-endpoints.md
Normal file
40
specs/012-api-docs-gate/contracts/docs-endpoints.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Contract: API Documentation Endpoints
|
||||
|
||||
These three endpoints exist in FastAPI by default. This feature makes their availability conditional on a runtime configuration flag.
|
||||
|
||||
## Affected Endpoints
|
||||
|
||||
| Endpoint | Default path | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| Swagger UI | `GET /docs` | Interactive browser-based API documentation |
|
||||
| ReDoc UI | `GET /redoc` | Alternative read-only API documentation |
|
||||
| OpenAPI schema | `GET /openapi.json` | Raw JSON schema of the entire API surface |
|
||||
|
||||
## Behaviour by Flag State
|
||||
|
||||
### `API_DOCS_ENABLED=true` (default)
|
||||
|
||||
All three endpoints respond exactly as they did before this feature. No change.
|
||||
|
||||
| Endpoint | Response |
|
||||
|----------|----------|
|
||||
| `GET /docs` | `200 OK` — Swagger UI HTML |
|
||||
| `GET /redoc` | `200 OK` — ReDoc UI HTML |
|
||||
| `GET /openapi.json` | `200 OK` — OpenAPI schema JSON |
|
||||
|
||||
### `API_DOCS_ENABLED=false`
|
||||
|
||||
All three endpoints are unregistered. Requests fall through to the framework's default 404 handler.
|
||||
|
||||
| Endpoint | Response |
|
||||
|----------|----------|
|
||||
| `GET /docs` | `404 Not Found` |
|
||||
| `GET /redoc` | `404 Not Found` |
|
||||
| `GET /openapi.json` | `404 Not Found` |
|
||||
|
||||
## Invariants
|
||||
|
||||
- All other endpoints are unaffected in both flag states.
|
||||
- The `GET /api/v1/health` endpoint always returns `200 OK` regardless of the flag.
|
||||
- Internal OpenAPI schema generation (used for request/response validation) is not disabled — only the HTTP routes serving it are removed.
|
||||
- The flag is read once at application startup. A running process does not respond to live changes; a restart is required.
|
||||
138
specs/012-api-docs-gate/plan.md
Normal file
138
specs/012-api-docs-gate/plan.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Implementation Plan: API Documentation Visibility Gate
|
||||
|
||||
**Branch**: `012-api-docs-gate` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/012-api-docs-gate/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add `API_DOCS_ENABLED` (boolean, default `true`) to `app/config.py`. When `false`, pass `docs_url=None`, `redoc_url=None`, `openapi_url=None` to the `FastAPI()` constructor in `app/main.py`, making all three documentation routes return 404. A field validator provides graceful fallback for invalid flag values. Two new integration tests verify both flag states; the existing unit test suite is extended with two settings tests.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12
|
||||
**Primary Dependencies**: FastAPI (constructor params), pydantic-settings (field validator)
|
||||
**Storage**: None
|
||||
**Testing**: pytest unit (`api/tests/unit/test_config.py`), pytest + ASGI test client (`api/tests/integration/test_docs_gate.py`)
|
||||
**Target Platform**: API container (same as existing)
|
||||
**Project Type**: Web service configuration change
|
||||
**Performance Goals**: No measurable impact — one boolean read at startup
|
||||
**Constraints**: Default must be `true` (backwards compatible); invalid env var value must not crash startup; no other routes affected
|
||||
**Scale/Scope**: Three files changed (`config.py`, `main.py`, `.env.example`); one new test file; one existing test file extended
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Principle | Requirement | Status |
|
||||
|-----------|-------------|--------|
|
||||
| §5.1 TDD | Failing tests written before implementation | ✅ Tasks order tests first |
|
||||
| §5.2 Integration tests | New integration tests follow existing pattern | ✅ |
|
||||
| §5.3 Tests next to code | `api/tests/unit/` and `api/tests/integration/` | ✅ |
|
||||
| §5.4 CI before done | All tests pass before task marked done | ✅ |
|
||||
| §7.2 Env config | Flag via environment variable, not hardcoded | ✅ |
|
||||
| §7.3 Linting | `ruff` passes on all changed files | ✅ Enforced in polish task |
|
||||
| §2.6 No speculative abstraction | One boolean field, no plugin system | ✅ |
|
||||
|
||||
**No violations. All gates pass.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/012-api-docs-gate/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← 6 decisions
|
||||
├── contracts/
|
||||
│ └── docs-endpoints.md ← behaviour contract for 3 affected endpoints
|
||||
├── quickstart.md ← 4 test scenarios
|
||||
└── tasks.md ← generated by /speckit-tasks
|
||||
```
|
||||
|
||||
### Source Code Changes
|
||||
|
||||
```text
|
||||
api/
|
||||
├── app/
|
||||
│ ├── config.py ← MODIFIED: add api_docs_enabled field + validator
|
||||
│ └── main.py ← MODIFIED: conditional docs_url/redoc_url/openapi_url
|
||||
├── tests/
|
||||
│ ├── unit/
|
||||
│ │ └── test_config.py ← MODIFIED: 2 new tests for api_docs_enabled
|
||||
│ └── integration/
|
||||
│ └── test_docs_gate.py ← NEW: 2 integration tests (disabled + enabled)
|
||||
|
||||
.env.example ← MODIFIED: document API_DOCS_ENABLED
|
||||
```
|
||||
|
||||
## Implementation Design
|
||||
|
||||
### `app/config.py` — new field with graceful fallback validator
|
||||
|
||||
```python
|
||||
from pydantic import field_validator
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ... existing fields ...
|
||||
api_docs_enabled: bool = True
|
||||
|
||||
@field_validator('api_docs_enabled', mode='before')
|
||||
@classmethod
|
||||
def coerce_docs_enabled(cls, v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
try:
|
||||
from pydantic import TypeAdapter
|
||||
return TypeAdapter(bool).validate_python(v)
|
||||
except Exception:
|
||||
return True # FR-007: invalid value → safe default (enabled)
|
||||
```
|
||||
|
||||
### `app/main.py` — conditional docs URLs
|
||||
|
||||
```python
|
||||
_settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Reactbin API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if _settings.api_docs_enabled else None,
|
||||
redoc_url="/redoc" if _settings.api_docs_enabled else None,
|
||||
openapi_url="/openapi.json" if _settings.api_docs_enabled else None,
|
||||
)
|
||||
```
|
||||
|
||||
### Integration test pattern
|
||||
|
||||
The `app` object is constructed at module import time. Tests reload the module with the env var pre-set:
|
||||
|
||||
```python
|
||||
def test_docs_disabled(monkeypatch, _base_env):
|
||||
monkeypatch.setenv("API_DOCS_ENABLED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
import importlib, app.main as m
|
||||
importlib.reload(m)
|
||||
client = TestClient(m.app)
|
||||
assert client.get("/docs").status_code == 404
|
||||
assert client.get("/redoc").status_code == 404
|
||||
assert client.get("/openapi.json").status_code == 404
|
||||
assert client.get("/api/v1/health").status_code == 200
|
||||
```
|
||||
|
||||
`get_settings.cache_clear()` is required before the reload so the new env var is picked up.
|
||||
|
||||
### `.env.example` addition
|
||||
|
||||
```bash
|
||||
# API documentation endpoints (Swagger UI, ReDoc, OpenAPI schema)
|
||||
# Set to false in production to avoid exposing the API surface publicly.
|
||||
API_DOCS_ENABLED=true
|
||||
```
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Item | Risk | Mitigation |
|
||||
|------|------|------------|
|
||||
| `@lru_cache` on `get_settings()` | Tests may pick up cached settings across reloads | Always call `get_settings.cache_clear()` before reloading `app.main` in tests |
|
||||
| Module-level `get_settings()` in `main.py` | Import fails if required settings are absent (pre-existing behaviour) | Not a new risk; same as today |
|
||||
| `openapi_url=None` | Disables HTTP route but not internal schema generation | Intentional; request validation is unaffected |
|
||||
42
specs/012-api-docs-gate/quickstart.md
Normal file
42
specs/012-api-docs-gate/quickstart.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Quickstart: API Documentation Visibility Gate
|
||||
|
||||
## Verify docs are disabled
|
||||
|
||||
```bash
|
||||
# Start API with docs disabled
|
||||
API_DOCS_ENABLED=false uvicorn app.main:app --reload
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/docs # → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/redoc # → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/openapi.json # → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/v1/health # → 200
|
||||
```
|
||||
|
||||
## Verify docs are enabled (default)
|
||||
|
||||
```bash
|
||||
# Start API without the flag (or with it set to true)
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/docs # → 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/redoc # → 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/openapi.json # → 200
|
||||
```
|
||||
|
||||
## Integration test scenarios
|
||||
|
||||
### Scenario 1: flag disabled — all three docs endpoints return 404
|
||||
|
||||
Start a test client with `API_DOCS_ENABLED=false` injected into settings. Assert each of the three endpoint paths returns 404. Assert `/api/v1/health` returns 200.
|
||||
|
||||
### Scenario 2: flag enabled (default) — docs endpoints return 200
|
||||
|
||||
Start a test client without the flag (or with `API_DOCS_ENABLED=true`). Assert each of the three endpoint paths returns 200.
|
||||
|
||||
### Scenario 3: invalid flag value — app starts, docs enabled
|
||||
|
||||
Set `API_DOCS_ENABLED=not-a-bool`. The app must start without error. Docs must be accessible (safe fallback to enabled).
|
||||
|
||||
### Scenario 4: flag absent — docs enabled (backwards compatibility)
|
||||
|
||||
Start the app with no `API_DOCS_ENABLED` variable set. Assert docs endpoints return 200 — identical to pre-feature behaviour.
|
||||
36
specs/012-api-docs-gate/research.md
Normal file
36
specs/012-api-docs-gate/research.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Research: API Documentation Visibility Gate
|
||||
|
||||
## Decision 1: Env var name
|
||||
|
||||
**Decision**: `API_DOCS_ENABLED` (boolean, default `true`)
|
||||
**Rationale**: Consistent with the existing `API_BASE_URL` naming convention in the project. The positive-phrasing default (`true` = enabled) preserves backwards compatibility — existing deployments that don't set the variable get the same behaviour as today.
|
||||
**Alternatives considered**: `HIDE_API_DOCS=false` (negative phrasing) — inverted booleans are error-prone and confusing in `.env` files; `DOCS_ENABLED` — too generic, could collide with other tools in a multi-service env file.
|
||||
|
||||
## Decision 2: FastAPI docs suppression mechanism
|
||||
|
||||
**Decision**: Pass `docs_url=None`, `redoc_url=None`, `openapi_url=None` to the `FastAPI()` constructor when the flag is disabled.
|
||||
**Rationale**: This is the official FastAPI-supported mechanism. Setting these to `None` causes FastAPI to register no routes for those paths — requests to them fall through to the default 404 handler. The internal OpenAPI schema is still generated in memory (for request validation), but no HTTP route exposes it.
|
||||
**Alternatives considered**: Route-level middleware that intercepts and returns 404 — more complex, not the canonical approach; removing routers at runtime — impossible, routers are registered at import time.
|
||||
|
||||
## Decision 3: Settings read at module level
|
||||
|
||||
**Decision**: Read `get_settings()` once at module import time in `main.py` to configure the `FastAPI()` constructor.
|
||||
**Rationale**: `FastAPI()` is instantiated at module level; the docs URL parameters must be known at that point. `get_settings()` is already `@lru_cache` so calling it at module level is cheap and consistent with calling it again inside `lifespan`. Tests that need to change the flag must reload the module or override `get_settings`.
|
||||
**Alternatives considered**: Lazy initialisation of `app` inside a factory function — would require restructuring `main.py` and all imports; not worth the complexity for this change.
|
||||
|
||||
## Decision 4: Graceful fallback for invalid flag values (FR-007)
|
||||
|
||||
**Decision**: Add a `@field_validator('api_docs_enabled', mode='before')` in `Settings` that wraps Pydantic's bool coercion in a try/except and returns `True` on any `ValueError`.
|
||||
**Rationale**: Pydantic v2 raises `ValidationError` for unrecognised boolean strings (e.g., `API_DOCS_ENABLED=maybe`). FR-007 requires the app to start rather than fail. The validator intercepts the invalid value before Pydantic's own coercion and returns the safe default.
|
||||
**Alternatives considered**: Using `Optional[bool] = True` without a validator — Pydantic would still raise on invalid input; using `str` field with manual parsing — duplicates Pydantic's boolean parsing logic unnecessarily.
|
||||
|
||||
## Decision 5: Integration test approach
|
||||
|
||||
**Decision**: Test both enabled and disabled states by overriding `get_settings` in integration tests using `app.dependency_overrides`, or by constructing a local `FastAPI` app instance with the appropriate `docs_url`/`redoc_url`/`openapi_url` values.
|
||||
**Rationale**: The `app` in `app.main` is created at import time. Since the unit tests already use `monkeypatch` + `importlib.reload` for config changes, the integration tests for docs visibility can follow the same pattern — reload `app.main` with the env var set before importing `app`. Alternatively, test the URL routing behaviour directly by constructing a minimal test app.
|
||||
**Alternatives considered**: Patching `app.docs_url` after import — FastAPI does not re-register routes when these attributes are changed post-construction; no effect on routing.
|
||||
|
||||
## Decision 6: Production documentation
|
||||
|
||||
**Decision**: Update `.env.example` to include `API_DOCS_ENABLED=true` with a comment recommending `false` for production. No changes to `api/Dockerfile.prod` (env vars are supplied by the deployment environment, not the image).
|
||||
**Rationale**: The Dockerfile intentionally contains no runtime secrets or config. The `.env.example` is the canonical documentation for operators. A comment is sufficient; the production Dockerfile.prod already has no docs-related config.
|
||||
80
specs/012-api-docs-gate/spec.md
Normal file
80
specs/012-api-docs-gate/spec.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Feature Specification: API Documentation Visibility Gate
|
||||
|
||||
**Feature Branch**: `012-api-docs-gate`
|
||||
**Created**: 2026-05-07
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Add an environment variable flag to disable the FastAPI Swagger and ReDoc documentation endpoints (and the raw OpenAPI schema) in production. When disabled, all three endpoints return 404. When enabled (the default), behaviour is unchanged. The flag should be off by default in production and on by default in development."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Documentation Hidden in Production (Priority: P1)
|
||||
|
||||
An operator deploys the API to a production environment and wants to ensure that the interactive documentation UI and the raw API schema are not publicly reachable. Setting a configuration flag causes all three documentation endpoints to return "not found", as if they do not exist.
|
||||
|
||||
**Why this priority**: Exposing the full API schema and interactive console to anonymous users in production reveals the attack surface of the application. Hiding it is a low-effort, high-value hardening step.
|
||||
|
||||
**Independent Test**: Start the API with the flag set to disabled. Request each of the three documentation endpoints. All three must return 404.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is started with documentation disabled, **When** a client requests the interactive documentation UI, **Then** the response is 404 Not Found.
|
||||
2. **Given** the API is started with documentation disabled, **When** a client requests the alternative documentation UI, **Then** the response is 404 Not Found.
|
||||
3. **Given** the API is started with documentation disabled, **When** a client requests the raw OpenAPI schema endpoint, **Then** the response is 404 Not Found.
|
||||
4. **Given** the API is started with documentation disabled, **When** a client requests any other API endpoint (e.g., the health check), **Then** the response is unaffected — normal behaviour continues.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Documentation Available in Development (Priority: P2)
|
||||
|
||||
A developer runs the API locally without setting the flag. The documentation endpoints remain fully accessible — no change in behaviour from before this feature.
|
||||
|
||||
**Why this priority**: Developer productivity depends on the interactive docs being available during local development. The default must not break existing workflows.
|
||||
|
||||
**Independent Test**: Start the API without the flag set (or with it explicitly enabled). Request each of the three documentation endpoints. All three must respond successfully with their normal content.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is started without the flag set, **When** a client requests any documentation endpoint, **Then** the response is the same as it was before this feature was introduced.
|
||||
2. **Given** the API is started with the flag explicitly set to enabled, **When** a client requests any documentation endpoint, **Then** the response is the same as it was before this feature was introduced.
|
||||
3. **Given** the flag is changed from enabled to disabled (or vice versa), **When** the API is restarted, **Then** the new state takes effect immediately with no other changes required.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if the flag is set to an unrecognised value (e.g., a typo)?
|
||||
- What happens if the flag is absent entirely — is the default enabled or disabled?
|
||||
- Does disabling documentation affect any other behaviour (e.g., internal schema generation used for validation)?
|
||||
- If a monitoring tool scrapes the schema endpoint for API drift detection, does disabling break it?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST support a configuration flag that controls whether the API documentation endpoints are reachable.
|
||||
- **FR-002**: When the flag is set to disabled, all three documentation endpoints (interactive UI, alternative UI, and raw schema) MUST return 404 Not Found.
|
||||
- **FR-003**: When the flag is set to enabled, the behaviour of all three documentation endpoints MUST be identical to the behaviour before this feature was introduced.
|
||||
- **FR-004**: The flag MUST default to **enabled** when not explicitly set (preserving backwards compatibility for existing deployments).
|
||||
- **FR-005**: Disabling documentation MUST NOT affect any other API endpoint, including the health check, authentication, and all resource endpoints.
|
||||
- **FR-006**: The flag MUST be configurable via an environment variable without requiring a code change or rebuild.
|
||||
- **FR-007**: An unrecognised or missing flag value MUST fall back to the enabled default rather than causing a startup failure.
|
||||
- **FR-008**: The existing `.env.example` file MUST be updated to document the flag and its default value.
|
||||
- **FR-009**: The production environment configuration MUST set the flag to disabled by default.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: With the flag disabled, all three documentation endpoints return 404, confirmed by automated test.
|
||||
- **SC-002**: With the flag enabled (or absent), all three documentation endpoints respond successfully, confirmed by automated test.
|
||||
- **SC-003**: All existing tests continue to pass — zero regressions introduced.
|
||||
- **SC-004**: The flag takes effect on restart with no other intervention required.
|
||||
- **SC-005**: The `.env.example` file documents the flag so any developer setting up the project discovers it without reading source code.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- There are exactly three documentation-related endpoints to gate: the primary interactive UI, the alternative documentation UI, and the raw OpenAPI schema JSON. No other endpoints are affected.
|
||||
- The flag is read once at application startup; a running process does not need to respond to live changes.
|
||||
- Internal schema generation (used by the framework for request validation) is not affected by hiding the documentation endpoints — only the public-facing HTTP routes are removed.
|
||||
- The production Dockerfile (`api/Dockerfile.prod`) does not hardcode the flag; it is supplied via the deployment environment (docker-compose, Kubernetes secret, etc.).
|
||||
- "Off by default in production" means the recommended value for production is disabled, documented in `.env.example` and in the production docker-compose or deployment config; it does not mean the application auto-detects its environment.
|
||||
100
specs/012-api-docs-gate/tasks.md
Normal file
100
specs/012-api-docs-gate/tasks.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Tasks: API Documentation Visibility Gate
|
||||
|
||||
**Input**: Design documents from `specs/012-api-docs-gate/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/docs-endpoints.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: TDD is non-negotiable (§5.1). Failing tests are written before implementation code in each phase.
|
||||
|
||||
**Organization**: No setup or foundational phases — this feature modifies three existing files and adds one new test file. Phase 3 (US1) covers the disable path; Phase 4 (US2) verifies the enable/default path using the same implementation; Phase 5 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 3: User Story 1 — Documentation Hidden in Production (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: When `API_DOCS_ENABLED=false`, all three documentation endpoints (`/docs`, `/redoc`, `/openapi.json`) return 404. All other endpoints are unaffected.
|
||||
|
||||
**Independent Test**: `make test-unit` passes the new settings tests; `make test-integration` passes the new `test_docs_disabled` integration test.
|
||||
|
||||
### Tests for User Story 1 (TDD — write first, confirm failure before T003)
|
||||
|
||||
- [X] T001 [US1] Add three failing unit tests to `api/tests/unit/test_config.py` using the existing `_apply_env`/`_BASE_ENV` pattern:
|
||||
1. `test_api_docs_enabled_default` — call `Settings()` with `_BASE_ENV` only (no `API_DOCS_ENABLED`); assert `s.api_docs_enabled is True`
|
||||
2. `test_api_docs_enabled_false` — call `Settings()` with `_BASE_ENV` + `{"API_DOCS_ENABLED": "false"}`; assert `s.api_docs_enabled is False`
|
||||
3. `test_api_docs_invalid_value_defaults_to_enabled` — call `Settings()` with `_BASE_ENV` + `{"API_DOCS_ENABLED": "not-a-bool"}`; assert `s.api_docs_enabled is True` (graceful fallback, FR-007)
|
||||
All three tests fail before T003 because `api_docs_enabled` does not yet exist on `Settings`.
|
||||
|
||||
- [X] T002 [US1] Create `api/tests/integration/test_docs_gate.py` with two failing integration tests; the file MUST set up a minimal app client using `from starlette.testclient import TestClient` and the `importlib.reload` + `get_settings.cache_clear()` pattern shown in plan.md:
|
||||
1. `test_docs_hidden_when_flag_disabled(monkeypatch)` — set `API_DOCS_ENABLED=false` via monkeypatch + all required env vars (`DATABASE_URL`, `JWT_SECRET_KEY`, `OWNER_USERNAME`, `OWNER_PASSWORD`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`); call `get_settings.cache_clear()`; `importlib.reload(app.main)`; create `TestClient(app.main.app)`; assert `/docs` → 404, `/redoc` → 404, `/openapi.json` → 404, `/api/v1/health` → 200; after test, call `get_settings.cache_clear()` again as cleanup
|
||||
2. `test_docs_visible_when_flag_enabled(monkeypatch)` — same setup but with `API_DOCS_ENABLED=true` (or omit it); assert `/docs` → 200, `/redoc` → 200, `/openapi.json` → 200
|
||||
Both tests fail before T003/T004 because `api_docs_enabled` does not exist on `Settings`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T003 [US1] Add `api_docs_enabled: bool = True` field and a `coerce_docs_enabled` field validator to the `Settings` class in `api/app/config.py`: the validator MUST use `mode='before'`, be a `@classmethod`, and wrap Pydantic bool coercion in a try/except that returns `True` on any exception (implements FR-007); import `field_validator` from `pydantic` at the top of the file; the field goes after the existing `login_trusted_proxy_ips` field.
|
||||
|
||||
- [X] T004 [US1] Update `api/app/main.py`: before the `app = FastAPI(...)` call, add `_settings = get_settings()`; add `docs_url="/docs" if _settings.api_docs_enabled else None`, `redoc_url="/redoc" if _settings.api_docs_enabled else None`, and `openapi_url="/openapi.json" if _settings.api_docs_enabled else None` as keyword arguments to the `FastAPI()` constructor; the existing module-level defaults for `app.state` (after the `app = FastAPI(...)` line) are unchanged.
|
||||
|
||||
- [X] T005 [US1] Verify TDD green for US1: run `cd api && python -m pytest tests/unit/ -v -k "docs"` and confirm all three new unit tests pass; then run `cd api && python -m pytest tests/unit/ -v` to confirm no regressions in the full 102-test unit suite.
|
||||
|
||||
**Checkpoint**: US1 is complete. With `API_DOCS_ENABLED=false` the three docs endpoints return 404; all other endpoints are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Documentation Available in Development (Priority: P2)
|
||||
|
||||
**Goal**: Without the flag set (or with it set to `true`), docs endpoints behave identically to before this feature. Default is backwards compatible.
|
||||
|
||||
**Independent Test**: `make test-integration` — the `test_docs_visible_when_flag_enabled` test written in T002 passes, confirming the enabled/default path.
|
||||
|
||||
- [X] T006 [US2] Verify TDD green for US2: run `make test-integration` from `/workspace` and confirm all integration tests pass, including `test_docs_gate.py::test_docs_visible_when_flag_enabled` and the full existing suite (102 tests + 2 new = 104 total).
|
||||
|
||||
**Checkpoint**: Both user stories verified. Flag disabled → 404; flag enabled or absent → unchanged behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T007 Add documentation for `API_DOCS_ENABLED` to `/workspace/.env.example`: insert a new section after the `LOGIN_TRUSTED_PROXY_IPS` block with a comment and `API_DOCS_ENABLED=true`; the comment MUST note that this should be set to `false` in production to avoid publicly exposing the API schema
|
||||
|
||||
- [X] T008 Run `ruff check api/app/config.py api/app/main.py api/tests/integration/test_docs_gate.py` from `/workspace/api` and fix any lint violations; then run `ruff check api/` to confirm the full API directory is clean
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- T001 and T002 can run in parallel (different files, both TDD-red before implementation)
|
||||
- T003 must complete before T004 (main.py reads from config.py)
|
||||
- T005 after T003 and T004
|
||||
- T006 after T005
|
||||
- T007 and T008 can run in parallel (different files, after all tests pass)
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 ∥ T002 (write failing tests — TDD red)
|
||||
Step 2: T003 (implement config.py — turns T001 green)
|
||||
Step 3: T004 (implement main.py — turns T002 green)
|
||||
Step 4: T005 (verify unit tests green)
|
||||
Step 5: T006 (verify integration tests green — regression gate)
|
||||
Step 6: T007 ∥ T008 (polish — .env.example + ruff)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 + US2 — one implementation covers both)
|
||||
|
||||
1. Write failing tests (T001, T002)
|
||||
2. Add `api_docs_enabled` to `config.py` (T003)
|
||||
3. Update `FastAPI()` constructor in `main.py` (T004)
|
||||
4. Verify all tests green (T005, T006)
|
||||
5. Polish (T007, T008)
|
||||
|
||||
US1 and US2 share the same implementation — the flag controls both paths. There is no separate implementation for US2; the default value of `true` is the entire implementation of US2.
|
||||
35
specs/013-k8s-manifests/checklists/requirements.md
Normal file
35
specs/013-k8s-manifests/checklists/requirements.md
Normal 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.
|
||||
59
specs/013-k8s-manifests/contracts/operator-deploy.md
Normal file
59
specs/013-k8s-manifests/contracts/operator-deploy.md
Normal 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
|
||||
```
|
||||
238
specs/013-k8s-manifests/plan.md
Normal file
238
specs/013-k8s-manifests/plan.md
Normal 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 |
|
||||
92
specs/013-k8s-manifests/quickstart.md
Normal file
92
specs/013-k8s-manifests/quickstart.md
Normal 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
|
||||
```
|
||||
63
specs/013-k8s-manifests/research.md
Normal file
63
specs/013-k8s-manifests/research.md
Normal 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.
|
||||
124
specs/013-k8s-manifests/spec.md
Normal file
124
specs/013-k8s-manifests/spec.md
Normal 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).
|
||||
174
specs/013-k8s-manifests/tasks.md
Normal file
174
specs/013-k8s-manifests/tasks.md
Normal 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 3–6 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 T007–T011
|
||||
- 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 T017–T019
|
||||
- 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
|
||||
34
specs/014-r2-cdn-serving/checklists/requirements.md
Normal file
34
specs/014-r2-cdn-serving/checklists/requirements.md
Normal 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`.
|
||||
54
specs/014-r2-cdn-serving/contracts/image-response.md
Normal file
54
specs/014-r2-cdn-serving/contracts/image-response.md
Normal 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.
|
||||
137
specs/014-r2-cdn-serving/plan.md
Normal file
137
specs/014-r2-cdn-serving/plan.md
Normal 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)
|
||||
66
specs/014-r2-cdn-serving/quickstart.md
Normal file
66
specs/014-r2-cdn-serving/quickstart.md
Normal 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.
|
||||
51
specs/014-r2-cdn-serving/research.md
Normal file
51
specs/014-r2-cdn-serving/research.md
Normal 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.
|
||||
93
specs/014-r2-cdn-serving/spec.md
Normal file
93
specs/014-r2-cdn-serving/spec.md
Normal 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
|
||||
116
specs/014-r2-cdn-serving/tasks.md
Normal file
116
specs/014-r2-cdn-serving/tasks.md
Normal 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 T003–T009 (verification requires full implementation)
|
||||
- T011 after T003–T004 (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. T002–T005 — API implementation and tests
|
||||
3. T006–T009 — UI updates
|
||||
4. **STOP and VALIDATE**: `make test-unit && make test-integration`, check browser network panel
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. T001–T005 (API only) → deploy → verify CDN URLs appear in API responses
|
||||
2. T006–T009 (UI) → deploy → verify browser fetches images from CDN
|
||||
3. T010 (local dev verification) → confirm fallback intact
|
||||
4. T011–T012 (polish + end-to-end) → ship
|
||||
34
specs/015-library-pagination/checklists/requirements.md
Normal file
34
specs/015-library-pagination/checklists/requirements.md
Normal 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`.
|
||||
52
specs/015-library-pagination/contracts/pagination-query.md
Normal file
52
specs/015-library-pagination/contracts/pagination-query.md
Normal 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`
|
||||
82
specs/015-library-pagination/plan.md
Normal file
82
specs/015-library-pagination/plan.md
Normal 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)`.
|
||||
41
specs/015-library-pagination/quickstart.md
Normal file
41
specs/015-library-pagination/quickstart.md
Normal 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.
|
||||
33
specs/015-library-pagination/research.md
Normal file
33
specs/015-library-pagination/research.md
Normal 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
|
||||
84
specs/015-library-pagination/spec.md
Normal file
84
specs/015-library-pagination/spec.md
Normal 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.
|
||||
82
specs/015-library-pagination/tasks.md
Normal file
82
specs/015-library-pagination/tasks.md
Normal 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. T001–T002 — 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. T001–T002 (US1) → validate
|
||||
2. T003–T004 (US2) → validate URL state
|
||||
3. T005 (polish) → ship
|
||||
34
specs/016-copy-url-toast/checklists/requirements.md
Normal file
34
specs/016-copy-url-toast/checklists/requirements.md
Normal 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`.
|
||||
50
specs/016-copy-url-toast/contracts/toast-service.md
Normal file
50
specs/016-copy-url-toast/contracts/toast-service.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Contract: ToastService
|
||||
|
||||
**Location**: `ui/src/app/services/toast.service.ts`
|
||||
**Provided in**: `root` (singleton)
|
||||
|
||||
## Interface
|
||||
|
||||
```typescript
|
||||
interface Toast {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
class ToastService {
|
||||
// Observable — emits a Toast when one is active, null when none.
|
||||
readonly current$: Observable<Toast | null>;
|
||||
|
||||
// Show a toast. Replaces any currently-visible toast.
|
||||
// duration defaults to 3000ms.
|
||||
show(message: string, type?: 'success' | 'error', duration?: number): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Behaviour
|
||||
|
||||
- `show()` emits the toast immediately on `current$`.
|
||||
- After `duration` ms, emits `null` to dismiss.
|
||||
- Calling `show()` again before the timer expires resets the timer (new toast replaces old).
|
||||
- `type` defaults to `'success'`.
|
||||
- `duration` defaults to `3000`.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
// In any component:
|
||||
constructor(private toast: ToastService) {}
|
||||
|
||||
async copyUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.toast.show('URL copied!');
|
||||
} catch {
|
||||
this.toast.show('Failed to copy URL', 'error');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Consumer: ToastComponent
|
||||
|
||||
`ToastComponent` subscribes to `current$` via the `async` pipe and renders/hides based on the emitted value. It is placed once in `AppComponent` and is always present in the DOM.
|
||||
74
specs/016-copy-url-toast/plan.md
Normal file
74
specs/016-copy-url-toast/plan.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Implementation Plan: Copy URL & Toast Notifications
|
||||
|
||||
**Branch**: `016-copy-url-toast` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/016-copy-url-toast/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a "Copy URL" button to the image detail page that copies the image's direct file URL to the clipboard, with a reusable toast notification service wired to confirm success or failure. All changes are UI-only; no API changes are required.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript (strict mode), Angular latest stable
|
||||
**Primary Dependencies**: Angular (`@angular/core`, `@angular/common`), RxJS (`BehaviorSubject`), browser Clipboard API (`navigator.clipboard.writeText`)
|
||||
**Storage**: N/A
|
||||
**Testing**: Karma/Jasmine (`ng test`)
|
||||
**Target Platform**: Browser (modern; Clipboard API requires HTTPS — already in place)
|
||||
**Project Type**: Angular standalone SPA
|
||||
**Performance Goals**: Copy action completes in < 100ms perceived latency; toast appears within 300ms of action
|
||||
**Constraints**: TypeScript strict mode, `ChangeDetectionStrategy.OnPush` on all components, no new npm dependencies
|
||||
**Scale/Scope**: Two new files (service + component), two modified files (detail + app component)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.1 Strict separation of concerns | ✓ PASS | Pure UI change; no API knowledge in UI beyond what's already in `ImageRecord.file_url` |
|
||||
| §2.6 No speculative abstraction | ✓ PASS | Toast service is justified: used immediately by this feature and explicitly planned for reuse (upload confirmation, delete confirmation, filter feedback). Two concrete use cases exist. |
|
||||
| §5.1 Tests alongside implementation | ✓ PASS | Tests required for `ToastService` and the copy button on `DetailComponent` |
|
||||
| §5.2 Test pyramid | ✓ PASS | Unit tests only (no API/DB involved); Karma/Jasmine |
|
||||
| §6 Tech stack | ✓ PASS | Angular, TypeScript strict — no new dependencies |
|
||||
| §7.3 Linting | ✓ PASS | `ng lint` must pass before task is done |
|
||||
| §8 Scope boundaries | ✓ PASS | No multi-user, no embeds, no public sharing infrastructure — just a clipboard copy |
|
||||
|
||||
**Post-design re-check**: No violations. Feature is entirely additive.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/016-copy-url-toast/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← Phase 0 output
|
||||
├── quickstart.md ← Phase 1 output
|
||||
├── contracts/
|
||||
│ └── toast-service.md ← Phase 1 output
|
||||
└── tasks.md ← /speckit-tasks output
|
||||
```
|
||||
|
||||
### Source Code
|
||||
|
||||
```text
|
||||
ui/src/app/
|
||||
├── app.component.ts ← modified: add <app-toast> to template
|
||||
├── services/
|
||||
│ └── toast.service.ts ← new: singleton toast service
|
||||
├── toast/
|
||||
│ └── toast.component.ts ← new: toast display component
|
||||
└── detail/
|
||||
└── detail.component.ts ← modified: add Copy URL button + inject ToastService
|
||||
|
||||
ui/src/app/services/
|
||||
toast.service.spec.ts ← new: unit tests for ToastService
|
||||
|
||||
ui/src/app/toast/
|
||||
toast.component.spec.ts ← new: unit tests for ToastComponent
|
||||
|
||||
ui/src/app/detail/
|
||||
detail.component.spec.ts ← modified: tests for copy button behaviour
|
||||
```
|
||||
|
||||
**Structure Decision**: Single-project Angular SPA. Toast service lives in `services/` alongside `ImageService` and `TagService`. Toast component gets its own `toast/` directory following the existing component-per-directory convention.
|
||||
33
specs/016-copy-url-toast/quickstart.md
Normal file
33
specs/016-copy-url-toast/quickstart.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Quickstart: Copy URL & Toast Notifications
|
||||
|
||||
## Happy Path — Copy URL
|
||||
|
||||
1. Open any image detail page (e.g. `http://localhost:4200/images/{id}`).
|
||||
2. Confirm a "Copy URL" button is visible.
|
||||
3. Click "Copy URL".
|
||||
4. Confirm a success toast appears ("URL copied!" or similar) and then disappears automatically.
|
||||
5. Paste into a text editor — confirm the pasted value is the full image file URL.
|
||||
|
||||
## Happy Path — Toast Auto-Dismiss
|
||||
|
||||
1. Click "Copy URL".
|
||||
2. Confirm the toast appears.
|
||||
3. Do not interact — wait ~3 seconds.
|
||||
4. Confirm the toast disappears on its own.
|
||||
|
||||
## Edge Case — Clipboard Unavailable
|
||||
|
||||
1. In Firefox, navigate to `about:config` and set `dom.events.asyncClipboard.clipboardItem` to `false` (or test with a non-HTTPS localhost where clipboard API may be blocked).
|
||||
2. Click "Copy URL".
|
||||
3. Confirm an error toast appears (e.g. "Failed to copy URL") and auto-dismisses.
|
||||
|
||||
## Edge Case — Rapid Clicks
|
||||
|
||||
1. Click "Copy URL" three times quickly.
|
||||
2. Confirm only one toast is visible at a time (new toast replaces old, no overlapping stack).
|
||||
|
||||
## Regression — Other Pages
|
||||
|
||||
1. Navigate to the library (`/`), upload page (`/upload`), tags page (`/tags`).
|
||||
2. Confirm no toast or copy button is visible on these pages.
|
||||
3. Confirm existing functionality is unaffected.
|
||||
55
specs/016-copy-url-toast/research.md
Normal file
55
specs/016-copy-url-toast/research.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Research: Copy URL & Toast Notifications
|
||||
|
||||
## Decision 1: Toast Service Architecture
|
||||
|
||||
**Decision**: `BehaviorSubject<Toast | null>` singleton service, one active toast at a time — new toasts replace the current one.
|
||||
|
||||
**Rationale**: The simplest approach that satisfies FR-007 (reusable from anywhere) and FR-008 (multiple toasts don't overlap illegibly). A queue adds complexity with no meaningful UX benefit for this app's usage pattern (copy URL, upload confirm, etc. — actions that don't overlap in practice). Replacing the current toast on rapid successive calls is acceptable and visually cleaner than a stack. The `BehaviorSubject` integrates naturally with Angular's `async` pipe and OnPush change detection.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `Subject` (not `BehaviorSubject`): Late subscribers miss toasts that already fired. Rejected — component may subscribe after service emits if change detection is deferred.
|
||||
- Toast queue (array): Adds observable complexity and UI layout decisions. Rejected — over-engineered for this use case.
|
||||
- Angular CDK Overlay: Official but heavy. Pulls in CDK dependency for a feature that needs ~30 lines of code. Rejected per §2.6 (no speculative abstraction) and §6 (no new dependencies).
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Clipboard API Usage
|
||||
|
||||
**Decision**: `navigator.clipboard.writeText(url)` — no polyfill, no fallback to `document.execCommand`.
|
||||
|
||||
**Rationale**: `execCommand('copy')` is deprecated and removed in some browsers. The Clipboard API is supported in all modern browsers (Chrome 66+, Firefox 63+, Safari 13.1+). The app already requires HTTPS in production (Let's Encrypt via cert-manager), which satisfies the Clipboard API's secure context requirement. On failure (permission denied, API unavailable), catch the rejected Promise and show an error toast.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `execCommand('copy')` fallback: Deprecated, inconsistent, adds code complexity. The failure path (error toast) covers the rare unavailability case more cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: What URL to Copy
|
||||
|
||||
**Decision**: Copy `image.file_url` as-is (the direct image file URL).
|
||||
|
||||
**Rationale**: `file_url` is the CDN URL in production (e.g. `https://cdn.reactbin.juggalol.com/…`) — already absolute. In development it is relative (`/api/v1/images/{id}/file`); for dev use, prepend `window.location.origin`. The direct file URL is the right thing to share for a reaction image library: it embeds inline when pasted into Discord/Slack without requiring a click-through.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Detail page URL (`/images/{id}`): The user can already copy this from the browser address bar. The file URL is the value-add.
|
||||
- Always prepend `window.location.origin`: Works for both environments, adds a guard. Included as a defensive measure for the dev case.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: Toast Positioning
|
||||
|
||||
**Decision**: Fixed position, bottom-center of the viewport.
|
||||
|
||||
**Rationale**: Bottom-center is less intrusive than top-right for a brief confirmation toast. It doesn't overlap the image or the copy button. `pointer-events: none` ensures it never blocks interaction.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Top-right: Common convention (Material, Bootstrap) but overlaps the header/nav area in this layout.
|
||||
- Top-center: Similar issue.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: OnPush compatibility
|
||||
|
||||
**Decision**: `ToastComponent` uses `ChangeDetectionStrategy.OnPush` with the `async` pipe consuming `toastService.current$`. Angular's `async` pipe calls `markForCheck()` automatically when the observable emits, making it fully compatible with OnPush.
|
||||
|
||||
**Rationale**: Consistent with all other components in the project. No manual `markForCheck()` calls needed in `ToastComponent`.
|
||||
76
specs/016-copy-url-toast/spec.md
Normal file
76
specs/016-copy-url-toast/spec.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Feature Specification: Copy URL & Toast Notifications
|
||||
|
||||
**Feature Branch**: `016-copy-url-toast`
|
||||
**Created**: 2026-05-09
|
||||
**Status**: Draft
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Copy Image URL from Detail Page (Priority: P1)
|
||||
|
||||
A user viewing an image on the detail page wants to share the direct link to that image. They click a "Copy URL" button and the image's direct URL is instantly copied to their clipboard, ready to paste anywhere.
|
||||
|
||||
**Why this priority**: This is the core feature and the primary user value. Everything else builds on it.
|
||||
|
||||
**Independent Test**: Open any image detail page. Click the "Copy URL" button. Paste into a text editor — confirm the pasted value is the direct URL to that image.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is on an image detail page, **When** they click "Copy URL", **Then** the image's direct URL is copied to their clipboard.
|
||||
2. **Given** a user clicks "Copy URL", **When** the copy succeeds, **Then** a confirmation toast appears briefly and disappears on its own.
|
||||
3. **Given** a user clicks "Copy URL", **When** the clipboard is unavailable (e.g. browser denies permission), **Then** a toast appears indicating the copy failed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Reusable Toast Notification System (Priority: P2)
|
||||
|
||||
Any part of the application can trigger a brief, non-blocking notification (toast) to confirm an action or surface an error. The toast appears, persists for a short time, then disappears automatically without user interaction.
|
||||
|
||||
**Why this priority**: The toast infrastructure is needed by US1 and is designed as a foundation for future features (e.g. upload confirmation, filter saved, delete confirmed).
|
||||
|
||||
**Independent Test**: Trigger a toast programmatically. Confirm it appears with the correct message, then disappears automatically after a few seconds without any user interaction.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a toast is triggered, **When** it appears, **Then** it displays the provided message and is visible above other content.
|
||||
2. **Given** a toast is visible, **When** sufficient time passes, **Then** it disappears automatically without user interaction.
|
||||
3. **Given** multiple toasts are triggered in quick succession, **When** they appear, **Then** they stack or queue without overlapping illegibly.
|
||||
4. **Given** a toast is visible, **When** the user interacts with the rest of the page, **Then** the toast does not block or intercept those interactions.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the clipboard API is not available or permission is denied? → Show an error toast.
|
||||
- What happens if the user clicks "Copy URL" multiple times rapidly? → Each click copies and shows a toast; toasts queue or stack cleanly.
|
||||
- What happens on a very long URL? → URL is copied in full; toast message is fixed (not the URL itself).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The image detail page MUST display a "Copy URL" button.
|
||||
- **FR-002**: Clicking "Copy URL" MUST copy the image's direct URL to the system clipboard.
|
||||
- **FR-003**: A success toast MUST appear after a successful copy, confirming the action to the user.
|
||||
- **FR-004**: A failure toast MUST appear if the copy cannot be completed (e.g. clipboard permission denied).
|
||||
- **FR-005**: Toasts MUST disappear automatically after a fixed duration without requiring user interaction.
|
||||
- **FR-006**: Toasts MUST NOT block user interaction with the rest of the page.
|
||||
- **FR-007**: The toast system MUST be reusable — any part of the application must be able to trigger a toast with a custom message.
|
||||
- **FR-008**: Multiple toasts triggered in quick succession MUST display without overlapping illegibly.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A user can copy an image URL in a single click with no additional steps.
|
||||
- **SC-002**: Toast confirmation appears within 300ms of the copy action completing.
|
||||
- **SC-003**: Toasts disappear automatically within 5 seconds of appearing.
|
||||
- **SC-004**: The toast system can be triggered from any page or component without modifying the toast component itself.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The image's direct URL is already available on the detail page (it is — currently displayed or derivable from the current route and API response).
|
||||
- Users are on modern browsers with Clipboard API support; graceful degradation covers the failure case via an error toast.
|
||||
- One toast variant is sufficient for v1: a simple text message with success/error styling. No actions, no dismiss button required.
|
||||
- Toast duration of approximately 3 seconds is appropriate (standard convention).
|
||||
- The detail page already exists; this feature adds to it without redesigning it.
|
||||
94
specs/016-copy-url-toast/tasks.md
Normal file
94
specs/016-copy-url-toast/tasks.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Tasks: Copy URL & Toast Notifications
|
||||
|
||||
**Input**: Design documents from `specs/016-copy-url-toast/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/toast-service.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/`.
|
||||
|
||||
**Organization**: No project setup needed — Angular project exists. The toast infrastructure (US2) must be built before the copy URL feature (US1) can use it, so phases follow implementation dependency order rather than spec priority order.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Toast Infrastructure — User Story 2 (Foundational)
|
||||
|
||||
**Goal**: Build the reusable `ToastService` and `ToastComponent` that US1 and all future features depend on.
|
||||
|
||||
**Independent Test**: In the browser console, call `toastService.show('Hello!')` from any page — confirm a toast appears at the bottom of the screen for ~3 seconds then disappears. Confirm no interaction with the rest of the UI is blocked.
|
||||
|
||||
- [X] T001 [US2] Write tests in `ui/src/app/services/toast.service.spec.ts` covering: (1) `show()` emits a `Toast` object on `current$` with the correct `message` and `type`; (2) after the duration elapses, `current$` emits `null`; (3) `type` defaults to `'success'` when not provided; (4) calling `show()` a second time before the first timer fires replaces the active toast. Run `ng test` and confirm new tests FAIL.
|
||||
|
||||
- [X] T002 [US2] Create `ui/src/app/services/toast.service.ts`: (a) define `export interface Toast { message: string; type: 'success' | 'error'; }`; (b) `@Injectable({ providedIn: 'root' })` class with a private `BehaviorSubject<Toast | null>(null)`; (c) expose `readonly current$: Observable<Toast | null>` from the subject; (d) implement `show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void` — emits the toast immediately, then calls `setTimeout(() => this.subject.next(null), duration)` (store the timer handle and `clearTimeout` it at the start of `show()` so rapid calls replace correctly). Run `ng test` and confirm T001 tests pass.
|
||||
|
||||
- [X] T003 [P] [US2] Write tests in `ui/src/app/toast/toast.component.spec.ts` covering: (1) when `ToastService.current$` emits a `{ message: 'Done', type: 'success' }` toast, a `.toast` element is rendered containing "Done"; (2) the element has the CSS class `success`; (3) when type is `'error'`, the element has class `error`; (4) when `current$` emits `null`, no `.toast` element is present. Run `ng test` and confirm new tests FAIL.
|
||||
|
||||
- [X] T004 [US2] Create `ui/src/app/toast/toast.component.ts`: (a) standalone component, `selector: 'app-toast'`, `ChangeDetectionStrategy.OnPush`, imports `[CommonModule]`; (b) inject `ToastService` as public; (c) template: `<div *ngIf="toastService.current$ | async as toast" class="toast" [class.success]="toast.type === 'success'" [class.error]="toast.type === 'error'">{{ toast.message }}</div>`; (d) styles: `.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius); font-size: 0.9rem; pointer-events: none; z-index: 1000; white-space: nowrap; }` with `.success { background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); }` and `.error { background: var(--danger); color: var(--danger-text); }`. Run `ng test` and confirm T003 tests pass.
|
||||
|
||||
- [X] T005 [US2] Register `ToastComponent` in `ui/src/app/app.component.ts`: add `ToastComponent` to the `imports` array; add `<app-toast></app-toast>` to the template after `<router-outlet />`; add a test to `ui/src/app/app.component.spec.ts` asserting that an `app-toast` element is present in the rendered output. Confirm all existing AppComponent tests still pass and `ng build --configuration development` succeeds.
|
||||
|
||||
**Checkpoint**: Toast infrastructure complete. Any component in the app can now inject `ToastService` and call `show()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Copy URL Button — User Story 1 (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Add a "Copy URL" button to the image detail page. One click copies the direct image file URL to the clipboard and shows a confirmation toast.
|
||||
|
||||
**Independent Test**: Open any image detail page. Click "Copy URL". Confirm a success toast appears. Paste into a text editor and confirm the pasted value is the full image file URL. Then simulate a clipboard failure (e.g. revoke clipboard permission) and confirm an error toast appears instead.
|
||||
|
||||
- [X] T006 [US1] Write tests in `ui/src/app/detail/detail.component.spec.ts` covering: (1) a "Copy URL" button (`.copy-url-btn`) is present in the DOM when an image is loaded; (2) clicking it calls `navigator.clipboard.writeText` with the image's `file_url` when `file_url` is already absolute (starts with `http`); (3) when `file_url` is relative (starts with `/`), `writeText` is called with `window.location.origin + file_url`; (4) when `writeText` resolves, `toastService.show` is called with a success message; (5) when `writeText` rejects, `toastService.show` is called with an error message and type `'error'`. Spy on `navigator.clipboard.writeText` using `spyOn(navigator.clipboard, 'writeText')` returning `Promise.resolve()` / `Promise.reject()` as appropriate. Run `ng test` and confirm new tests FAIL.
|
||||
|
||||
- [X] T007 [US1] Update `ui/src/app/detail/detail.component.ts`: (a) inject `ToastService` (add to constructor); (b) add `copyUrl(): void` method — resolves the URL as `this.image!.file_url.startsWith('http') ? this.image!.file_url : window.location.origin + this.image!.file_url`, then calls `navigator.clipboard.writeText(url).then(() => this.toastService.show('URL copied!')).catch(() => this.toastService.show('Failed to copy URL', 'error'))`; (c) add a `<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>` to the template inside the `*ngIf="image && !loading"` block, placed below the image and above the tags section; (d) style: `padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0;` with hover `border-color: var(--border-focus)`. Run `ng test` and confirm T006 tests pass.
|
||||
|
||||
**Checkpoint**: US1 complete. Detail page has a working Copy URL button with toast feedback.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T008 Run `ng lint` on all modified and created files in `ui/src/app/`; fix any issues. Confirm `ng test` passes with all new and existing tests green. Manually verify all `quickstart.md` scenarios in a browser: happy path copy, auto-dismiss, clipboard error, rapid clicks, regression on other pages.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- T001 before T002 (write failing tests before service implementation)
|
||||
- T002 before T003/T004 (service must exist for component tests to import it)
|
||||
- T003 before T004 (write failing tests before component implementation)
|
||||
- T004 before T005 (component must exist before registering in app)
|
||||
- T005 before T006 (toast infrastructure must be complete before copy URL tests)
|
||||
- T006 before T007 (write failing tests before detail component changes)
|
||||
- T007 before T008 (implementation before polish)
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 (US2: failing ToastService tests)
|
||||
Step 2: T002 (US2: ToastService implementation — tests turn green)
|
||||
Step 3: T003 (US2: failing ToastComponent tests) [can parallel with T002 if needed]
|
||||
Step 4: T004 (US2: ToastComponent implementation — tests turn green)
|
||||
Step 5: T005 (US2: wire ToastComponent into AppComponent)
|
||||
Step 6: T006 (US1: failing copy URL tests)
|
||||
Step 7: T007 (US1: copy URL implementation — tests turn green)
|
||||
Step 8: T008 (Polish: lint + manual verification)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 — single story delivers full feature value)
|
||||
|
||||
1. T001–T005 — toast infrastructure
|
||||
2. T006–T007 — copy URL button
|
||||
3. **STOP and VALIDATE**: open browser, click Copy URL, confirm toast, paste to verify URL
|
||||
4. T008 — polish
|
||||
5. Deploy
|
||||
|
||||
### Note on Priority Ordering
|
||||
|
||||
US2 (toast system) is listed as P2 in the spec because it is infrastructure rather than the end-user-visible feature. However it is a hard implementation prerequisite for US1 (P1). Phases follow implementation dependency order: US2 infrastructure is built first, US1 feature consumes it second.
|
||||
34
specs/017-short-id-migration/checklists/requirements.md
Normal file
34
specs/017-short-id-migration/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Short Image IDs
|
||||
|
||||
**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.
|
||||
115
specs/017-short-id-migration/contracts/image-api.md
Normal file
115
specs/017-short-id-migration/contracts/image-api.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Contract: Image API (Short ID Update)
|
||||
|
||||
## ImageRecord Response Schema
|
||||
|
||||
All image endpoints return this shape. `short_id` is a new field; all other fields are unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "7343d164-80bb-473b-b239-717f2842ae4e",
|
||||
"short_id": "xK7mN2pQ",
|
||||
"hash": "163dec08460650439f1e7439721e8e566aff7d8aaad60cf451e7d3518a334a23",
|
||||
"filename": "image.gif",
|
||||
"mime_type": "image/gif",
|
||||
"size_bytes": 1957149,
|
||||
"width": 265,
|
||||
"height": 199,
|
||||
"storage_key": "xK7mN2pQ",
|
||||
"thumbnail_key": "xK7mN2pQ-thumb",
|
||||
"file_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ",
|
||||
"thumbnail_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ-thumb",
|
||||
"created_at": "2026-05-09T02:46:29.520296+00:00",
|
||||
"tags": ["kfc"]
|
||||
}
|
||||
```
|
||||
|
||||
**Constraints**:
|
||||
- `short_id`: exactly 8 alphanumeric characters `[a-zA-Z0-9]{8}`
|
||||
- `storage_key`: equals `short_id` (post-migration)
|
||||
- `thumbnail_key`: equals `{short_id}-thumb` or `null` if no thumbnail exists
|
||||
- `file_url`: `{cdn_base}/{short_id}` when CDN is configured; `/api/v1/images/{short_id}/file` otherwise
|
||||
- `thumbnail_url`: `{cdn_base}/{short_id}-thumb` or `null`
|
||||
|
||||
---
|
||||
|
||||
## Route Changes
|
||||
|
||||
All routes that previously accepted `{image_id}` as a UUID now accept `{short_id}` as an 8-character alphanumeric string.
|
||||
|
||||
### GET /api/v1/images/{short_id}
|
||||
|
||||
Fetch a single image by short ID.
|
||||
|
||||
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||
- **Response 200**: ImageRecord
|
||||
- **Response 404**: `{"detail": "Image not found", "code": "image_not_found"}`
|
||||
- **Response 422**: `{"detail": "Invalid image ID", "code": "invalid_short_id"}` if param is not 8 alphanumeric chars
|
||||
|
||||
### PATCH /api/v1/images/{short_id}/tags
|
||||
|
||||
Update tags on an image. Auth required.
|
||||
|
||||
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||
- **Body**: `{"tags": ["tag1", "tag2"]}`
|
||||
- **Response 200**: ImageRecord (updated)
|
||||
- **Response 404/422**: same shape as above
|
||||
|
||||
### DELETE /api/v1/images/{short_id}
|
||||
|
||||
Delete an image and its storage objects. Auth required.
|
||||
|
||||
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||
- **Response 204**: no body
|
||||
- **Response 404**: error envelope
|
||||
|
||||
### GET /api/v1/images/{short_id}/file
|
||||
|
||||
Serve the raw image file (proxy mode, when CDN is not configured).
|
||||
|
||||
- **Path param**: `short_id`
|
||||
- **Response 200**: raw image bytes with correct `Content-Type`
|
||||
|
||||
### GET /api/v1/images/{short_id}/thumbnail
|
||||
|
||||
Serve the thumbnail (proxy mode).
|
||||
|
||||
- **Path param**: `short_id`
|
||||
- **Response 200**: WebP bytes or original image if no thumbnail
|
||||
|
||||
### POST /api/v1/images (upload — unchanged route, updated response)
|
||||
|
||||
- **Response**: ImageRecord with `short_id` populated
|
||||
|
||||
---
|
||||
|
||||
## Frontend Route Change
|
||||
|
||||
| Old route | New route |
|
||||
|-----------------|--------------|
|
||||
| `/images/:id` | `/i/:id` |
|
||||
|
||||
The `:id` segment now contains the `short_id` value (8 alphanumeric chars) rather than a UUID.
|
||||
|
||||
---
|
||||
|
||||
## ImageRecord TypeScript Interface (updated)
|
||||
|
||||
```typescript
|
||||
export interface ImageRecord {
|
||||
id: string; // UUID — retained, not used for routing
|
||||
short_id: string; // NEW — 8-char base62, used for all routing and API calls
|
||||
hash: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
storage_key: string;
|
||||
thumbnail_key: string | null;
|
||||
file_url: string;
|
||||
thumbnail_url: string | null;
|
||||
created_at: string;
|
||||
tags: string[];
|
||||
duplicate?: boolean;
|
||||
}
|
||||
```
|
||||
77
specs/017-short-id-migration/data-model.md
Normal file
77
specs/017-short-id-migration/data-model.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Data Model: Short Image IDs
|
||||
|
||||
## Changed Entity: Image
|
||||
|
||||
### New Column
|
||||
|
||||
| Column | Type | Constraints | Notes |
|
||||
|------------|--------------|------------------------------|-------------------------------------------|
|
||||
| `short_id` | VARCHAR(8) | UNIQUE, NOT NULL (post-migration), INDEX | Base62 alphanumeric, 8 characters |
|
||||
|
||||
### Updated Columns (values change, types unchanged)
|
||||
|
||||
| Column | Old values | New values |
|
||||
|-----------------|-----------------------------------------|-----------------------------------|
|
||||
| `storage_key` | SHA-256 hash (64 hex chars) | short_id (8 base62 chars) |
|
||||
| `thumbnail_key` | `{hash}-thumb` (69 chars) | `{short_id}-thumb` (13 chars) |
|
||||
|
||||
### Unchanged Columns
|
||||
|
||||
| Column | Notes |
|
||||
|------------|-----------------------------------------------------------------------|
|
||||
| `id` | UUID primary key — unchanged, retained as internal identifier |
|
||||
| `hash` | SHA-256 content hash — unchanged, still used for deduplication |
|
||||
| `filename` | Unchanged |
|
||||
| `mime_type`| Unchanged |
|
||||
| `size_bytes`, `width`, `height` | Unchanged |
|
||||
| `created_at` | Unchanged |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `short_id`: exactly 8 characters, matching `[a-zA-Z0-9]{8}` — generated on insert, never updated
|
||||
- `short_id` must be unique across all image records
|
||||
- On collision (rare), a new value is generated and retried (up to 10 attempts)
|
||||
|
||||
---
|
||||
|
||||
## Alembic Migrations
|
||||
|
||||
### Migration 003 — Add `short_id` column (nullable)
|
||||
|
||||
```
|
||||
ALTER TABLE images ADD COLUMN short_id VARCHAR(8) NULL;
|
||||
CREATE UNIQUE INDEX ix_images_short_id ON images (short_id);
|
||||
```
|
||||
|
||||
Run immediately on deploy. Existing rows get `short_id = NULL`. New uploads will populate `short_id` on insert (application-level).
|
||||
|
||||
### Migration Script — Backfill existing rows
|
||||
|
||||
`api/scripts/migrate_to_short_ids.py`
|
||||
|
||||
For each image where `short_id IS NULL`:
|
||||
1. Generate 8-char base62 short_id (retry on collision)
|
||||
2. Copy storage object: `{hash}` → `{short_id}` (S3 copy)
|
||||
3. Copy thumbnail if present: `{hash}-thumb` → `{short_id}-thumb`
|
||||
4. Verify new objects exist (S3 head_object)
|
||||
5. Update DB row: `short_id = {short_id}`, `storage_key = {short_id}`, `thumbnail_key = {short_id}-thumb` (or NULL)
|
||||
6. Delete old storage objects
|
||||
|
||||
### Migration 004 — Add NOT NULL constraint
|
||||
|
||||
```
|
||||
ALTER TABLE images ALTER COLUMN short_id SET NOT NULL;
|
||||
```
|
||||
|
||||
Run only after the migration script completes successfully with zero `short_id IS NULL` rows remaining.
|
||||
|
||||
---
|
||||
|
||||
## Storage Object Naming Convention
|
||||
|
||||
| Object type | Key pattern | Example |
|
||||
|-------------|---------------------|-------------------|
|
||||
| Original | `{short_id}` | `xK7mN2pQ` |
|
||||
| Thumbnail | `{short_id}-thumb` | `xK7mN2pQ-thumb` |
|
||||
|
||||
No folder structure. Flat bucket layout (unchanged from current convention).
|
||||
198
specs/017-short-id-migration/plan.md
Normal file
198
specs/017-short-id-migration/plan.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Implementation Plan: Short Image IDs
|
||||
|
||||
**Branch**: `017-short-id-migration` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/017-short-id-migration/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Replace hash-based storage keys and UUID-based URL routing with 8-character base62 short IDs. The short ID becomes the canonical identifier in URLs (`/i/:short_id`), storage keys (`{short_id}` / `{short_id}-thumb`), and all API responses. Hash-based deduplication is preserved unchanged. A Python migration script handles existing images: generates short IDs, copies storage objects to new keys, updates DB records, deletes old keys.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12+ (API), TypeScript strict (UI)
|
||||
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Alembic, aiobotocore/boto3, Angular (latest stable)
|
||||
**Storage**: PostgreSQL (DB), S3-compatible via boto3 (MinIO local / CDN in prod)
|
||||
**Testing**: pytest + pytest-asyncio (API unit + integration), Karma/Jasmine (Angular)
|
||||
**Target Platform**: Linux server (k3s), browser SPA
|
||||
**Project Type**: Web application (FastAPI API + Angular SPA)
|
||||
**Performance Goals**: Migration script should process all existing images without timeout; no user-facing performance change
|
||||
**Constraints**: Migration must be idempotent; no data loss; copy-before-delete for all storage operations
|
||||
**Scale/Scope**: Personal collection (~hundreds to low thousands of images); collision probability negligible
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.5 DB abstraction — all queries through repos | ✅ PASS | New `get_by_short_id()` added to `ImageRepository`; no raw SQL outside repo |
|
||||
| §2.6 No speculative abstraction | ✅ PASS | `generate_short_id()` is a concrete utility; no new interfaces |
|
||||
| §3.1 Routes prefixed `/api/v1/` | ✅ PASS | All routes remain under `/api/v1/images/` |
|
||||
| §3.1 Adding fields is non-breaking | ✅ PASS | `short_id` is additive; `id` UUID retained |
|
||||
| §4.2 Images immutable after upload | ✅ PASS | File content is copied, not replaced; the operation changes the storage key, not the bytes |
|
||||
| §4.3 Deduplication by content hash | ✅ PASS | `hash` column retained; `get_by_hash` unchanged |
|
||||
| §5.1 Tests alongside every implementation task | ✅ PASS | Each task includes tests |
|
||||
| §5.2 Integration tests use real PostgreSQL + MinIO | ✅ PASS | Existing integration test infrastructure reused |
|
||||
| §8 Scope boundaries | ✅ PASS | No multi-user, no public sharing feature, no OR/NOT tag logic |
|
||||
|
||||
**No violations. Implementation may proceed.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/017-short-id-migration/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← short ID generation, migration strategy
|
||||
├── data-model.md ← Image schema changes, Alembic migrations
|
||||
├── contracts/
|
||||
│ └── image-api.md ← updated ImageRecord schema, route changes
|
||||
├── quickstart.md ← manual test scenarios
|
||||
└── tasks.md ← generated by /speckit-tasks
|
||||
```
|
||||
|
||||
### Source Code Changes
|
||||
|
||||
```text
|
||||
api/
|
||||
├── app/
|
||||
│ ├── models.py # Add Image.short_id column
|
||||
│ ├── utils.py # Add generate_short_id()
|
||||
│ ├── repositories/
|
||||
│ │ └── image_repo.py # Add get_by_short_id(), update create()
|
||||
│ └── routers/
|
||||
│ └── images.py # Path params uuid→str, add short_id to response
|
||||
├── alembic/versions/
|
||||
│ ├── 003_add_short_id.py # ADD COLUMN short_id VARCHAR(8) NULLABLE UNIQUE
|
||||
│ └── 004_short_id_not_null.py # SET NOT NULL (run after migration script)
|
||||
├── scripts/
|
||||
│ └── migrate_to_short_ids.py # Backfill existing images
|
||||
└── tests/
|
||||
├── unit/
|
||||
│ ├── test_hashing.py # Add generate_short_id() tests
|
||||
│ ├── test_url_construction.py # Update mock images to include short_id
|
||||
│ └── test_short_id.py # NEW: collision retry, charset validation
|
||||
└── integration/
|
||||
├── test_upload.py # Assert short_id in response
|
||||
├── test_search.py # Update {id} → {short_id} in route calls
|
||||
├── test_delete.py # Update route params
|
||||
├── test_serving.py # Update route params
|
||||
└── test_tags.py # Update route params
|
||||
|
||||
ui/src/app/
|
||||
├── app.routes.ts # 'images/:id' → 'i/:id'
|
||||
├── services/
|
||||
│ └── image.service.ts # Add short_id to ImageRecord, update service calls
|
||||
├── library/
|
||||
│ └── library.component.ts # Navigate to ['/i', img.short_id]
|
||||
├── upload/
|
||||
│ └── upload.component.ts # Navigate to ['/i', res.short_id] after upload
|
||||
└── detail/
|
||||
└── detail.component.ts # (no route change needed; reads :id param same way)
|
||||
```
|
||||
|
||||
**Structure Decision**: Existing web application layout. API changes are concentrated in models, repository, router, and a new migration script. UI changes are confined to routes, image service interface, and two navigation calls.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Backend — Short ID Infrastructure
|
||||
|
||||
1. Add `generate_short_id()` to `api/app/utils.py`
|
||||
- Base62 charset: `string.ascii_letters + string.digits`
|
||||
- Uses `secrets.choice` for cryptographic randomness
|
||||
- Returns 8-character string
|
||||
|
||||
2. Add Alembic migration `003_add_short_id.py`
|
||||
- `ADD COLUMN short_id VARCHAR(8) NULL`
|
||||
- `CREATE UNIQUE INDEX ix_images_short_id ON images (short_id)`
|
||||
|
||||
3. Update `api/app/models.py`
|
||||
- Add `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`
|
||||
|
||||
4. Update `api/app/repositories/image_repo.py`
|
||||
- Add `get_by_short_id(short_id: str) -> Image | None`
|
||||
- Update `create()` to accept and persist `short_id` parameter
|
||||
|
||||
5. Update `api/app/routers/images.py`
|
||||
- Change all `image_id: uuid.UUID` path params to `short_id: str`
|
||||
- Add `_validate_short_id(short_id: str)` helper (8 alphanumeric chars, else 422)
|
||||
- Replace `get_by_id` calls with `get_by_short_id`
|
||||
- Update `_image_to_dict` to include `"short_id": image.short_id` in response
|
||||
- Update upload handler: generate `short_id` with collision retry, use as storage key
|
||||
|
||||
### Phase 2: Migration Script
|
||||
|
||||
`api/scripts/migrate_to_short_ids.py`:
|
||||
|
||||
```
|
||||
for each image where short_id IS NULL:
|
||||
generate short_id (retry on DB collision)
|
||||
copy {hash} → {short_id} in storage
|
||||
if thumbnail_key IS NOT NULL:
|
||||
copy {hash}-thumb → {short_id}-thumb in storage
|
||||
verify new objects exist (head_object)
|
||||
UPDATE images SET short_id={sid}, storage_key={sid}, thumbnail_key={sid}-thumb WHERE id={id}
|
||||
delete {hash} from storage
|
||||
if thumbnail_key was not null:
|
||||
delete {hash}-thumb from storage
|
||||
log: "migrated {id} → {short_id}"
|
||||
|
||||
print summary: N migrated, M skipped (already had short_id)
|
||||
```
|
||||
|
||||
After script runs with 0 remaining `NULL` short_ids, apply migration `004_short_id_not_null.py`.
|
||||
|
||||
### Phase 3: Frontend
|
||||
|
||||
1. `app.routes.ts`: `path: 'images/:id'` → `path: 'i/:id'`
|
||||
2. `image.service.ts`: add `short_id: string` to `ImageRecord`
|
||||
3. `library.component.ts`: `router.navigate(['/images', img.id])` → `router.navigate(['/i', img.short_id])`
|
||||
4. `upload.component.ts`: `router.navigate(['/images', res.id])` → `router.navigate(['/i', res.short_id])`
|
||||
|
||||
### Phase 4: Polish
|
||||
|
||||
- Update all existing API integration tests to use `short_id` in route paths
|
||||
- Run `ng lint` and `ruff check` across modified files
|
||||
- Verify `ng build --configuration production` succeeds
|
||||
- Run full test suites: `make test-unit && make test-integration`
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
### Collision Retry Pattern (upload)
|
||||
|
||||
```python
|
||||
MAX_RETRIES = 10
|
||||
for attempt in range(MAX_RETRIES):
|
||||
short_id = generate_short_id()
|
||||
try:
|
||||
image = await image_repo.create(..., short_id=short_id)
|
||||
break
|
||||
except IntegrityError: # short_id collision
|
||||
await db.rollback()
|
||||
if attempt == MAX_RETRIES - 1:
|
||||
raise RuntimeError("Could not generate unique short_id")
|
||||
```
|
||||
|
||||
### Route Validation
|
||||
|
||||
```python
|
||||
import re
|
||||
_SHORT_ID_RE = re.compile(r'^[a-zA-Z0-9]{8}$')
|
||||
|
||||
def _validate_short_id(short_id: str) -> None:
|
||||
if not _SHORT_ID_RE.match(short_id):
|
||||
raise HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})
|
||||
```
|
||||
|
||||
### `_image_to_dict` Update
|
||||
|
||||
Add `"short_id": image.short_id` to the returned dict. The `file_url` and `thumbnail_url` generation already uses `image.storage_key` which will now equal `image.short_id` — no formula change needed.
|
||||
|
||||
### Migration Script Entry Point
|
||||
|
||||
```bash
|
||||
cd api && python -m scripts.migrate_to_short_ids
|
||||
```
|
||||
|
||||
Reads DB URL and storage config from environment variables (same as the application).
|
||||
73
specs/017-short-id-migration/quickstart.md
Normal file
73
specs/017-short-id-migration/quickstart.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Quickstart: Short Image IDs
|
||||
|
||||
## Scenario 1 — Happy Path: New Upload Gets Short ID
|
||||
|
||||
1. Log in and navigate to Upload.
|
||||
2. Upload any image.
|
||||
3. Observe: browser navigates to `/i/AbCdEfGh` (8-char short ID, not a UUID).
|
||||
4. Copy the URL from the address bar and paste in a new tab — image loads correctly.
|
||||
5. Open the URL in a private/incognito window (not logged in) — image still loads.
|
||||
|
||||
**Pass criteria**: URL is `/i/{8 alphanumeric chars}`, image loads authenticated and unauthenticated.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 2 — Deduplication Still Works
|
||||
|
||||
1. Upload any image — note the short ID in the URL.
|
||||
2. Upload the exact same file again.
|
||||
3. Observe: API returns `duplicate: true`, browser navigates to the same short ID URL as step 1.
|
||||
|
||||
**Pass criteria**: No second record created, same short ID returned.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 3 — Library Navigation Uses Short IDs
|
||||
|
||||
1. Open the library (`/`).
|
||||
2. Click any image card.
|
||||
3. Observe: navigated to `/i/{short_id}`, not `/images/{uuid}`.
|
||||
|
||||
**Pass criteria**: All image card clicks navigate to `/i/` routes.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 4 — Tag and Delete Operations Work via Short ID
|
||||
|
||||
1. Open an image detail page at `/i/{short_id}`.
|
||||
2. If logged in: add a tag, remove a tag — confirm both succeed.
|
||||
3. If logged in: delete the image — confirm navigates back to library, image no longer appears.
|
||||
|
||||
**Pass criteria**: Tag updates and delete work correctly when the route uses a short ID.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 5 — Migration: All Existing Images Accessible
|
||||
|
||||
1. After running the migration script: open the library.
|
||||
2. Click through several images from before the migration.
|
||||
3. Observe: all navigate to `/i/{short_id}` URLs, all images and thumbnails load.
|
||||
4. No broken image placeholders visible.
|
||||
|
||||
**Pass criteria**: 100% of pre-migration images accessible via short ID with no broken assets.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 6 — Migration Script Is Idempotent
|
||||
|
||||
1. Run the migration script once — note how many images were migrated.
|
||||
2. Run the migration script a second time.
|
||||
3. Observe: script reports 0 images migrated (all already have short IDs), exits cleanly.
|
||||
|
||||
**Pass criteria**: Second run produces no DB changes, no storage operations, no errors.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 7 — Copy URL Button Copies Short Page URL
|
||||
|
||||
1. Open any image detail page at `/i/{short_id}`.
|
||||
2. Click "Copy URL".
|
||||
3. Paste into a text editor.
|
||||
4. Observe: pasted value is the CDN file URL (e.g. `https://cdn.reactbin.juggalol.com/xK7mN2pQ`), not a UUID-based URL.
|
||||
|
||||
**Pass criteria**: Copied URL contains the short_id, not a UUID.
|
||||
56
specs/017-short-id-migration/research.md
Normal file
56
specs/017-short-id-migration/research.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Research: Short Image IDs
|
||||
|
||||
## Short ID Generation
|
||||
|
||||
**Decision**: Use `secrets.choice` over `string.ascii_letters + string.digits` (base62, 62 characters), 8 characters long.
|
||||
|
||||
**Rationale**: `secrets.choice` is cryptographically random, eliminating any bias from modular reduction that affects simpler approaches. Base62 (a–z, A–Z, 0–9) is URL-safe without percent-encoding. 8 characters gives 62⁸ ≈ 218 trillion combinations — negligible collision probability even at millions of images.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `secrets.token_urlsafe(6)` — includes `-` and `_`, not pure alphanumeric
|
||||
- UUID truncation (first 8 chars of hex) — only 16 chars of alphabet (hex), dramatically fewer combinations than base62
|
||||
- nanoid (npm) — JavaScript library, requires a separate dependency for Python
|
||||
|
||||
**Collision retry**: On insert, if a `UniqueConstraint` violation is raised on `short_id`, generate a new one and retry (up to a configurable limit, e.g., 10 attempts). At 10,000 images the per-attempt collision probability is ~4.6 × 10⁻¹¹; retries are a pure safety measure.
|
||||
|
||||
---
|
||||
|
||||
## Alembic Two-Phase Migration Strategy
|
||||
|
||||
**Decision**: Two separate Alembic migrations (003 + 004), with the Python migration script run between them.
|
||||
|
||||
**Rationale**: The `short_id` column must start nullable so existing rows can be inserted without a value. The migration script fills all existing rows. Once confirmed, a second migration adds the NOT NULL constraint. Running both as one migration would require a complex inline Python script in Alembic (fragile, untestable). Two migrations with a script in between is the standard approach for backfill + constraint change.
|
||||
|
||||
**Migration 003**: `ADD COLUMN short_id VARCHAR(8) NULL UNIQUE` + GiST/B-tree index.
|
||||
**Script**: Fill all rows, idempotent (skip rows where `short_id IS NOT NULL`).
|
||||
**Migration 004**: `ALTER COLUMN short_id SET NOT NULL`.
|
||||
|
||||
---
|
||||
|
||||
## Storage Object Copy Strategy
|
||||
|
||||
**Decision**: Copy-then-verify-then-delete (not atomic rename). Using the MinIO/S3 `copy_object` API followed by a `delete_object` call.
|
||||
|
||||
**Rationale**: S3-compatible object stores do not support atomic renames. The safe approach is: copy to new key, verify new object exists (head_object), update DB, delete old key. If interrupted after copy but before delete, the old object remains — wasted storage but no data loss. The migration is idempotent: if `short_id` is already set on a row, the script skips it.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `mc mv` (MinIO client CLI) — simpler but harder to script transactionally with DB updates
|
||||
- Direct Python with `aiobotocore` — chosen; same library already used by the storage backend
|
||||
|
||||
---
|
||||
|
||||
## API Route Parameter Change
|
||||
|
||||
**Decision**: Change all image route parameters from `image_id: uuid.UUID` to `short_id: str` with manual length/charset validation.
|
||||
|
||||
**Rationale**: FastAPI's `uuid.UUID` type annotation rejects non-UUID strings at the path-parsing stage, so the existing routes cannot accept short IDs without a type change. Switching to `str` with a custom validator (8 alphanumeric chars) is minimal and clear.
|
||||
|
||||
**Impact**: All routes under `/api/v1/images/{id}` change to accept an 8-char string. The `id` field in API responses is retained as the UUID; `short_id` is added as a new field. The UI switches to using `short_id` for all navigation and API calls.
|
||||
|
||||
---
|
||||
|
||||
## Response Schema: Additive Change
|
||||
|
||||
**Decision**: Add `short_id` as a new field to the image response dict. The existing `id` (UUID) field is retained.
|
||||
|
||||
**Rationale**: Adding a field is non-breaking per §3.1. Removing `id` would be a breaking change. Retaining both allows any internal tooling or API consumers that already use `id` to continue working. The UI transitions to using `short_id` for routing and API calls, but the UUID remains queryable if needed.
|
||||
104
specs/017-short-id-migration/spec.md
Normal file
104
specs/017-short-id-migration/spec.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Feature Specification: Short Image IDs
|
||||
|
||||
**Feature Branch**: `017-short-id-migration`
|
||||
**Created**: 2026-05-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Replace UUID-based image identifiers with 8-character base62 short IDs. Short IDs become the canonical identifier in URLs (/i/:short_id replacing /images/:uuid), MinIO storage keys, and all API responses. Existing hash-based deduplication is preserved. Migration includes backfilling short IDs for existing images, renaming storage objects, and regenerating file URLs. Frontend routes update to use short IDs throughout."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Clean, Shareable Image Links (Priority: P1)
|
||||
|
||||
A user wants to share an image with someone. They copy the page URL or use the "Copy URL" button and get a short, clean link they can paste anywhere. The link is brief enough to share in a message without looking like machine-generated noise.
|
||||
|
||||
**Why this priority**: This is the primary user-facing value of the change. Every image in the library benefits immediately. Short links are more trustworthy, easier to share, and less likely to break in messaging apps that truncate long URLs.
|
||||
|
||||
**Independent Test**: Open any image detail page. Confirm the URL in the browser address bar is short (e.g. `/i/AbCdEfGh`). Copy the URL and paste it into a new tab — confirm the correct image loads. Share the link with someone who is not logged in and confirm they can view the image.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is on the image detail page, **When** they look at the browser address bar, **Then** the URL contains a short 8-character identifier rather than a long UUID.
|
||||
2. **Given** a short image URL, **When** an unauthenticated user opens it, **Then** the image loads correctly without requiring login.
|
||||
3. **Given** a short image URL, **When** it is pasted into a messaging app or email, **Then** it is compact enough to read at a glance and does not get truncated.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
|
||||
|
||||
When a new image is uploaded, the system assigns it a short ID immediately. The image is accessible via its short URL straight away. If the same file has already been uploaded before, the existing record is returned rather than creating a duplicate — the deduplication behaviour is unchanged.
|
||||
|
||||
**Why this priority**: This ensures the new convention is in place going forward. Without this, the migration work in US3 would need to be re-run for any new uploads.
|
||||
|
||||
**Independent Test**: Upload a new image. Confirm the detail page URL contains an 8-character short ID. Upload the exact same file again — confirm no new record is created and the existing short URL is returned.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user uploads an image, **When** the upload completes, **Then** the image is accessible at a short URL (`/i/{short_id}`).
|
||||
2. **Given** a user uploads a file that is identical to a previously uploaded image, **When** the upload completes, **Then** the system returns the existing image's short URL rather than creating a duplicate entry.
|
||||
3. **Given** a newly uploaded image, **When** the "Copy URL" button is used, **Then** the copied link is the short image page URL.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
|
||||
|
||||
All images that existed before this change are assigned short IDs and remain fully accessible. Their stored files are renamed to match the new convention. After migration, all image links throughout the application use short IDs — no UUID-based links remain active.
|
||||
|
||||
**Why this priority**: Without migration, legacy images would either be inaccessible or require maintaining two parallel URL schemes. Clean cutover is preferable. This is lower priority than P1/P2 because it is an administrative operation rather than a user-facing feature, but it must complete before the feature can be considered fully shipped.
|
||||
|
||||
**Independent Test**: After running the migration, browse the library and open several images — confirm all detail pages use short URLs. Confirm no broken images or missing thumbnails.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** images that existed before the migration, **When** the migration completes, **Then** all are accessible via short URLs.
|
||||
2. **Given** the migration has run, **When** a user browses the library and opens any image, **Then** the detail page URL is a short ID URL.
|
||||
3. **Given** the migration has run, **When** any image or thumbnail is displayed, **Then** it loads correctly with no broken images.
|
||||
4. **Given** the migration is running, **When** it encounters an error on one image, **Then** it reports the failure clearly and continues processing remaining images rather than aborting entirely.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if a short ID collision occurs during generation? The system must retry with a new ID rather than failing or overwriting an existing image.
|
||||
- What happens if a record lacks a short ID but the file content is unchanged? The migration assigns a new short ID without re-uploading the file.
|
||||
- What happens if the migration is interrupted partway through? Already-migrated images remain accessible; un-migrated images are identifiable so the migration can be re-run safely.
|
||||
- What happens if a thumbnail does not exist for an image (e.g., GIFs where generation failed)? The migration skips the thumbnail rename for that record and continues.
|
||||
- What happens if a user has bookmarked a UUID-based URL before the migration? Those URLs become invalid; this is acceptable for a personal tool with no external consumers.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST assign every image a unique 8-character short ID composed of alphanumeric characters (a–z, A–Z, 0–9).
|
||||
- **FR-002**: Every image detail page MUST be accessible at the path `/i/{short_id}`.
|
||||
- **FR-003**: The UUID-based image detail route (`/images/{uuid}`) MUST be retired; short ID routes are the sole canonical paths.
|
||||
- **FR-004**: Image storage objects (original and thumbnail) MUST use the short ID as their storage key, following flat naming: `{short_id}` for the original and `{short_id}-thumb` for the thumbnail.
|
||||
- **FR-005**: The publicly accessible image file URL and thumbnail URL MUST reflect the new storage key names.
|
||||
- **FR-006**: On upload, the system MUST check whether an identical file (by hash) already exists and return the existing record rather than creating a duplicate, regardless of short IDs.
|
||||
- **FR-007**: The system MUST generate a new short ID on upload, retrying automatically if a collision with an existing ID is detected.
|
||||
- **FR-008**: A migration process MUST assign short IDs to all existing images that do not have one, rename their storage objects to match the new keys, and update all stored URLs.
|
||||
- **FR-009**: The migration MUST be re-runnable safely — images already migrated MUST be skipped rather than processed again.
|
||||
- **FR-010**: All application links that reference images (library grid, detail page, API responses) MUST use short IDs after the migration.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Image**: Each image has a unique short ID (8 alphanumeric characters) that serves as its canonical identifier in URLs, storage, and API responses. The image retains its content hash for deduplication. The short ID is independent of the hash.
|
||||
- **Storage Object**: Each image has two storage objects — an original and a thumbnail — named using the short ID (`{short_id}` and `{short_id}-thumb`). Flat naming, no folder structure.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All image detail page URLs use an 8-character alphanumeric identifier at `/i/{short_id}`.
|
||||
- **SC-002**: 100% of existing images are accessible via short URL after migration completes, with no broken images or missing thumbnails.
|
||||
- **SC-003**: Uploading the same file twice produces one record — deduplication rate remains 100% for identical files.
|
||||
- **SC-004**: The migration completes without data loss — no image file or thumbnail is deleted before its renamed copy is confirmed present in storage.
|
||||
- **SC-005**: The migration is idempotent — running it a second time produces no changes and no errors.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- UUID-based image URLs do not need to remain accessible after migration; this is a personal tool with no external consumers relying on the old URL structure.
|
||||
- The migration will be run manually by the operator as a one-time administrative step; it does not need to be triggered from the UI.
|
||||
- Storage object renaming is implemented as copy-then-delete to avoid data loss if the process is interrupted mid-run.
|
||||
- The short ID character set is base62 (a–z, A–Z, 0–9); no special characters, ensuring URL-safe identifiers without percent-encoding.
|
||||
- The `hash` column is retained and continues to be used for deduplication; it is not removed as part of this change.
|
||||
- Thumbnails may not exist for all images (e.g., some GIFs); the migration handles missing thumbnails gracefully by skipping the thumbnail rename for those records.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user