Compare commits
80 Commits
003-upload
...
v1.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bcc1cc82 | |||
| 1166e8c5d3 | |||
| 8e94c232b4 | |||
| b00c52baa3 | |||
| 0dc350d534 | |||
| ac565e4b85 | |||
| 0808e027a5 | |||
| fc48b37ee7 | |||
| 026467c6db | |||
| e852c773e7 | |||
| 69a4d5a084 | |||
| e13a81e31e | |||
| 0624795370 | |||
| e4a77fdea3 | |||
| 22e8717e0c | |||
| 8a187b45b9 | |||
| 47e8f80572 | |||
| ebfef1b783 | |||
| ed98957dfe | |||
| c0f7954fee | |||
| c987827f76 | |||
| 6058aa6150 | |||
| 28113f38e6 | |||
| d883b76c0d | |||
| 0ad82e60ac | |||
| 40ceecda76 | |||
| fca3190eb1 | |||
| 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 | |||
| 12176471e1 | |||
| 7a835d3172 | |||
| f3e0021ee8 | |||
| 354c85292d | |||
| 265b967f6b | |||
| 355014f975 | |||
| 6092a4454e | |||
| 28df9a1261 | |||
| 9246f75fdd | |||
| 5179786261 | |||
| 86961d19ee | |||
| 5fbbc1e67f | |||
| d91a65abe5 | |||
| ec7bf591a4 |
22
.env.example
22
.env.example
@@ -11,5 +11,27 @@ S3_REGION=us-east-1
|
||||
# Angular SPA — injected at build or runtime
|
||||
API_BASE_URL=http://localhost:8000
|
||||
|
||||
# CDN base URL for serving images (e.g. https://cdn.example.com).
|
||||
# Leave empty in local dev to use API proxy fallback.
|
||||
S3_PUBLIC_BASE_URL=
|
||||
|
||||
# Upload size limit in bytes (default 50 MiB)
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
|
||||
# Owner credentials and JWT signing secret
|
||||
JWT_SECRET_KEY=change-me-to-a-long-random-string
|
||||
JWT_EXPIRY_SECONDS=86400
|
||||
OWNER_USERNAME=owner
|
||||
OWNER_PASSWORD=change-me
|
||||
|
||||
# Login brute-force protection
|
||||
LOGIN_MAX_FAILURES=5
|
||||
LOGIN_WINDOW_SECONDS=300
|
||||
LOGIN_COOLDOWN_SECONDS=900
|
||||
# Comma-separated IPs/CIDRs of trusted upstream proxies (e.g. nginx ingress pod CIDR).
|
||||
# Leave empty when not behind a reverse proxy.
|
||||
LOGIN_TRUSTED_PROXY_IPS=
|
||||
|
||||
# API documentation endpoints (Swagger UI, ReDoc, OpenAPI schema)
|
||||
# Set to false in production to avoid exposing the API surface publicly.
|
||||
API_DOCS_ENABLED=true
|
||||
|
||||
36
.env.test.example
Normal file
36
.env.test.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# Integration test environment variables
|
||||
# Used when running pytest directly on the host (outside Docker).
|
||||
#
|
||||
# Start test services first:
|
||||
# docker compose -f docker-compose.test.yml up -d postgres-test minio-test minio-init-test
|
||||
#
|
||||
# Then source this file and run tests:
|
||||
# export $(grep -v '^#' .env.test.example | xargs)
|
||||
# cd api && python -m pytest tests/integration/ -v
|
||||
|
||||
# PostgreSQL test database (postgres-test container on host port 5433)
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||
DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||
|
||||
# MinIO test instance (minio-test container on host port 9002)
|
||||
S3_ENDPOINT_URL=http://localhost:9002
|
||||
S3_BUCKET_NAME=reactbin-test
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# Auth (test values — not for production)
|
||||
JWT_SECRET_KEY=test-secret-key-for-testing-only
|
||||
OWNER_USERNAME=testowner
|
||||
OWNER_PASSWORD=testpassword
|
||||
|
||||
# API
|
||||
API_BASE_URL=http://localhost:8000
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
|
||||
# Login brute-force protection
|
||||
LOGIN_MAX_FAILURES=5
|
||||
LOGIN_WINDOW_SECONDS=300
|
||||
LOGIN_COOLDOWN_SECONDS=900
|
||||
# Comma-separated IPs/CIDRs of trusted upstream proxies; leave empty for direct connections.
|
||||
LOGIN_TRUSTED_PROXY_IPS=
|
||||
234
.gitea/workflows/pipeline.yml
Normal file
234
.gitea/workflows/pipeline.yml
Normal file
@@ -0,0 +1,234 @@
|
||||
name: Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ui-test:
|
||||
name: UI Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-bullseye
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Firefox
|
||||
run: apt-get update -qq && apt-get install -y --no-install-recommends firefox-esr
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ui/node_modules
|
||||
key: npm-${{ hashFiles('ui/package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ui
|
||||
|
||||
- name: Run tests
|
||||
run: FIREFOX_BIN=/usr/bin/firefox-esr npx ng test --watch=false
|
||||
working-directory: ui
|
||||
|
||||
ui-lint:
|
||||
name: UI Lint
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-bullseye
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ui/node_modules
|
||||
key: npm-${{ hashFiles('ui/package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ui
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
working-directory: ui
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
api-unit:
|
||||
name: API Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||
steps:
|
||||
- name: Install Node (for JS actions)
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends nodejs ca-certificates curl
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache uv store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /root/.cache/uv
|
||||
key: uv-${{ hashFiles('api/uv.lock') }}
|
||||
restore-keys: uv-
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev
|
||||
working-directory: api
|
||||
|
||||
- name: Run unit tests
|
||||
run: uv run pytest tests/unit/ -q
|
||||
working-directory: api
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://u:p@localhost/db
|
||||
S3_ENDPOINT_URL: http://localhost:9000
|
||||
S3_BUCKET_NAME: test
|
||||
S3_ACCESS_KEY_ID: key
|
||||
S3_SECRET_ACCESS_KEY: secret
|
||||
S3_REGION: us-east-1
|
||||
API_BASE_URL: http://localhost:8000
|
||||
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
|
||||
OWNER_USERNAME: testowner
|
||||
OWNER_PASSWORD: testpassword
|
||||
|
||||
api-lint:
|
||||
name: API Lint
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||
steps:
|
||||
- name: Install Node (for JS actions)
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends nodejs ca-certificates curl
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Ruff
|
||||
run: uvx ruff check .
|
||||
working-directory: api
|
||||
|
||||
api-integration:
|
||||
name: API Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: reactbin
|
||||
POSTGRES_PASSWORD: reactbin
|
||||
POSTGRES_DB: reactbin_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U reactbin -d reactbin_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
minio:
|
||||
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
|
||||
env:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_DEFAULT_BUCKETS: reactbin-test
|
||||
options: >-
|
||||
--health-cmd "mc ready local || exit 1"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- name: Install Node and curl (for JS actions and mc)
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends nodejs ca-certificates curl
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache uv store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /root/.cache/uv
|
||||
key: uv-${{ hashFiles('api/uv.lock') }}
|
||||
restore-keys: uv-
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev
|
||||
working-directory: api
|
||||
|
||||
- name: Run integration tests
|
||||
run: uv run pytest tests/integration/ -q
|
||||
working-directory: api
|
||||
env:
|
||||
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
|
||||
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
|
||||
S3_ENDPOINT_URL: http://minio:9000
|
||||
S3_BUCKET_NAME: reactbin-test
|
||||
S3_ACCESS_KEY_ID: minioadmin
|
||||
S3_SECRET_ACCESS_KEY: minioadmin
|
||||
S3_REGION: us-east-1
|
||||
API_BASE_URL: http://localhost:8000
|
||||
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
|
||||
OWNER_USERNAME: testowner
|
||||
OWNER_PASSWORD: testpassword
|
||||
|
||||
# ── Image builds (tag-only, gated on all jobs) ────────────────────────────────
|
||||
|
||||
build-api:
|
||||
name: Build & Push API Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile.prod
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:${{ github.ref_name }}
|
||||
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:latest
|
||||
|
||||
build-ui:
|
||||
name: Build & Push UI Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./ui
|
||||
file: ./ui/Dockerfile.prod
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:${{ github.ref_name }}
|
||||
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:latest
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,7 +1,11 @@
|
||||
# Developer notes
|
||||
notes/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
@@ -12,6 +16,8 @@ venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
!api/tests/build/
|
||||
!ui/tests/build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
|
||||
BIN
.img/reactbin-ui.png
Normal file
BIN
.img/reactbin-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -18,6 +18,13 @@ hooks:
|
||||
prompt: Execute speckit.git.feature?
|
||||
description: Create feature branch before specification
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before specification
|
||||
condition: null
|
||||
before_clarify:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
@@ -26,6 +33,13 @@ hooks:
|
||||
prompt: Commit outstanding changes before clarification?
|
||||
description: Auto-commit before spec clarification
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before clarification
|
||||
condition: null
|
||||
before_plan:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
@@ -34,6 +48,13 @@ hooks:
|
||||
prompt: Commit outstanding changes before planning?
|
||||
description: Auto-commit before implementation planning
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before planning
|
||||
condition: null
|
||||
before_tasks:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
@@ -42,6 +63,13 @@ hooks:
|
||||
prompt: Commit outstanding changes before task generation?
|
||||
description: Auto-commit before task generation
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before task generation
|
||||
condition: null
|
||||
before_implement:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
@@ -50,6 +78,13 @@ hooks:
|
||||
prompt: Commit outstanding changes before implementation?
|
||||
description: Auto-commit before implementation
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before implementation
|
||||
condition: null
|
||||
before_checklist:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
@@ -58,6 +93,13 @@ hooks:
|
||||
prompt: Commit outstanding changes before checklist?
|
||||
description: Auto-commit before checklist generation
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before checklist generation
|
||||
condition: null
|
||||
before_analyze:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
@@ -66,6 +108,13 @@ hooks:
|
||||
prompt: Commit outstanding changes before analysis?
|
||||
description: Auto-commit before analysis
|
||||
condition: null
|
||||
- extension: memory-loader
|
||||
command: speckit.memory-loader.load
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.memory-loader.load?
|
||||
description: Load project memory files before analysis
|
||||
condition: null
|
||||
before_taskstoissues:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
},
|
||||
"registered_skills": [],
|
||||
"installed_at": "2026-05-02T15:15:14.534434+00:00"
|
||||
},
|
||||
"memory-loader": {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"manifest_hash": "sha256:d1caef45965accd4316d8aede0a4ac67f910017ea3c501814cfc7e2d8177ab0b",
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"registered_commands": {
|
||||
"claude": [
|
||||
"speckit.memory-loader.load"
|
||||
]
|
||||
},
|
||||
"registered_skills": [],
|
||||
"installed_at": "2026-05-11T20:50:02.702659+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
.specify/extensions/memory-loader/CHANGELOG.md
Normal file
9
.specify/extensions/memory-loader/CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- `speckit.memory-loader.load` command to read all `.specify/memory/*.md` files
|
||||
- `before_*` hooks for specify, plan, tasks, implement, clarify, checklist, and analyze lifecycle commands
|
||||
- Graceful degradation when memory directory is missing or files are unreadable
|
||||
21
.specify/extensions/memory-loader/LICENSE
Normal file
21
.specify/extensions/memory-loader/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 KevinBrown5280
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
81
.specify/extensions/memory-loader/README.md
Normal file
81
.specify/extensions/memory-loader/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# spec-kit-memory-loader
|
||||
|
||||
A [Spec Kit](https://github.com/github/spec-kit) extension that loads `.specify/memory/` files before spec-kit lifecycle commands so LLM agents have project governance context (constitution, glossary, conventions, resource standards).
|
||||
|
||||
## Problem
|
||||
|
||||
Spec-kit lifecycle commands (`/speckit.specify`, `/speckit.plan`, `/speckit.implement`, etc.) execute without awareness of project-specific governance documents stored in `.specify/memory/`. This means:
|
||||
|
||||
- Constitution principles are not consulted during specification
|
||||
- Glossary terms are not available during planning
|
||||
- Coding conventions are missed during implementation
|
||||
- Resource standards are ignored during task generation
|
||||
|
||||
## Solution
|
||||
|
||||
The Memory Loader extension registers `before_*` hooks on all major spec-kit lifecycle commands. Before each command runs, it reads every `.md` file from `.specify/memory/` and outputs their contents, giving the LLM agent full governance context.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# From release
|
||||
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/tags/v1.0.0.zip
|
||||
|
||||
# From main branch
|
||||
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/heads/main.zip
|
||||
|
||||
# Development mode (local clone)
|
||||
specify extension add --dev /path/to/spec-kit-memory-loader
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description | Modifies Files? |
|
||||
|---------|-------------|-----------------|
|
||||
| `speckit.memory-loader.load` | Read all project memory files and output their contents for context | No — read-only |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Gather**: Reads every `.md` file from `.specify/memory/`
|
||||
- If the directory does not exist, skips silently
|
||||
- If a file cannot be read, skips it and continues
|
||||
|
||||
2. **Output**: For each file, prints a headed section:
|
||||
```
|
||||
## Memory: {filename}
|
||||
|
||||
{file contents}
|
||||
```
|
||||
|
||||
3. **Summarize**: After all files, outputs:
|
||||
```
|
||||
Context loaded: {memory_count} memory files
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
The extension fires automatically before these lifecycle commands:
|
||||
|
||||
| Hook | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| `before_specify` | `speckit.memory-loader.load` | Load context before specification |
|
||||
| `before_plan` | `speckit.memory-loader.load` | Load context before planning |
|
||||
| `before_tasks` | `speckit.memory-loader.load` | Load context before task generation |
|
||||
| `before_implement` | `speckit.memory-loader.load` | Load context before implementation |
|
||||
| `before_clarify` | `speckit.memory-loader.load` | Load context before clarification |
|
||||
| `before_checklist` | `speckit.memory-loader.load` | Load context before checklist generation |
|
||||
| `before_analyze` | `speckit.memory-loader.load` | Load context before analysis |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Read-only** — never modifies any files
|
||||
- **Graceful degradation** — missing directory or unreadable files are skipped silently
|
||||
- **Governance only** — loads project-level memory; feature-specific reference docs are handled separately by a companion extension
|
||||
|
||||
## Requirements
|
||||
|
||||
- Spec Kit >= 0.6.0
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: "Read all project memory files and output their contents for LLM context"
|
||||
---
|
||||
|
||||
# Load Project Memory
|
||||
|
||||
Read ALL `.md` files in `.specify/memory/` and output their contents. This gives you project governance context (constitution, glossary, conventions, resource standards) for the command that follows.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Gather**: Read every `.md` file from `.specify/memory/`.
|
||||
- If the directory does not exist, skip it silently.
|
||||
- If a file cannot be read, skip it and continue.
|
||||
|
||||
2. **Output**: For each file, print a headed section:
|
||||
|
||||
```
|
||||
## Memory: {filename}
|
||||
|
||||
{file contents}
|
||||
```
|
||||
|
||||
3. **Summarize**: After all files, output:
|
||||
|
||||
```
|
||||
Context loaded: {memory_count} memory files
|
||||
```
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- Designed as a mandatory `before_*` hook that fires before spec-kit lifecycle commands.
|
||||
- Loads governance context only. Feature-specific reference docs are loaded by the `spec-reference-loader` extension.
|
||||
- This is a read-only operation — do NOT modify any files.
|
||||
61
.specify/extensions/memory-loader/extension.yml
Normal file
61
.specify/extensions/memory-loader/extension.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: "memory-loader"
|
||||
name: "Memory Loader"
|
||||
version: "1.0.0"
|
||||
description: "Loads .specify/memory/ files before spec-kit lifecycle commands so LLM agents have project governance context"
|
||||
author: "KevinBrown5280"
|
||||
repository: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
|
||||
license: "MIT"
|
||||
homepage: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.6.0"
|
||||
|
||||
provides:
|
||||
commands:
|
||||
- name: "speckit.memory-loader.load"
|
||||
file: "commands/speckit.memory-loader.load.md"
|
||||
description: "Read all project memory files and output their contents for context"
|
||||
|
||||
hooks:
|
||||
before_specify:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before specification"
|
||||
|
||||
before_plan:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before planning"
|
||||
|
||||
before_tasks:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before task generation"
|
||||
|
||||
before_implement:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before implementation"
|
||||
|
||||
before_clarify:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before clarification"
|
||||
|
||||
before_checklist:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before checklist generation"
|
||||
|
||||
before_analyze:
|
||||
command: "speckit.memory-loader.load"
|
||||
optional: false
|
||||
description: "Load project memory files before analysis"
|
||||
|
||||
tags:
|
||||
- "memory"
|
||||
- "context"
|
||||
- "governance"
|
||||
@@ -1,3 +1 @@
|
||||
{
|
||||
"feature_directory": "specs/003-upload-thumbnails"
|
||||
}
|
||||
{"feature_directory":"specs/018-pagination-controls"}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"here": true,
|
||||
"integration": "claude",
|
||||
"script": "sh",
|
||||
"speckit_version": "0.8.2.dev0"
|
||||
"speckit_version": "0.8.8"
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"version": "0.8.8",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [
|
||||
"claude"
|
||||
],
|
||||
"integration_settings": {
|
||||
"claude": {
|
||||
"script": "sh",
|
||||
"invoke_separator": "-"
|
||||
}
|
||||
},
|
||||
"integration": "claude",
|
||||
"version": "0.8.2.dev0"
|
||||
"default_integration": "claude"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"integration": "claude",
|
||||
"version": "0.8.2.dev0",
|
||||
"installed_at": "2026-05-02T15:15:14.461699+00:00",
|
||||
"version": "0.8.8",
|
||||
"installed_at": "2026-05-11T20:40:51.902830+00:00",
|
||||
"files": {
|
||||
".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90",
|
||||
".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977",
|
||||
".claude/skills/speckit-clarify/SKILL.md": "f2560f9f2007b4e995130f0c42633f08837a76a35d94e84091713a6f39bb1064",
|
||||
".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
|
||||
".claude/skills/speckit-implement/SKILL.md": "da9b4d6f9894d300515c66c057cee74025b27f2238895e3c22b59c6266b5be74",
|
||||
".claude/skills/speckit-implement/SKILL.md": "6029565c1a56de8919d1846b187cd644f734a0e30a6067a709803e6bc0d2abf7",
|
||||
".claude/skills/speckit-plan/SKILL.md": "8141ebbce228ad0b422a84e3b995d2bd85de917b96eadd02b5fcb56fb23f2594",
|
||||
".claude/skills/speckit-specify/SKILL.md": "8599f8e2e3463de7d4f47591565340be2f775fd61b7dd9d2175503bc3b713b77",
|
||||
".claude/skills/speckit-tasks/SKILL.md": "792589edf0ebf89af797c6bdda4e9d2c9938c696181d6f1484bf7a7cd090efaa",
|
||||
".claude/skills/speckit-specify/SKILL.md": "caadc05119eca453709a0425ed88d253883f9c55da4c13a4898367653a859483",
|
||||
".claude/skills/speckit-tasks/SKILL.md": "54c4665be61818ed50aa528bb4c51db3627079b2c67d47f2b01046268288c4a5",
|
||||
".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"integration": "speckit",
|
||||
"version": "0.8.2.dev0",
|
||||
"version": "0.8.8",
|
||||
"installed_at": "2026-05-02T15:15:14.478105+00:00",
|
||||
"files": {
|
||||
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
|
||||
@@ -11,6 +11,7 @@
|
||||
".specify/templates/plan-template.md": "5ad267630e370c73fe957dafa61bf76d633f3aea9d2f0b5195087d729cdd1e41",
|
||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
|
||||
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c"
|
||||
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
|
||||
".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: [TEMPLATE — no prior version] → 1.1.0
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-02
|
||||
Version change: 1.3.0 → 1.4.0
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-08
|
||||
|
||||
Principles introduced (first population from docs/CONSTITUTION.md):
|
||||
- §2 Architecture Principles (6 sub-principles)
|
||||
@@ -82,22 +82,23 @@ or SDK-specific types directly — only the interface contract.
|
||||
|
||||
### 2.4 Auth abstraction (progressive)
|
||||
|
||||
Authentication is treated as a pluggable backend from day one, even though
|
||||
Phase 1 ships with no auth. The API MUST route all request-identity resolution
|
||||
through a single `AuthProvider` interface. The no-op provider (Phase 1) returns
|
||||
a static anonymous identity. Adding username/password or OIDC in a later phase
|
||||
MUST be a new provider implementation, not a rewrite of business logic.
|
||||
Authentication is treated as a pluggable backend from day one. The API MUST
|
||||
route all request-identity resolution through a single `AuthProvider` interface.
|
||||
Each phase introduces a new provider implementation; no phase rewrites business
|
||||
logic already behind the interface.
|
||||
|
||||
**Phase 1 implements: no-auth (localhost only).**
|
||||
**Planned phases: username/password, then OIDC.**
|
||||
**Phase 1 — no-auth (NoOpAuthProvider): complete.**
|
||||
**Phase 2 — JWT bearer token (JWTAuthProvider, HS256, single owner): complete.**
|
||||
**Phase 3 — OIDC: planned.**
|
||||
The constitution acknowledges all three; the spec governs which is built.
|
||||
|
||||
### 2.5 Database abstraction
|
||||
|
||||
PostgreSQL is the Phase 1 database. All DB access MUST go through a repository
|
||||
layer (one repository class per domain aggregate). Raw SQL or an ORM is
|
||||
acceptable, but no query logic MAY live outside a repository. This makes the
|
||||
planned PostgreSQL → SQLite refactor a repository-layer change only.
|
||||
PostgreSQL is the database. All DB access MUST go through a repository layer
|
||||
(one repository class per domain aggregate). Raw SQL or an ORM is acceptable,
|
||||
but no query logic MAY live outside a repository. No alternative database
|
||||
engine (SQLite, DuckDB, in-memory substitutes) MAY be used in integration
|
||||
tests — dialect differences mask production bugs.
|
||||
|
||||
### 2.6 No speculative abstraction
|
||||
|
||||
@@ -170,17 +171,23 @@ OR/NOT logic is explicitly out of scope until the constitution is revised.
|
||||
|
||||
## 5. Testing Discipline
|
||||
|
||||
### 5.1 TDD is non-negotiable
|
||||
### 5.1 Tests are required alongside every implementation task
|
||||
|
||||
No production code MAY be written before a failing test exists for it. This
|
||||
applies to both API and UI. Tasks MUST include a "write failing test" step
|
||||
before any implementation step.
|
||||
Every implementation task MUST be accompanied by tests covering its behaviour.
|
||||
The ideal is red-green-refactor: write a failing test, then make it pass. In
|
||||
practice, tests written in the same task as the implementation are acceptable;
|
||||
what is non-negotiable is that no implementation task is marked done without
|
||||
corresponding test coverage. Tasks MUST NOT be split such that implementation
|
||||
is complete but tests are deferred to a later task.
|
||||
|
||||
### 5.2 Test pyramid
|
||||
|
||||
- **Unit tests** — pure logic, repository mocks, no I/O
|
||||
- **Integration tests** — API routes tested against a real (test) database
|
||||
and a real (test) S3-compatible bucket (e.g. MinIO in Docker)
|
||||
- **Integration tests** — API routes tested against a real PostgreSQL instance
|
||||
and a real S3-compatible bucket (e.g. MinIO in Docker). SQLite and other
|
||||
in-memory database substitutes are **prohibited** — PostgreSQL-specific
|
||||
behaviour (GROUP BY enforcement, JSON operators, constraint handling) MUST
|
||||
be exercised by the test suite.
|
||||
- **E2E tests** — Angular + API, minimal set covering the core happy paths
|
||||
|
||||
Unit and integration tests are required. E2E tests are best-effort in v1.
|
||||
@@ -190,25 +197,34 @@ Unit and integration tests are required. E2E tests are best-effort in v1.
|
||||
API tests in `api/tests/`, UI tests colocated with their components. No
|
||||
separate top-level `tests/` directory that mirrors the source tree.
|
||||
|
||||
### 5.4 CI must pass before any task is considered done
|
||||
### 5.4 The test suite must pass before any task is considered done
|
||||
|
||||
"Done" means: all tests pass, linter passes, type checker passes. A task MUST
|
||||
NOT be marked complete while CI is failing.
|
||||
NOT be marked complete while any of these are failing.
|
||||
|
||||
The acceptance gate is `make test-unit && make test-integration` plus `ruff
|
||||
check` / `ruff format --check` for the API. A formal CI pipeline is planned
|
||||
but not yet in place; until one exists, passing the above commands locally is
|
||||
the required gate. When CI is introduced it MUST enforce the same checks.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tech Stack Constraints
|
||||
|
||||
| Concern | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| API language | Python 3.12+ | Primary language, type hints required |
|
||||
| API framework | FastAPI | Async, OpenAPI-native |
|
||||
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
||||
| DB migrations | Alembic | Schema changes tracked in version control |
|
||||
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||
| Concern | Choice | Rationale |
|
||||
|------------------|-------------------------------------------|-------------------------------------------|
|
||||
| API language | Python 3.12+ | Primary language, type hints required |
|
||||
| API framework | FastAPI | Async, OpenAPI-native |
|
||||
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
||||
| DB migrations | Alembic | Schema changes tracked in version control |
|
||||
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
||||
| Auth tokens | PyJWT (HS256) | Lightweight; compatible with OIDC migration path |
|
||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||
| Production runtime | k3s (Kubernetes) | Manifests in `k8s/`; see deployment docs |
|
||||
| Ingress | nginx ingress controller + cert-manager | TLS via Let's Encrypt (`letsencrypt-prod` ClusterIssuer) |
|
||||
| Secret management | HashiCorp Vault + VSO (Vault Secrets Operator) | Secrets never committed; VSO syncs Vault KV v2 → K8s Secrets |
|
||||
|
||||
---
|
||||
|
||||
@@ -241,12 +257,20 @@ revised:
|
||||
- Multi-user support
|
||||
- Public sharing or embeds
|
||||
- Collections or albums beyond tag-based grouping
|
||||
- Image editing or transformation
|
||||
- Image editing or transformation beyond thumbnail generation
|
||||
- OR/NOT tag logic
|
||||
- Mobile-native app
|
||||
- Username/password auth (planned Phase 2)
|
||||
- OIDC auth (planned Phase 3)
|
||||
|
||||
**Known gaps carried forward from v1** — these are not out of scope; they are
|
||||
acknowledged deficiencies that MUST be resolved before the affected area is
|
||||
expanded:
|
||||
|
||||
- **Password hashing**: The owner password is currently stored and compared in
|
||||
plaintext. Hashing (bcrypt or Argon2) MUST be implemented before any
|
||||
additional authentication work (e.g. OIDC, additional accounts) is started.
|
||||
Specs that touch credential storage MUST address this first.
|
||||
|
||||
---
|
||||
|
||||
## 9. Governance
|
||||
@@ -277,12 +301,16 @@ Phase 1 design is complete.
|
||||
|
||||
## 10. Revision Log
|
||||
|
||||
| Version | Date | Change |
|
||||
|---|---|---|
|
||||
| 1.0.0 | 2026-05-01 | Initial constitution |
|
||||
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
||||
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
||||
| Version | Date | Change |
|
||||
|---------|------------|---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 1.0.0 | 2026-05-01 | Initial constitution |
|
||||
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
||||
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
||||
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
|
||||
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
|
||||
| 1.3.0 | 2026-05-06 | §2.5: Remove planned PostgreSQL → SQLite refactor note; prohibit alternative database engines in integration tests. §5.2: Explicitly require PostgreSQL for integration tests; prohibit SQLite — a production HAVING/GROUP BY bug was masked by SQLite's permissive dialect. |
|
||||
| 1.4.0 | 2026-05-08 | §5.1: Soften strict TDD wording to reflect actual practice — tests alongside implementation are acceptable; deferring tests to a later task is not. §5.4: Replace "CI must pass" with local test suite gate; note CI is planned but not yet in place. §6: Add production runtime rows (k3s, nginx ingress + cert-manager, Vault + VSO). §8: Add "known gaps" subsection; document plaintext password storage as a deficiency that must be resolved before further auth work. |
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-02
|
||||
**Version**: 1.4.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-08
|
||||
|
||||
96
.specify/scripts/bash/setup-tasks.sh
Executable file
96
.specify/scripts/bash/setup-tasks.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_MODE=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Validate branch
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build available docs list
|
||||
docs=()
|
||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
fi
|
||||
@@ -8,7 +8,7 @@ description: "Task list template for feature implementation"
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: The examples below include test tasks. Per §5.1 of the constitution, TDD is non-negotiable — test tasks MUST appear before every implementation task. The test task labels below marked "OPTIONAL" refer to the *type* of test (E2E is best-effort per §5.2), not whether tests are written at all.
|
||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
@@ -79,7 +79,7 @@ Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 1 (REQUIRED per §5.1 — TDD) ⚠️
|
||||
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
@@ -105,7 +105,7 @@ Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 2 (REQUIRED per §5.1 — TDD) ⚠️
|
||||
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
@@ -127,7 +127,7 @@ Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 3 (REQUIRED per §5.1 — TDD) ⚠️
|
||||
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
@@ -198,7 +198,7 @@ Examples of foundational tasks (adjust based on your project):
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests for User Story 1 together (TDD — write before implementation):
|
||||
# Launch all tests for User Story 1 together (if tests requested):
|
||||
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||
|
||||
|
||||
4
.yamllint.yml
Normal file
4
.yamllint.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
extends: relaxed
|
||||
rules:
|
||||
line-length:
|
||||
max: 120
|
||||
@@ -1,5 +1,4 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/003-upload-thumbnails/plan.md`.
|
||||
shell commands, and other important information, read the current plan
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
25
Makefile
Normal file
25
Makefile
Normal file
@@ -0,0 +1,25 @@
|
||||
.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod validate-k8s
|
||||
|
||||
test-unit:
|
||||
cd api && python -m pytest tests/unit/ -v
|
||||
|
||||
test-integration:
|
||||
docker compose -f docker-compose.test.yml build api-test
|
||||
docker compose -f docker-compose.test.yml run --rm api-test
|
||||
|
||||
build-prod:
|
||||
docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:latest
|
||||
|
||||
verify-prod:
|
||||
bash api/tests/build/verify_production_image.sh
|
||||
|
||||
build-ui-prod:
|
||||
docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
|
||||
verify-ui-prod:
|
||||
bash ui/tests/build/verify_production_image.sh
|
||||
|
||||
# Offline: yamllint only. Online (requires kubeconfig): kubectl apply --dry-run=client -f k8s/
|
||||
validate-k8s:
|
||||
yamllint -d relaxed k8s/
|
||||
kubectl apply --dry-run=client -f k8s/
|
||||
142
README.md
142
README.md
@@ -1,2 +1,142 @@
|
||||
# reactbin
|
||||
Organize your reaction images.
|
||||
_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 |
|
||||
|
||||
55
api/Dockerfile.prod
Normal file
55
api/Dockerfile.prod
Normal file
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ════════════════════════════════════════════════
|
||||
# Build stage: install production deps via uv
|
||||
# ════════════════════════════════════════════════
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
# Layer cache split: deps only (changes rarely)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Layer cache split: source (changes often)
|
||||
COPY app/ ./app/
|
||||
|
||||
# ════════════════════════════════════════════════
|
||||
# Runtime stage: lean image with venv + source
|
||||
# ════════════════════════════════════════════════
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1001 appgroup \
|
||||
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
|
||||
|
||||
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||
# Explicitly list every source directory — add new top-level dirs here or they won't exist in prod
|
||||
COPY --chown=appuser:appgroup app/ ./app/
|
||||
COPY --chown=appuser:appgroup alembic/ ./alembic/
|
||||
COPY --chown=appuser:appgroup alembic.ini .
|
||||
COPY --chown=appuser:appgroup scripts/ ./scripts/
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", \
|
||||
"--host", "0.0.0.0", \
|
||||
"--port", "8000", \
|
||||
"--timeout-graceful-shutdown", "30"]
|
||||
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)
|
||||
51
api/app/auth/jwt_provider.py
Normal file
51
api/app/auth/jwt_provider.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.provider import AuthProvider, Identity
|
||||
|
||||
_UNAUTHORIZED = HTTPException(
|
||||
status_code=401, detail={"detail": "Unauthorized", "code": "unauthorized"}
|
||||
)
|
||||
|
||||
|
||||
class JWTAuthProvider(AuthProvider):
|
||||
def __init__(
|
||||
self,
|
||||
secret_key: str,
|
||||
expiry_seconds: int,
|
||||
owner_username: str,
|
||||
owner_password: str,
|
||||
) -> None:
|
||||
self._secret_key = secret_key
|
||||
self._expiry_seconds = expiry_seconds
|
||||
self._owner_username = owner_username
|
||||
self._owner_password = owner_password
|
||||
|
||||
def create_token(self) -> str:
|
||||
now = datetime.now(tz=UTC)
|
||||
payload = {
|
||||
"sub": "owner",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=self._expiry_seconds),
|
||||
}
|
||||
return jwt.encode(payload, self._secret_key, algorithm="HS256")
|
||||
|
||||
def verify_credentials(self, username: str, password: str) -> bool:
|
||||
username_ok = secrets.compare_digest(username, self._owner_username)
|
||||
password_ok = secrets.compare_digest(password, self._owner_password)
|
||||
return username_ok and password_ok
|
||||
|
||||
async def get_identity(self, authorization: str | None) -> Identity:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise _UNAUTHORIZED
|
||||
token = authorization.removeprefix("Bearer ")
|
||||
try:
|
||||
jwt.decode(token, self._secret_key, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise _UNAUTHORIZED from None
|
||||
except jwt.InvalidTokenError:
|
||||
raise _UNAUTHORIZED from None
|
||||
return Identity(id="owner", anonymous=False)
|
||||
@@ -4,5 +4,5 @@ _ANONYMOUS = Identity(id="anonymous", anonymous=True)
|
||||
|
||||
|
||||
class NoOpAuthProvider(AuthProvider):
|
||||
async def get_identity(self) -> Identity:
|
||||
async def get_identity(self, authorization: str | None) -> Identity:
|
||||
return _ANONYMOUS
|
||||
|
||||
@@ -10,5 +10,5 @@ class Identity:
|
||||
|
||||
class AuthProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_identity(self) -> Identity:
|
||||
"""Resolve the request identity."""
|
||||
async def get_identity(self, authorization: str | None) -> Identity:
|
||||
"""Resolve the request identity from the Authorization header value."""
|
||||
|
||||
99
api/app/auth/rate_limiter.py
Normal file
99
api/app/auth/rate_limiter.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Network, IPv6Network
|
||||
from threading import Lock
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip(
|
||||
request: Request,
|
||||
trusted_networks: list[IPv4Network | IPv6Network],
|
||||
) -> str:
|
||||
"""Return the resolved client IP.
|
||||
|
||||
Prefers X-Real-IP over X-Forwarded-For when the TCP peer is a trusted
|
||||
proxy. ingress-nginx sets X-Real-IP via its realip module using an
|
||||
authoritative CIDR allowlist; it overwrites any client-supplied value, so
|
||||
it cannot be spoofed via XFF injection. XFF[0] is the fallback for paths
|
||||
that lack nginx (none currently exist, but kept for defence in depth).
|
||||
"""
|
||||
peer = request.client.host if request.client else "unknown"
|
||||
if trusted_networks and peer != "unknown":
|
||||
try:
|
||||
peer_addr = ipaddress.ip_address(peer)
|
||||
if any(peer_addr in net for net in trusted_networks):
|
||||
real_ip = request.headers.get("X-Real-IP", "").strip()
|
||||
if real_ip:
|
||||
return real_ip
|
||||
# XFF[0] fallback — warn because this path should not be
|
||||
# reached in production (nginx always sets X-Real-IP).
|
||||
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||
if xff:
|
||||
logger.warning(
|
||||
"X-Real-IP absent from trusted peer %s; falling back to XFF[0]", peer
|
||||
)
|
||||
return xff
|
||||
except ValueError:
|
||||
pass
|
||||
return peer
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Record:
|
||||
failures: int = 0
|
||||
window_start: float = field(default_factory=time.time)
|
||||
blocked_until: float = 0.0
|
||||
|
||||
|
||||
class LoginRateLimiter:
|
||||
def __init__(
|
||||
self,
|
||||
max_failures: int = 5,
|
||||
window_seconds: int = 300,
|
||||
cooldown_seconds: int = 900,
|
||||
) -> None:
|
||||
self._max = max_failures
|
||||
self._window = window_seconds
|
||||
self._cooldown = cooldown_seconds
|
||||
self._store: dict[str, _Record] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
@property
|
||||
def cooldown_seconds(self) -> int:
|
||||
return self._cooldown
|
||||
|
||||
def is_blocked(self, ip: str) -> bool:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
rec = self._store.get(ip)
|
||||
if rec is None:
|
||||
return False
|
||||
if rec.blocked_until > now:
|
||||
return True
|
||||
if rec.blocked_until > 0:
|
||||
del self._store[ip]
|
||||
return False
|
||||
|
||||
def record_failure(self, ip: str) -> None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
rec = self._store.get(ip)
|
||||
if rec is None:
|
||||
rec = _Record(window_start=now)
|
||||
self._store[ip] = rec
|
||||
if now - rec.window_start > self._window:
|
||||
rec.failures = 0
|
||||
rec.window_start = now
|
||||
rec.failures += 1
|
||||
if rec.failures >= self._max:
|
||||
rec.blocked_until = now + self._cooldown
|
||||
logger.warning("Login blocked for %s after %d failures", ip, rec.failures)
|
||||
|
||||
def record_success(self, ip: str) -> None:
|
||||
with self._lock:
|
||||
self._store.pop(ip, None)
|
||||
@@ -1,4 +1,6 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -12,7 +14,29 @@ class Settings(BaseSettings):
|
||||
s3_secret_access_key: str
|
||||
s3_region: str = "us-east-1"
|
||||
api_base_url: str = "http://localhost:8000"
|
||||
s3_public_base_url: str | None = None
|
||||
max_upload_bytes: int = 52_428_800 # 50 MiB
|
||||
jwt_secret_key: str
|
||||
jwt_expiry_seconds: int = 86400
|
||||
owner_username: str
|
||||
owner_password: str
|
||||
login_max_failures: int = 5
|
||||
login_window_seconds: int = 300
|
||||
login_cooldown_seconds: int = 900
|
||||
login_trusted_proxy_ips: str = ""
|
||||
api_docs_enabled: bool = True
|
||||
|
||||
@field_validator("api_docs_enabled", mode="before")
|
||||
@classmethod
|
||||
def coerce_docs_enabled(cls, v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
try:
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
return TypeAdapter(bool).validate_python(v)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.noop import NoOpAuthProvider
|
||||
from app.auth.provider import AuthProvider
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.auth.provider import AuthProvider, Identity
|
||||
from app.database import get_session_factory
|
||||
from app.storage.backend import StorageBackend
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
@@ -23,12 +23,38 @@ def get_storage() -> StorageBackend:
|
||||
def get_auth() -> AuthProvider:
|
||||
global _auth
|
||||
if _auth is None:
|
||||
_auth = NoOpAuthProvider()
|
||||
from app.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
_auth = JWTAuthProvider(
|
||||
secret_key=s.jwt_secret_key,
|
||||
expiry_seconds=s.jwt_expiry_seconds,
|
||||
owner_username=s.owner_username,
|
||||
owner_password=s.owner_password,
|
||||
)
|
||||
return _auth
|
||||
|
||||
|
||||
def get_jwt_auth() -> JWTAuthProvider:
|
||||
auth = get_auth()
|
||||
assert isinstance(auth, JWTAuthProvider)
|
||||
return auth
|
||||
|
||||
|
||||
async def require_auth(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
) -> Identity:
|
||||
identity = await auth.get_identity(authorization)
|
||||
if identity.anonymous:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"detail": "Authentication required", "code": "unauthorized"},
|
||||
)
|
||||
return identity
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
async with factory() as session, session.begin():
|
||||
yield session
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import ipaddress
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.rate_limiter import LoginRateLimiter
|
||||
from app.config import get_settings
|
||||
from app.database import get_engine, get_session_factory, Base
|
||||
from app.database import Base, get_engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI):
|
||||
settings = get_settings()
|
||||
# Verify DB connection and run migrations on startup
|
||||
application.state.login_rate_limiter = LoginRateLimiter(
|
||||
max_failures=settings.login_max_failures,
|
||||
window_seconds=settings.login_window_seconds,
|
||||
cooldown_seconds=settings.login_cooldown_seconds,
|
||||
)
|
||||
trusted_networks = []
|
||||
for part in settings.login_trusted_proxy_ips.split(","):
|
||||
part = part.strip()
|
||||
if part:
|
||||
with suppress(ValueError):
|
||||
trusted_networks.append(ipaddress.ip_network(part, strict=False))
|
||||
application.state.login_trusted_networks = trusted_networks
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
# In production, Alembic handles migrations; this is a dev convenience
|
||||
@@ -20,7 +33,20 @@ async def lifespan(application: FastAPI):
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
|
||||
_settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Reactbin API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if _settings.api_docs_enabled else None,
|
||||
redoc_url="/redoc" if _settings.api_docs_enabled else None,
|
||||
openapi_url="/openapi.json" if _settings.api_docs_enabled else None,
|
||||
)
|
||||
|
||||
# Defaults so app.state is populated even when lifespan doesn't run (e.g. tests)
|
||||
app.state.login_rate_limiter = LoginRateLimiter()
|
||||
app.state.login_trusted_networks = []
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
@@ -36,7 +62,8 @@ async def health():
|
||||
|
||||
|
||||
# Routers registered after all modules are defined to avoid circular imports
|
||||
from app.routers import images, tags # noqa: E402
|
||||
from app.routers import auth, images, tags # noqa: E402
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(images.router, prefix="/api/v1")
|
||||
app.include_router(tags.router, prefix="/api/v1")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Image(Base):
|
||||
@@ -22,11 +22,16 @@ class Image(Base):
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)
|
||||
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan")
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(
|
||||
back_populates="image", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def tags(self) -> list[str]:
|
||||
@@ -38,7 +43,9 @@ class Tag(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,15 +11,27 @@ class ImageRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
|
||||
async def get_by_hash(self, hash_hex: str) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
select(Image)
|
||||
.where(Image.hash == hash_hex)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
|
||||
async def get_by_id(self, image_id: uuid.UUID) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
select(Image)
|
||||
.where(Image.id == image_id)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_short_id(self, short_id: str) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image)
|
||||
.where(Image.short_id == short_id)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -34,6 +45,7 @@ class ImageRepository:
|
||||
width: int,
|
||||
height: int,
|
||||
storage_key: str,
|
||||
short_id: str,
|
||||
thumbnail_key: str | None = None,
|
||||
) -> Image:
|
||||
image = Image(
|
||||
@@ -44,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)
|
||||
@@ -57,7 +70,7 @@ class ImageRepository:
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Image], int]:
|
||||
from sqlalchemy import func, and_
|
||||
from sqlalchemy import func
|
||||
|
||||
base_query = select(Image).options(
|
||||
selectinload(Image.image_tags).selectinload(ImageTag.tag)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Image, ImageTag, Tag
|
||||
@@ -48,9 +48,7 @@ class TagRepository:
|
||||
for name in tag_names:
|
||||
tag = await self.upsert_by_name(name)
|
||||
existing = await self._session.execute(
|
||||
select(ImageTag).where(
|
||||
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
|
||||
)
|
||||
select(ImageTag).where(ImageTag.image_id == image.id, ImageTag.tag_id == tag.id)
|
||||
)
|
||||
if existing.scalar_one_or_none() is None:
|
||||
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
|
||||
@@ -76,6 +74,8 @@ class TagRepository:
|
||||
prefix: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
sort: str = "name",
|
||||
min_count: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
count_subq = (
|
||||
select(func.count(ImageTag.image_id))
|
||||
@@ -86,17 +86,20 @@ class TagRepository:
|
||||
|
||||
query = select(Tag, count_subq.label("image_count"))
|
||||
if prefix:
|
||||
query = query.where(Tag.name.like(f"{prefix}%"))
|
||||
query = query.where(Tag.name.ilike(f"%{prefix}%"))
|
||||
if min_count > 0:
|
||||
query = query.where(count_subq >= min_count)
|
||||
|
||||
total_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await self._session.execute(total_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
paginated = query.order_by(Tag.name).limit(limit).offset(offset)
|
||||
order = [count_subq.desc(), Tag.name.asc()] if sort == "count_desc" else [Tag.name.asc()]
|
||||
|
||||
paginated = query.order_by(*order).limit(limit).offset(offset)
|
||||
rows = await self._session.execute(paginated)
|
||||
|
||||
items = [
|
||||
{"id": str(tag.id), "name": tag.name, "image_count": count}
|
||||
for tag, count in rows.all()
|
||||
{"id": str(tag.id), "name": tag.name, "image_count": count} for tag, count in rows.all()
|
||||
]
|
||||
return items, total
|
||||
|
||||
55
api/app/routers/auth.py
Normal file
55
api/app/routers/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
|
||||
from app.dependencies import get_jwt_auth
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post("/auth/token", response_model=TokenResponse)
|
||||
async def login(
|
||||
request: Request,
|
||||
body: LoginRequest,
|
||||
auth: JWTAuthProvider = Depends(get_jwt_auth),
|
||||
):
|
||||
limiter: LoginRateLimiter = request.app.state.login_rate_limiter
|
||||
ip: str = get_client_ip(request, request.app.state.login_trusted_networks)
|
||||
|
||||
if limiter.is_blocked(ip):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"detail": "Too many failed login attempts. Please try again later.",
|
||||
"code": "login_rate_limited",
|
||||
},
|
||||
headers={"Retry-After": str(limiter.cooldown_seconds)},
|
||||
)
|
||||
|
||||
if not auth.verify_credentials(body.username, body.password):
|
||||
limiter.record_failure(ip)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||
)
|
||||
|
||||
limiter.record_success(ip)
|
||||
token = auth.create_token()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=auth._expiry_seconds,
|
||||
)
|
||||
@@ -1,21 +1,21 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.provider import AuthProvider
|
||||
from app.auth.provider import Identity
|
||||
from app.config import get_settings
|
||||
from app.dependencies import get_auth, get_db, get_storage
|
||||
from app.dependencies import get_db, get_storage, require_auth
|
||||
from app.models import Image
|
||||
from app.repositories.image_repo import ImageRepository
|
||||
from app.repositories.tag_repo import TagRepository
|
||||
from app.storage.backend import StorageBackend
|
||||
from app.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,
|
||||
}
|
||||
@@ -109,7 +133,7 @@ async def upload_image(
|
||||
tags: str | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
_: Identity = Depends(require_auth),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
data = await file.read()
|
||||
@@ -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,33 +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
|
||||
|
||||
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,
|
||||
)
|
||||
for _ in range(10):
|
||||
short_id = generate_short_id()
|
||||
await storage.put(short_id, data, mime_type)
|
||||
|
||||
thumbnail_key: str | None = None
|
||||
try:
|
||||
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
|
||||
await storage.put(f"{short_id}-thumb", thumb_bytes, "image/webp")
|
||||
thumbnail_key = f"{short_id}-thumb"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Thumbnail generation failed for %s; proceeding without thumbnail", short_id
|
||||
)
|
||||
|
||||
try:
|
||||
image = await image_repo.create(
|
||||
hash_hex=hash_hex,
|
||||
filename=file.filename or "upload",
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(data),
|
||||
width=width,
|
||||
height=height,
|
||||
storage_key=short_id,
|
||||
short_id=short_id,
|
||||
thumbnail_key=thumbnail_key,
|
||||
)
|
||||
break
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
await storage.delete(short_id)
|
||||
if thumbnail_key:
|
||||
await storage.delete(thumbnail_key)
|
||||
thumbnail_key = None
|
||||
continue
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"detail": "Failed to assign unique ID", "code": "id_collision"},
|
||||
)
|
||||
|
||||
if tag_names:
|
||||
tag_repo = TagRepository(db)
|
||||
await tag_repo.attach_tags(image, tag_names)
|
||||
image = await image_repo.reload_with_tags(image.id)
|
||||
|
||||
return _image_to_dict(image, duplicate=False)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base, duplicate=False)
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
@@ -190,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,
|
||||
@@ -248,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,
|
||||
@@ -280,14 +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,
|
||||
@@ -306,17 +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,
|
||||
|
||||
@@ -12,9 +12,13 @@ async def list_tags(
|
||||
q: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
sort: str = "name",
|
||||
min_count: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit = min(limit, 200)
|
||||
limit = min(limit, 500)
|
||||
tag_repo = TagRepository(db)
|
||||
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset)
|
||||
items, total = await tag_repo.list_tags(
|
||||
prefix=q, limit=limit, offset=offset, sort=sort, min_count=min_count
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"pydantic-settings>=2.2",
|
||||
"python-multipart>=0.0.9",
|
||||
"pillow>=10.0",
|
||||
"PyJWT>=2.8",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -29,10 +30,14 @@ dev = [
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
exclude = ["alembic/"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = []
|
||||
ignore = [
|
||||
"B008", # FastAPI Depends/File/Form in function signatures — intentional
|
||||
"B904", # raise-without-from inside except — HTTPException re-raise pattern
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
@@ -43,3 +48,11 @@ testpaths = ["tests"]
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"anyio>=4.13.0",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
0
api/scripts/__init__.py
Normal file
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())
|
||||
0
api/tests/build/.gitkeep
Normal file
0
api/tests/build/.gitkeep
Normal file
119
api/tests/build/verify_production_image.sh
Executable file
119
api/tests/build/verify_production_image.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# TDD verification script for api/Dockerfile.prod
|
||||
# Fails (red) if Dockerfile.prod does not exist or any check fails.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="reactbin-api-prod:verify-$$"
|
||||
IMAGE2="reactbin-api-prod:verify-cache-$$"
|
||||
PG_CONTAINER=""
|
||||
APP_CONTAINER=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$APP_CONTAINER" ] && docker rm -f "$APP_CONTAINER" 2>/dev/null || true
|
||||
[ -n "$PG_CONTAINER" ] && docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rmi "$IMAGE" 2>/dev/null || true
|
||||
docker rmi "$IMAGE2" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── US1 check 1: build ────────────────────────────────────────────────────────
|
||||
echo "[verify] Building $IMAGE..."
|
||||
docker build -f api/Dockerfile.prod api/ -t "$IMAGE"
|
||||
echo "[verify] Build OK"
|
||||
|
||||
# ── US1 check 2: start with a throwaway postgres ──────────────────────────────
|
||||
echo "[verify] Starting postgres..."
|
||||
PG_CONTAINER=$(docker run -d \
|
||||
-e POSTGRES_DB=reactbin_verify \
|
||||
-e POSTGRES_USER=verify \
|
||||
-e POSTGRES_PASSWORD=verify \
|
||||
postgres:16-alpine)
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$PG_CONTAINER" pg_isready -U verify -q 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
if [[ $i -eq 30 ]]; then echo "FAIL: postgres did not become ready"; exit 1; fi
|
||||
done
|
||||
|
||||
PG_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_CONTAINER")
|
||||
|
||||
echo "[verify] Starting production container..."
|
||||
APP_CONTAINER=$(docker run -d \
|
||||
-p 18000:8000 \
|
||||
-e JWT_SECRET_KEY=verify-key \
|
||||
-e OWNER_USERNAME=testowner \
|
||||
-e OWNER_PASSWORD=testpassword \
|
||||
-e DATABASE_URL="postgresql+asyncpg://verify:verify@${PG_IP}:5432/reactbin_verify" \
|
||||
-e S3_ENDPOINT_URL=http://noop:9000 \
|
||||
-e S3_BUCKET_NAME=noop \
|
||||
-e S3_ACCESS_KEY_ID=noop \
|
||||
-e S3_SECRET_ACCESS_KEY=noop \
|
||||
-e S3_REGION=us-east-1 \
|
||||
"$IMAGE")
|
||||
|
||||
# ── US1 check 3: health endpoint ──────────────────────────────────────────────
|
||||
echo "[verify] Polling health endpoint..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:18000/api/v1/health > /dev/null; then break; fi
|
||||
sleep 1
|
||||
if [[ $i -eq 30 ]]; then echo "FAIL: health check timed out after 30s"; exit 1; fi
|
||||
done
|
||||
echo "[verify] Health check passed"
|
||||
|
||||
# ── US2 check 1: non-root user ────────────────────────────────────────────────
|
||||
UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)
|
||||
if [[ "$UID_IN_CONTAINER" -eq 0 ]]; then
|
||||
echo "FAIL: process running as root (UID 0)"; exit 1
|
||||
fi
|
||||
echo "[verify] Non-root user OK (UID $UID_IN_CONTAINER)"
|
||||
|
||||
# ── C1: stdout/stderr log capture ────────────────────────────────────────────
|
||||
LOGS=$(docker logs "$APP_CONTAINER" 2>&1)
|
||||
if [[ -z "$LOGS" ]]; then
|
||||
echo "FAIL: no output on stdout/stderr"; exit 1
|
||||
fi
|
||||
if ! echo "$LOGS" | grep -qiE "(started server|application startup complete|uvicorn)"; then
|
||||
echo "FAIL: no startup logs found on stdout/stderr"; exit 1
|
||||
fi
|
||||
echo "[verify] Stdout logging OK"
|
||||
|
||||
# ── US1 check 4: SIGTERM → exit 0 ────────────────────────────────────────────
|
||||
docker stop "$APP_CONTAINER" > /dev/null
|
||||
EXIT_CODE=$(docker wait "$APP_CONTAINER")
|
||||
if [[ "$EXIT_CODE" -ne 0 ]]; then
|
||||
echo "FAIL: non-zero exit code $EXIT_CODE after SIGTERM"; exit 1
|
||||
fi
|
||||
echo "[verify] Graceful shutdown OK (exit $EXIT_CODE)"
|
||||
|
||||
# ── US2 check 2: dev deps absent ─────────────────────────────────────────────
|
||||
if docker run --rm "$IMAGE" /app/.venv/bin/python -c "import pytest" 2>/dev/null; then
|
||||
echo "FAIL: pytest importable in production image (dev deps present)"; exit 1
|
||||
fi
|
||||
echo "[verify] Dev deps absent OK"
|
||||
|
||||
# ── C2: no hardcoded secrets in image layers ─────────────────────────────────
|
||||
if docker history --no-trunc "$IMAGE" 2>&1 | grep -qiE "(password|secret_key|api_key|token)"; then
|
||||
echo "FAIL: potential secret found in image history"; exit 1
|
||||
fi
|
||||
echo "[verify] No secrets in image layers OK"
|
||||
|
||||
# ── C3: missing env var → non-zero exit ──────────────────────────────────────
|
||||
set +e
|
||||
docker run --rm -e JWT_SECRET_KEY=verify-key "$IMAGE" 2>/dev/null
|
||||
MISSING_ENV_EXIT=$?
|
||||
set -e
|
||||
if [[ "$MISSING_ENV_EXIT" -eq 0 ]]; then
|
||||
echo "FAIL: container exited 0 despite missing OWNER_USERNAME"; exit 1
|
||||
fi
|
||||
echo "[verify] Missing-env-var exit check OK (exit $MISSING_ENV_EXIT)"
|
||||
|
||||
# ── US3: dep layer cached on source-only rebuild ──────────────────────────────
|
||||
echo "[verify] Testing cache hit on source-only rebuild..."
|
||||
touch api/app/main.py
|
||||
BUILD2_OUTPUT=$(docker build --progress=plain -f api/Dockerfile.prod api/ -t "$IMAGE2" 2>&1)
|
||||
if ! echo "$BUILD2_OUTPUT" | grep -q "CACHED"; then
|
||||
echo "FAIL: dependency layer not reused on source-only rebuild"; exit 1
|
||||
fi
|
||||
echo "[verify] Dep layer cache hit confirmed (US3 OK)"
|
||||
|
||||
echo "[verify] All checks passed (US1 + US2 + US3)."
|
||||
@@ -1,19 +1,32 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.main import app
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.dependencies import get_db, get_storage, get_auth
|
||||
# Provide required settings for the test environment before any app imports resolve them
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OWNER_USERNAME", "testowner")
|
||||
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider # noqa: E402
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.database import Base # noqa: E402
|
||||
from app.dependencies import get_auth, get_db, get_storage # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
# Bust the LRU cache so get_settings() picks up the env vars set above
|
||||
get_settings.cache_clear()
|
||||
|
||||
_TEST_JWT_SECRET = os.environ["JWT_SECRET_KEY"]
|
||||
_TEST_OWNER_USERNAME = os.environ["OWNER_USERNAME"]
|
||||
_TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def engine():
|
||||
settings = get_settings()
|
||||
# Use a separate test database URL if TEST_DATABASE_URL is set
|
||||
import os
|
||||
db_url = os.getenv("TEST_DATABASE_URL", settings.database_url)
|
||||
eng = create_async_engine(db_url, echo=False)
|
||||
async with eng.begin() as conn:
|
||||
@@ -34,8 +47,8 @@ async def db_session(engine):
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session):
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
from app.auth.noop import NoOpAuthProvider
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
|
||||
storage = S3StorageBackend()
|
||||
auth = NoOpAuthProvider()
|
||||
@@ -57,3 +70,52 @@ async def client(db_session):
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def jwt_auth_provider() -> JWTAuthProvider:
|
||||
return JWTAuthProvider(
|
||||
secret_key=_TEST_JWT_SECRET,
|
||||
expiry_seconds=3600,
|
||||
owner_username=_TEST_OWNER_USERNAME,
|
||||
owner_password=_TEST_OWNER_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(db_session, jwt_auth_provider):
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
|
||||
storage = S3StorageBackend()
|
||||
auth = jwt_auth_provider
|
||||
|
||||
async def override_db():
|
||||
yield db_session
|
||||
|
||||
def override_storage():
|
||||
return storage
|
||||
|
||||
def override_auth():
|
||||
return auth
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_storage] = override_storage
|
||||
app.dependency_overrides[get_auth] = override_auth
|
||||
|
||||
valid_token = auth.create_token()
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c, valid_token
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
db_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||
if not db_url.startswith("postgresql+asyncpg://"):
|
||||
pytest.exit(
|
||||
"Integration tests require a PostgreSQL database "
|
||||
"(postgresql+asyncpg://...). "
|
||||
"Set TEST_DATABASE_URL or DATABASE_URL accordingly. "
|
||||
f"Got: {db_url!r}",
|
||||
returncode=1,
|
||||
)
|
||||
|
||||
51
api/tests/integration/test_auth.py
Normal file
51
api/tests/integration/test_auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
|
||||
_VALID_CREDS = {"username": "testowner", "password": "testpassword"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post("/api/v1/auth/token", json=_VALID_CREDS)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert isinstance(body.get("access_token"), str)
|
||||
assert len(body["access_token"]) > 0
|
||||
assert body.get("token_type") == "bearer"
|
||||
assert body.get("expires_in", 0) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
json={"username": "testowner", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "invalid_credentials"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_username(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
json={"username": "notowner", "password": "testpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "invalid_credentials"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_password(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post("/api/v1/auth/token", json={"username": "testowner"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_username(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post("/api/v1/auth/token", json={"password": "testpassword"})
|
||||
assert response.status_code == 422
|
||||
@@ -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
|
||||
@@ -19,51 +18,62 @@ def _minimal_jpeg_v2() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_record(client):
|
||||
async def test_delete_removes_record(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_jpeg_v2()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
get_resp = await client.get(f"/api/v1/images/{image_id}")
|
||||
get_resp = await client.get(f"/api/v1/i/{image_id}")
|
||||
assert get_resp.status_code == 404
|
||||
assert get_resp.json()["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_storage_object(client):
|
||||
async def test_delete_removes_storage_object(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_jpeg_v2() + b"\x00"
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
storage_key = upload.json()["hash"]
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
# Confirm storage redirect no longer works (404 since record is gone)
|
||||
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
file_resp = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert file_resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unknown_id_returns_404(client):
|
||||
response = await client.delete(f"/api/v1/images/{uuid.uuid4()}")
|
||||
async def test_delete_unknown_id_returns_404(authed_client):
|
||||
client, token = authed_client
|
||||
response = await client.delete(
|
||||
"/api/v1/i/NotFound",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_thumbnail(client):
|
||||
async def test_delete_removes_thumbnail(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
|
||||
data = buf.getvalue()
|
||||
@@ -71,14 +81,15 @@ async def test_delete_removes_thumbnail(client):
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
image_id = upload.json()["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}")
|
||||
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()
|
||||
121
api/tests/integration/test_login_rate_limit.py
Normal file
121
api/tests/integration/test_login_rate_limit.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.auth.rate_limiter import LoginRateLimiter
|
||||
from app.main import app
|
||||
|
||||
BAD_CREDS = {"username": "attacker", "password": "wrong"}
|
||||
VALID_CREDS = {
|
||||
"username": os.environ.get("OWNER_USERNAME", "testowner"),
|
||||
"password": os.environ.get("OWNER_PASSWORD", "testpassword"),
|
||||
}
|
||||
|
||||
|
||||
def _fresh_limiter():
|
||||
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_failures_trigger_429(client: AsyncClient):
|
||||
original_limiter = app.state.login_rate_limiter
|
||||
original_networks = app.state.login_trusted_networks
|
||||
app.state.login_rate_limiter = _fresh_limiter()
|
||||
app.state.login_trusted_networks = []
|
||||
try:
|
||||
for _ in range(3):
|
||||
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["code"] == "login_rate_limited"
|
||||
finally:
|
||||
app.state.login_rate_limiter = original_limiter
|
||||
app.state.login_trusted_networks = original_networks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_resets_counter(client: AsyncClient):
|
||||
original_limiter = app.state.login_rate_limiter
|
||||
original_networks = app.state.login_trusted_networks
|
||||
app.state.login_rate_limiter = _fresh_limiter()
|
||||
app.state.login_trusted_networks = []
|
||||
try:
|
||||
for _ in range(2):
|
||||
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
await client.post("/api/v1/auth/token", json=VALID_CREDS)
|
||||
for _ in range(3):
|
||||
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
assert resp.status_code == 401, "counter should have reset after success"
|
||||
finally:
|
||||
app.state.login_rate_limiter = original_limiter
|
||||
app.state.login_trusted_networks = original_networks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_429_has_retry_after_header(client: AsyncClient):
|
||||
original_limiter = app.state.login_rate_limiter
|
||||
original_networks = app.state.login_trusted_networks
|
||||
app.state.login_rate_limiter = _fresh_limiter()
|
||||
app.state.login_trusted_networks = []
|
||||
try:
|
||||
for _ in range(3):
|
||||
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
assert resp.status_code == 429
|
||||
assert "Retry-After" in resp.headers
|
||||
assert int(resp.headers["Retry-After"]) > 0
|
||||
finally:
|
||||
app.state.login_rate_limiter = original_limiter
|
||||
app.state.login_trusted_networks = original_networks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_429_body_shape(client: AsyncClient):
|
||||
original_limiter = app.state.login_rate_limiter
|
||||
original_networks = app.state.login_trusted_networks
|
||||
app.state.login_rate_limiter = _fresh_limiter()
|
||||
app.state.login_trusted_networks = []
|
||||
try:
|
||||
for _ in range(3):
|
||||
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json() == {
|
||||
"detail": "Too many failed login attempts. Please try again later.",
|
||||
"code": "login_rate_limited",
|
||||
}
|
||||
finally:
|
||||
app.state.login_rate_limiter = original_limiter
|
||||
app.state.login_trusted_networks = original_networks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xff_header_ignored_when_no_trusted_networks(client: AsyncClient):
|
||||
original_limiter = app.state.login_rate_limiter
|
||||
original_networks = app.state.login_trusted_networks
|
||||
app.state.login_rate_limiter = _fresh_limiter()
|
||||
app.state.login_trusted_networks = []
|
||||
try:
|
||||
# Send 3 failures all claiming to be "1.2.3.4" via XFF
|
||||
for _ in range(3):
|
||||
await client.post(
|
||||
"/api/v1/auth/token",
|
||||
json=BAD_CREDS,
|
||||
headers={"X-Forwarded-For": "1.2.3.4"},
|
||||
)
|
||||
# 4th request with a *different* XFF — if XFF were trusted, this
|
||||
# would appear to be a fresh IP and get 401. Since XFF is ignored,
|
||||
# the real peer ("testclient") is blocked and we get 429.
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
json=BAD_CREDS,
|
||||
headers={"X-Forwarded-For": "9.9.9.9"},
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
"XFF should be ignored when no trusted networks are configured; "
|
||||
"expected real peer to be blocked"
|
||||
)
|
||||
finally:
|
||||
app.state.login_rate_limiter = original_limiter
|
||||
app.state.login_trusted_networks = original_networks
|
||||
92
api/tests/integration/test_protected.py
Normal file
92
api/tests/integration/test_protected.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tests that write endpoints require authentication (US2).
|
||||
These use the authed_client fixture which wires JWTAuthProvider.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _minimal_jpeg() -> bytes:
|
||||
return (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x02"
|
||||
b"\xff\xd9"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_without_token_returns_401(authed_client):
|
||||
client, _ = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "unauthorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_valid_token_succeeds(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code in (200, 201)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_without_token_returns_401(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.delete("/api/v1/i/NotFound")
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "unauthorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_with_valid_token_succeeds(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["short_id"]
|
||||
response = await client.delete(
|
||||
f"/api/v1/i/{image_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_tags_without_token_returns_401(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.patch(
|
||||
"/api/v1/i/NotFound/tags",
|
||||
json={"tags": ["a"]},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "unauthorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_tags_with_valid_token_succeeds(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["short_id"]
|
||||
response = await client.patch(
|
||||
f"/api/v1/i/{image_id}/tags",
|
||||
json={"tags": ["protected-tag"]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
70
api/tests/integration/test_public_access.py
Normal file
70
api/tests/integration/test_public_access.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
US3 regression tests: all read endpoints must remain accessible without a token
|
||||
even after require_auth is applied to write endpoints.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _minimal_jpeg() -> bytes:
|
||||
return (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x03"
|
||||
b"\xff\xd9"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_without_token_is_200(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.get("/api/v1/images")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_without_token_is_200(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["short_id"]
|
||||
response = await client.get(f"/api/v1/i/{image_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_file_without_token_is_200(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["short_id"]
|
||||
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_thumbnail_without_token_is_200(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["short_id"]
|
||||
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_without_token_is_200(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.get("/api/v1/tags")
|
||||
assert response.status_code == 200
|
||||
@@ -3,6 +3,7 @@ T041 — GET /api/v1/images?tags=cat,funny → only images with both tags
|
||||
T042 — same query excludes images with only one matching tag
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -15,7 +16,9 @@ def _minimal_gif() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_and_filter_returns_only_matching_images(client):
|
||||
async def test_and_filter_returns_only_matching_images(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_gif()
|
||||
|
||||
# Image with both tags
|
||||
@@ -23,6 +26,7 @@ async def test_and_filter_returns_only_matching_images(client):
|
||||
"/api/v1/images",
|
||||
files={"file": ("both.gif", io.BytesIO(data), "image/gif")},
|
||||
data={"tags": "andcat,andfunny"},
|
||||
headers=headers,
|
||||
)
|
||||
both_id = r_both.json()["id"]
|
||||
|
||||
@@ -31,6 +35,7 @@ async def test_and_filter_returns_only_matching_images(client):
|
||||
"/api/v1/images",
|
||||
files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")},
|
||||
data={"tags": "andcat"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
response = await client.get("/api/v1/images?tags=andcat,andfunny")
|
||||
@@ -42,7 +47,9 @@ async def test_and_filter_returns_only_matching_images(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_excludes_partial_tag_match(client):
|
||||
async def test_filter_excludes_partial_tag_match(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_gif()
|
||||
|
||||
# Image with only "exclcat"
|
||||
@@ -50,6 +57,7 @@ async def test_filter_excludes_partial_tag_match(client):
|
||||
"/api/v1/images",
|
||||
files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")},
|
||||
data={"tags": "exclcat"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Filter requires both exclcat and exclother
|
||||
|
||||
@@ -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
|
||||
@@ -29,18 +28,20 @@ def _minimal_webp() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_returns_200_with_content(client):
|
||||
async def test_file_returns_200_with_content(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_webp()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
upload_body = upload.json()
|
||||
image_id = upload_body["id"]
|
||||
image_id = upload_body["short_id"]
|
||||
image_hash = upload_body["hash"]
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("image/")
|
||||
assert response.headers["etag"] == f'"{image_hash}"'
|
||||
@@ -50,23 +51,25 @@ async def test_file_returns_200_with_content(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_unknown_id_returns_404(client):
|
||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
|
||||
response = await client.get("/api/v1/i/NotFound/file")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_response_exposes_no_storage_details(client):
|
||||
async def test_file_response_exposes_no_storage_details(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_webp()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert "minio" not in response.text.lower()
|
||||
@@ -75,18 +78,20 @@ async def test_file_response_exposes_no_storage_details(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_returns_webp(client):
|
||||
async def test_thumbnail_returns_webp(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
body = upload.json()
|
||||
image_id = body["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}"'
|
||||
@@ -95,22 +100,24 @@ async def test_thumbnail_returns_webp(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_fallback_returns_original(client, db_session):
|
||||
async def test_thumbnail_fallback_returns_original(authed_client, db_session):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
image_id = upload.json()["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
|
||||
@@ -118,7 +125,7 @@ async def test_thumbnail_fallback_returns_original(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"
|
||||
|
||||
@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
|
||||
T058 — PATCH with invalid tag → 422 invalid_tag
|
||||
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
|
||||
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
|
||||
T001 — GET /api/v1/tags?sort=count_desc returns tags ordered highest-count-first
|
||||
T002 — GET /api/v1/tags?min_count=N excludes tags with image_count < N
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _minimal_png() -> bytes:
|
||||
import struct, zlib
|
||||
import struct
|
||||
import zlib
|
||||
def chunk(name: bytes, data: bytes) -> bytes:
|
||||
c = name + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
@@ -27,12 +31,14 @@ def _minimal_png() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_tags_persists_tags(client):
|
||||
async def test_upload_with_tags_persists_tags(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_png()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
||||
data={"tags": "cat,funny"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
@@ -40,12 +46,15 @@ async def test_upload_with_tags_persists_tags(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_upload_tags_unchanged(client):
|
||||
async def test_duplicate_upload_tags_unchanged(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_png()
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
||||
data={"tags": "original-tag"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
original_tags = set(r1.json()["tags"])
|
||||
@@ -54,6 +63,7 @@ async def test_duplicate_upload_tags_unchanged(client):
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
||||
data={"tags": "different-tag"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicate"] is True
|
||||
@@ -61,18 +71,22 @@ async def test_duplicate_upload_tags_unchanged(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_replaces_tag_set(client):
|
||||
async def test_patch_replaces_tag_set(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_png()
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("patch-test.png", io.BytesIO(data), "image/png")},
|
||||
data={"tags": "old-tag"},
|
||||
headers=headers,
|
||||
)
|
||||
image_id = r1.json()["id"]
|
||||
image_id = r1.json()["short_id"]
|
||||
|
||||
patch = await client.patch(
|
||||
f"/api/v1/images/{image_id}/tags",
|
||||
f"/api/v1/i/{image_id}/tags",
|
||||
json={"tags": ["new-tag", "another"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert patch.status_code == 200
|
||||
body = patch.json()
|
||||
@@ -81,17 +95,21 @@ async def test_patch_replaces_tag_set(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_invalid_tag_returns_422(client):
|
||||
async def test_patch_invalid_tag_returns_422(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_png()
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
|
||||
headers=headers,
|
||||
)
|
||||
image_id = r1.json()["id"]
|
||||
image_id = r1.json()["short_id"]
|
||||
|
||||
patch = await client.patch(
|
||||
f"/api/v1/images/{image_id}/tags",
|
||||
f"/api/v1/i/{image_id}/tags",
|
||||
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert patch.status_code == 422
|
||||
body = patch.json()
|
||||
@@ -99,12 +117,14 @@ async def test_patch_invalid_tag_returns_422(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_alphabetical_with_counts(client):
|
||||
async def test_list_tags_alphabetical_with_counts(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_png()
|
||||
await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("tag-list-test.png", io.BytesIO(data), "image/png")},
|
||||
data={"tags": "zebra,apple"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response = await client.get("/api/v1/tags")
|
||||
assert response.status_code == 200
|
||||
@@ -117,12 +137,14 @@ async def test_list_tags_alphabetical_with_counts(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_prefix_filter(client):
|
||||
async def test_list_tags_prefix_filter(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_png()
|
||||
await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("prefix-test.png", io.BytesIO(data), "image/png")},
|
||||
data={"tags": "cat,catfish,caterpillar,dog"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response = await client.get("/api/v1/tags?q=cat")
|
||||
assert response.status_code == 200
|
||||
@@ -130,3 +152,70 @@ async def test_list_tags_prefix_filter(client):
|
||||
for item in body["items"]:
|
||||
assert item["name"].startswith("cat")
|
||||
assert not any(item["name"] == "dog" for item in body["items"])
|
||||
|
||||
|
||||
def _unique_png(seed: int) -> bytes:
|
||||
"""Generate a 1x1 PNG with a seed-determined pixel so each seed produces a distinct hash."""
|
||||
import struct
|
||||
import zlib
|
||||
def chunk(name: bytes, data: bytes) -> bytes:
|
||||
c = name + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
|
||||
r, g, b = (seed * 37) % 256, (seed * 53) % 256, (seed * 71) % 256
|
||||
idat_data = zlib.compress(bytes([0, r, g, b]))
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", idat_data)
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_sort_count_desc(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# popular-sort-tag appears on 2 images, rare-sort-tag on 1 — verify count_desc ordering
|
||||
for seed in (100, 101):
|
||||
await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": (f"sort-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
|
||||
data={"tags": "popular-sort-tag,rare-sort-tag" if seed == 100 else "popular-sort-tag"},
|
||||
headers=headers,
|
||||
)
|
||||
response = await client.get("/api/v1/tags?sort=count_desc")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
sort_items = [i for i in items if i["name"] in ("popular-sort-tag", "rare-sort-tag")]
|
||||
assert len(sort_items) == 2
|
||||
# popular-sort-tag (count=2) must come before rare-sort-tag (count=1)
|
||||
names = [i["name"] for i in sort_items]
|
||||
assert names.index("popular-sort-tag") < names.index("rare-sort-tag")
|
||||
# Counts must be non-increasing
|
||||
counts = [i["image_count"] for i in items]
|
||||
assert counts == sorted(counts, reverse=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_min_count_excludes_below_threshold(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# common-min-tag appears on 2 images, uncommon-min-tag on 1
|
||||
for seed in (200, 201):
|
||||
await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": (f"min-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
|
||||
data={"tags": "common-min-tag,uncommon-min-tag" if seed == 200 else "common-min-tag"},
|
||||
headers=headers,
|
||||
)
|
||||
# min_count=2 should exclude uncommon-min-tag (count=1) but keep common-min-tag (count=2)
|
||||
response = await client.get("/api/v1/tags?min_count=2")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
names = [i["name"] for i in items]
|
||||
assert "common-min-tag" in names
|
||||
assert "uncommon-min-tag" not in names
|
||||
# All returned tags must have image_count >= 2
|
||||
for item in items:
|
||||
assert item["image_count"] >= 2
|
||||
|
||||
@@ -3,9 +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 re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -27,11 +28,13 @@ def _minimal_jpeg() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_new_image_returns_201(client):
|
||||
async def test_upload_new_image_returns_201(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
@@ -44,12 +47,15 @@ async def test_upload_new_image_returns_201(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_duplicate_returns_200_with_flag(client):
|
||||
async def test_upload_duplicate_returns_200_with_flag(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# First upload
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
|
||||
@@ -57,6 +63,7 @@ async def test_upload_duplicate_returns_200_with_flag(client):
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
@@ -65,10 +72,12 @@ async def test_upload_duplicate_returns_200_with_flag(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_invalid_mime_type_returns_422(client):
|
||||
async def test_upload_invalid_mime_type_returns_422(authed_client):
|
||||
client, token = authed_client
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
@@ -77,10 +86,12 @@ async def test_upload_invalid_mime_type_returns_422(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_oversized_file_returns_422(client):
|
||||
async def test_upload_oversized_file_returns_422(authed_client):
|
||||
import os
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
client, token = authed_client
|
||||
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -88,6 +99,7 @@ async def test_upload_oversized_file_returns_422(client):
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
@@ -99,40 +111,117 @@ async def test_upload_oversized_file_returns_422(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unknown_image_returns_404_with_envelope(client):
|
||||
import uuid
|
||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
|
||||
response = await client.get("/api/v1/i/NotFound")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
assert "detail" in body
|
||||
|
||||
|
||||
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_returns_thumbnail_key(client):
|
||||
async def test_upload_returns_short_id(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("s1.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "short_id" in body
|
||||
assert _SHORT_ID_RE.match(body["short_id"]), f"short_id invalid: {body['short_id']}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_storage_key_equals_short_id(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(10, 20, 30))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("s2.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["storage_key"] == body["short_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_thumbnail_key_equals_short_id_thumb(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(30, 60, 90))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("s3.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
if body["thumbnail_key"] is not None:
|
||||
assert body["thumbnail_key"] == f"{body['short_id']}-thumb"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_upload_returns_same_short_id(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(200, 100, 50))
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicate"] is True
|
||||
assert r2.json()["short_id"] == r1.json()["short_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_returns_thumbnail_key(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(100, 150, 200))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "thumbnail_key" in body
|
||||
assert body["thumbnail_key"] is not None
|
||||
assert body["thumbnail_key"].endswith("-thumb")
|
||||
assert "file_url" in body
|
||||
assert body["file_url"].startswith("/api/v1/i/")
|
||||
assert "thumbnail_url" in body
|
||||
assert body["thumbnail_url"].startswith("/api/v1/i/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_upload_reuses_thumbnail_key(client):
|
||||
async def test_duplicate_upload_reuses_thumbnail_key(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _real_jpeg(color=(200, 100, 50))
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
@@ -143,13 +232,18 @@ async def test_duplicate_upload_reuses_thumbnail_key(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_succeeds_when_thumbnail_fails(client):
|
||||
async def test_upload_succeeds_when_thumbnail_fails(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(50, 200, 150))
|
||||
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code in (200, 201)
|
||||
body = response.json()
|
||||
assert body["thumbnail_key"] is None
|
||||
assert "file_url" in body
|
||||
assert body["file_url"].startswith("/api/v1/i/")
|
||||
assert body["thumbnail_url"] is None
|
||||
|
||||
@@ -1,40 +1,101 @@
|
||||
import os
|
||||
import pytest
|
||||
_BASE_ENV = {
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"S3_ENDPOINT_URL": "http://localhost:9000",
|
||||
"S3_BUCKET_NAME": "test-bucket",
|
||||
"S3_ACCESS_KEY_ID": "key",
|
||||
"S3_SECRET_ACCESS_KEY": "secret",
|
||||
"S3_REGION": "us-east-1",
|
||||
"API_BASE_URL": "http://localhost:8000",
|
||||
"JWT_SECRET_KEY": "test-secret",
|
||||
"OWNER_USERNAME": "admin",
|
||||
"OWNER_PASSWORD": "password",
|
||||
}
|
||||
|
||||
|
||||
def _apply_env(monkeypatch, extra=None):
|
||||
for k, v in {**_BASE_ENV, **(extra or {})}.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
|
||||
|
||||
def test_settings_load_from_env(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
|
||||
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
|
||||
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
|
||||
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
monkeypatch.setenv("S3_REGION", "us-east-1")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
|
||||
_apply_env(monkeypatch)
|
||||
|
||||
# Import inside test to pick up monkeypatched env
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
|
||||
assert s.s3_bucket_name == "test-bucket"
|
||||
assert s.max_upload_bytes == 52428800 # default
|
||||
assert s.jwt_secret_key == "test-secret"
|
||||
assert s.jwt_expiry_seconds == 86400 # default
|
||||
assert s.owner_username == "admin"
|
||||
|
||||
|
||||
def test_settings_max_upload_bytes_override(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
|
||||
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
|
||||
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
|
||||
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
monkeypatch.setenv("S3_REGION", "us-east-1")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
|
||||
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
|
||||
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.max_upload_bytes == 10485760
|
||||
|
||||
|
||||
def test_settings_jwt_expiry_override(monkeypatch):
|
||||
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.jwt_expiry_seconds == 3600
|
||||
|
||||
|
||||
def test_api_docs_enabled_default(monkeypatch):
|
||||
_apply_env(monkeypatch)
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is True
|
||||
|
||||
|
||||
def test_api_docs_enabled_false(monkeypatch):
|
||||
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is False
|
||||
|
||||
|
||||
def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
|
||||
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "not-a-bool"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is True
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
from app.utils import compute_sha256
|
||||
|
||||
from app.utils import compute_sha256, generate_short_id
|
||||
|
||||
|
||||
def test_sha256_known_bytes():
|
||||
@@ -18,3 +19,24 @@ def test_sha256_returns_64_char_hex():
|
||||
result = compute_sha256(b"test data")
|
||||
assert len(result) == 64
|
||||
assert all(c in "0123456789abcdef" for c in result)
|
||||
|
||||
|
||||
def test_generate_short_id_length():
|
||||
assert len(generate_short_id()) == 8
|
||||
|
||||
|
||||
def test_generate_short_id_charset():
|
||||
result = generate_short_id()
|
||||
assert all(
|
||||
c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in result
|
||||
)
|
||||
|
||||
|
||||
def test_generate_short_id_randomness():
|
||||
assert generate_short_id() != generate_short_id()
|
||||
|
||||
|
||||
def test_generate_short_id_importable():
|
||||
from app.utils import generate_short_id as fn
|
||||
|
||||
assert callable(fn)
|
||||
|
||||
96
api/tests/unit/test_jwt_auth.py
Normal file
96
api/tests/unit/test_jwt_auth.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
|
||||
SECRET = "test-secret-key"
|
||||
USERNAME = "owner"
|
||||
PASSWORD = "hunter2"
|
||||
|
||||
|
||||
def make_provider(**kwargs) -> JWTAuthProvider:
|
||||
defaults = dict(
|
||||
secret_key=SECRET,
|
||||
expiry_seconds=3600,
|
||||
owner_username=USERNAME,
|
||||
owner_password=PASSWORD,
|
||||
)
|
||||
return JWTAuthProvider(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
def test_create_token_is_valid_jwt():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
payload = pyjwt.decode(token, SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "owner"
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_returns_owner():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
identity = await provider.get_identity(f"Bearer {token}")
|
||||
assert identity.id == "owner"
|
||||
assert identity.anonymous is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_expired_token():
|
||||
provider = make_provider(expiry_seconds=-1)
|
||||
token = provider.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(f"Bearer {token}")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_wrong_key():
|
||||
provider = make_provider()
|
||||
other = make_provider(secret_key="different-secret")
|
||||
token = other.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(f"Bearer {token}")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_garbage():
|
||||
provider = make_provider()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity("Bearer not.a.real.token")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_missing_header():
|
||||
provider = make_provider()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_missing_bearer_prefix():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(token)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_credentials_true():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials(USERNAME, PASSWORD) is True
|
||||
|
||||
|
||||
def test_verify_credentials_false_wrong_password():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials(USERNAME, "wrongpassword") is False
|
||||
|
||||
|
||||
def test_verify_credentials_false_wrong_username():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials("notowner", PASSWORD) is False
|
||||
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
|
||||
105
api/tests/unit/test_rate_limiter.py
Normal file
105
api/tests/unit/test_rate_limiter.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import ipaddress
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LoginRateLimiter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_limiter():
|
||||
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
|
||||
|
||||
|
||||
def test_not_blocked_initially():
|
||||
assert make_limiter().is_blocked("1.2.3.4") is False
|
||||
|
||||
|
||||
def test_blocked_after_threshold():
|
||||
limiter = make_limiter()
|
||||
for _ in range(3):
|
||||
limiter.record_failure("1.2.3.4")
|
||||
assert limiter.is_blocked("1.2.3.4") is True
|
||||
|
||||
|
||||
def test_success_clears_failures():
|
||||
limiter = make_limiter()
|
||||
limiter.record_failure("1.2.3.4")
|
||||
limiter.record_failure("1.2.3.4")
|
||||
limiter.record_success("1.2.3.4")
|
||||
assert limiter.is_blocked("1.2.3.4") is False
|
||||
|
||||
|
||||
def test_ips_are_isolated():
|
||||
limiter = make_limiter()
|
||||
for _ in range(3):
|
||||
limiter.record_failure("1.1.1.1")
|
||||
assert limiter.is_blocked("2.2.2.2") is False
|
||||
|
||||
|
||||
def test_window_resets_after_expiry():
|
||||
import time
|
||||
|
||||
limiter = LoginRateLimiter(max_failures=3, window_seconds=0, cooldown_seconds=300)
|
||||
limiter.record_failure("1.2.3.4")
|
||||
limiter.record_failure("1.2.3.4")
|
||||
time.sleep(0.01)
|
||||
limiter.record_failure("1.2.3.4")
|
||||
# window expired — counter reset on third call, so failures = 1, not 3
|
||||
assert limiter.is_blocked("1.2.3.4") is False
|
||||
|
||||
|
||||
def test_log_warning_on_lockout(caplog):
|
||||
import logging
|
||||
|
||||
limiter = make_limiter()
|
||||
with caplog.at_level(logging.WARNING, logger="app.auth.rate_limiter"):
|
||||
for _ in range(3):
|
||||
limiter.record_failure("5.6.7.8")
|
||||
assert "Login blocked" in caplog.text
|
||||
assert "5.6.7.8" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_client_ip tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_request(peer: str, headers: dict) -> MagicMock:
|
||||
req = MagicMock(spec=Request)
|
||||
req.client.host = peer
|
||||
req.headers = headers
|
||||
return req
|
||||
|
||||
|
||||
def test_get_client_ip_no_trusted_networks_returns_peer():
|
||||
req = make_request("203.0.113.1", {"X-Forwarded-For": "10.0.0.1"})
|
||||
assert get_client_ip(req, []) == "203.0.113.1"
|
||||
|
||||
|
||||
def test_get_client_ip_trusted_peer_uses_real_ip():
|
||||
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
|
||||
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||
assert get_client_ip(req, nets) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_get_client_ip_real_ip_wins_over_xff():
|
||||
# Regression: spoofed XFF must not override nginx-set X-Real-IP.
|
||||
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9", "X-Forwarded-For": "1.2.3.4"})
|
||||
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||
assert get_client_ip(req, nets) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_get_client_ip_untrusted_peer_ignores_xff():
|
||||
req = make_request("8.8.8.8", {"X-Forwarded-For": "203.0.113.5"})
|
||||
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||
assert get_client_ip(req, nets) == "8.8.8.8"
|
||||
|
||||
|
||||
def test_get_client_ip_trusted_peer_falls_back_to_xff_when_no_real_ip():
|
||||
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
|
||||
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||
assert get_client_ip(req, nets) == "203.0.113.5"
|
||||
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,16 +2,21 @@
|
||||
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
|
||||
T038 — tag validation: rejects names > 64 chars, invalid chars
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repositories.tag_repo import TagRepository
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("Cat", "cat"),
|
||||
(" funny ", "funny"),
|
||||
("REACTION", "reaction"),
|
||||
(" MiXeD ", "mixed"),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("Cat", "cat"),
|
||||
(" funny ", "funny"),
|
||||
("REACTION", "reaction"),
|
||||
(" MiXeD ", "mixed"),
|
||||
],
|
||||
)
|
||||
def test_normalise_lowercases_and_strips(raw, expected):
|
||||
assert TagRepository.normalise(raw) == expected
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError
|
||||
|
||||
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
||||
|
||||
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]
|
||||
|
||||
1594
api/uv.lock
generated
Normal file
1594
api/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
docker-compose.test.yml
Normal file
67
docker-compose.test.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: reactbin
|
||||
POSTGRES_PASSWORD: reactbin
|
||||
POSTGRES_DB: reactbin_test
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reactbin"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-test:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9002:9000"
|
||||
- "9003:9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-init-test:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio-test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio-test:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/reactbin-test
|
||||
"
|
||||
|
||||
api-test:
|
||||
build:
|
||||
context: ./api
|
||||
environment:
|
||||
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||
S3_ENDPOINT_URL: http://minio-test:9000
|
||||
S3_BUCKET_NAME: reactbin-test
|
||||
S3_ACCESS_KEY_ID: minioadmin
|
||||
S3_SECRET_ACCESS_KEY: minioadmin
|
||||
S3_REGION: us-east-1
|
||||
JWT_SECRET_KEY: test-secret-key-for-testing-only
|
||||
OWNER_USERNAME: testowner
|
||||
OWNER_PASSWORD: testpassword
|
||||
API_BASE_URL: http://localhost:8000
|
||||
MAX_UPLOAD_BYTES: "52428800"
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
minio-init-test:
|
||||
condition: service_completed_successfully
|
||||
command: ["python", "-m", "pytest", "tests/", "-v"]
|
||||
working_dir: /app
|
||||
52
k8s/api/deployment.yaml
Normal file
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.3
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
workingDir: /app
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: api-env
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
containers:
|
||||
- name: api
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.4.3
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: api-env
|
||||
env:
|
||||
- name: API_DOCS_ENABLED
|
||||
value: "false"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
13
k8s/api/service.yaml
Normal file
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.3
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101 # nginxinc/nginx-unprivileged default UID
|
||||
13
k8s/ui/service.yaml
Normal file
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"
|
||||
0
specs/001-reaction-image-board/SHIPPED
Normal file
0
specs/001-reaction-image-board/SHIPPED
Normal file
0
specs/002-api-image-proxy/SHIPPED
Normal file
0
specs/002-api-image-proxy/SHIPPED
Normal file
0
specs/003-upload-thumbnails/SHIPPED
Normal file
0
specs/003-upload-thumbnails/SHIPPED
Normal file
0
specs/004-jwt-bearer-auth/SHIPPED
Normal file
0
specs/004-jwt-bearer-auth/SHIPPED
Normal file
34
specs/004-jwt-bearer-auth/checklists/requirements.md
Normal file
34
specs/004-jwt-bearer-auth/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: JWT Bearer Token Authentication
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All items pass. Spec is ready for `/speckit-plan`.
|
||||
120
specs/004-jwt-bearer-auth/contracts/api.md
Normal file
120
specs/004-jwt-bearer-auth/contracts/api.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# API Contracts: JWT Bearer Token Authentication
|
||||
|
||||
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
|
||||
|
||||
All routes remain under `/api/v1/`. Error responses use the existing envelope:
|
||||
`{ "detail": "<human message>", "code": "<machine code>" }`.
|
||||
|
||||
---
|
||||
|
||||
## New Endpoint
|
||||
|
||||
### `POST /api/v1/auth/token`
|
||||
|
||||
Issues a bearer token for the owner after verifying credentials.
|
||||
|
||||
**Request**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "<string>",
|
||||
"password": "<string>"
|
||||
}
|
||||
```
|
||||
|
||||
Both fields are required. A missing or empty field returns `422`.
|
||||
|
||||
**Success response** — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "<jwt-string>",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400
|
||||
}
|
||||
```
|
||||
|
||||
`expires_in` reflects the configured `JWT_EXPIRY_SECONDS` value.
|
||||
|
||||
**Failure responses**
|
||||
|
||||
| Status | Code | When |
|
||||
|---|---|---|
|
||||
| `401` | `invalid_credentials` | Username or password is wrong |
|
||||
| `422` | (FastAPI default) | Missing or malformed request body |
|
||||
|
||||
---
|
||||
|
||||
## Changed Endpoints — Access Control
|
||||
|
||||
The following endpoints now require a valid bearer token. Requests without
|
||||
a token, or with an invalid/expired token, receive a `401`.
|
||||
|
||||
| Method | Path | Was | Now |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/v1/images` | Public | **Protected** |
|
||||
| `DELETE` | `/api/v1/images/{id}` | Public | **Protected** |
|
||||
| `PATCH` | `/api/v1/images/{id}/tags` | Public | **Protected** |
|
||||
|
||||
**Bearer token transmission**
|
||||
|
||||
The client MUST include the token in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**401 response shape** (returned by all three protected endpoints when
|
||||
authentication fails):
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Authentication required",
|
||||
"code": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unchanged Endpoints — Remain Public
|
||||
|
||||
The following endpoints require no token and must continue to accept requests
|
||||
without an `Authorization` header:
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/images` | List / filter images |
|
||||
| `GET` | `/api/v1/images/{id}` | Get image metadata |
|
||||
| `GET` | `/api/v1/images/{id}/file` | Serve original image |
|
||||
| `GET` | `/api/v1/images/{id}/thumbnail` | Serve image thumbnail |
|
||||
| `GET` | `/api/v1/tags` | List / search tags |
|
||||
| `GET` | `/api/v1/health` | Health check |
|
||||
|
||||
Sending a token on these endpoints is harmless (the server ignores it) but
|
||||
is not required.
|
||||
|
||||
---
|
||||
|
||||
## Token Validation Rules
|
||||
|
||||
The API validates tokens using the following rules, in order:
|
||||
|
||||
1. The `Authorization` header value MUST begin with `Bearer ` (case-sensitive).
|
||||
2. The token MUST be a valid HS256-signed JWT (verified against `JWT_SECRET_KEY`).
|
||||
3. The `exp` claim MUST be in the future (at time of request receipt).
|
||||
4. Any failure in steps 1–3 returns `401 unauthorized`.
|
||||
|
||||
---
|
||||
|
||||
## UI Route Contracts
|
||||
|
||||
These are Angular SPA routes affected by this feature.
|
||||
|
||||
| Route | Guard | Behaviour |
|
||||
|---|---|---|
|
||||
| `/login` | None | Login form; redirects to `returnUrl` or `/` on success |
|
||||
| `/upload` | `authGuard` | Redirects to `/login?returnUrl=/upload` if not authenticated |
|
||||
| `/images/:id` | None | Always accessible; tag-edit and delete controls visible only when authenticated |
|
||||
| `/` | None | Always accessible (library) |
|
||||
187
specs/004-jwt-bearer-auth/data-model.md
Normal file
187
specs/004-jwt-bearer-auth/data-model.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Data Model: JWT Bearer Token Authentication
|
||||
|
||||
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## Database Changes
|
||||
|
||||
**None.** JWTs are stateless bearer tokens. The API validates them by
|
||||
cryptographic signature and embedded expiry claim on each request. No token
|
||||
storage, session table, or blocklist is introduced in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Schema (new env vars)
|
||||
|
||||
Four new environment variables are added to `api/app/config.py` and
|
||||
`.env.example`.
|
||||
|
||||
| Variable | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `JWT_SECRET_KEY` | `str` | Yes | — | HMAC-SHA256 signing secret. Must be a long random string; no default (startup fails if absent). |
|
||||
| `JWT_EXPIRY_SECONDS` | `int` | No | `86400` | Token lifetime in seconds (24 h). |
|
||||
| `OWNER_USERNAME` | `str` | Yes | — | Login username for the single owner account. |
|
||||
| `OWNER_PASSWORD` | `str` | Yes | — | Login password for the single owner account. |
|
||||
|
||||
These values are loaded via `pydantic-settings` (`BaseSettings`) alongside the
|
||||
existing database and S3 settings. `JWT_SECRET_KEY`, `OWNER_USERNAME`, and
|
||||
`OWNER_PASSWORD` have no defaults and will raise a validation error at startup
|
||||
if absent, providing a clear "misconfigured" failure rather than a silent
|
||||
security hole.
|
||||
|
||||
---
|
||||
|
||||
## Token Structure
|
||||
|
||||
A JWT issued by the login endpoint carries the following claims.
|
||||
|
||||
| Claim | Type | Value |
|
||||
|---|---|---|
|
||||
| `sub` | string | `"owner"` — fixed identifier for the single owner |
|
||||
| `iat` | integer | Unix epoch seconds at time of issuance |
|
||||
| `exp` | integer | `iat + JWT_EXPIRY_SECONDS` |
|
||||
|
||||
Algorithm: `HS256` (HMAC-SHA256). Secret: `JWT_SECRET_KEY` setting.
|
||||
|
||||
The token is opaque to the client. The Angular SPA stores it in
|
||||
`sessionStorage` and transmits it as `Authorization: Bearer <token>` on every
|
||||
request.
|
||||
|
||||
---
|
||||
|
||||
## Module and Interface Changes
|
||||
|
||||
### `api/app/auth/provider.py` — updated interface
|
||||
|
||||
The `get_identity()` method gains an `authorization` parameter — the raw value
|
||||
of the `Authorization` HTTP header (or `None` if the header is absent).
|
||||
|
||||
```
|
||||
AuthProvider (abstract)
|
||||
get_identity(authorization: str | None) -> Identity
|
||||
|
||||
Identity (dataclass)
|
||||
id: str
|
||||
anonymous: bool = True
|
||||
```
|
||||
|
||||
### `api/app/auth/noop.py` — no behavioural change
|
||||
|
||||
`NoOpAuthProvider.get_identity()` continues to return the static anonymous
|
||||
identity regardless of the `authorization` argument. The signature is updated
|
||||
to match the new interface.
|
||||
|
||||
### `api/app/auth/jwt_provider.py` — new module
|
||||
|
||||
```
|
||||
JWTAuthProvider (AuthProvider)
|
||||
__init__(secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str)
|
||||
|
||||
get_identity(authorization: str | None) -> Identity
|
||||
- Parses "Bearer <token>" from authorization header
|
||||
- Decodes and validates the JWT (signature + exp)
|
||||
- Returns Identity(id="owner", anonymous=False) on success
|
||||
- Raises HTTPException 401 with code "unauthorized" on any failure
|
||||
|
||||
create_token() -> str
|
||||
- Mints a new HS256 JWT with sub="owner", iat=now, exp=now+expiry_seconds
|
||||
- Returns the encoded token string
|
||||
|
||||
verify_credentials(username: str, password: str) -> bool
|
||||
- Compares username and password against OWNER_USERNAME / OWNER_PASSWORD
|
||||
- Uses secrets.compare_digest to prevent timing attacks
|
||||
- Returns True on match, False otherwise
|
||||
```
|
||||
|
||||
### `api/app/dependencies.py` — new `require_auth` dependency
|
||||
|
||||
```
|
||||
require_auth(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
auth: AuthProvider = Depends(get_auth)
|
||||
) -> Identity
|
||||
- Calls auth.get_identity(authorization)
|
||||
- Raises HTTPException 401 if identity.anonymous is True
|
||||
- Returns the Identity on success
|
||||
```
|
||||
|
||||
Protected routes inject `identity: Identity = Depends(require_auth)` and do
|
||||
not need to perform any additional auth checks — the dependency raises before
|
||||
the route body executes if authentication fails.
|
||||
|
||||
### `api/app/routers/auth.py` — new router
|
||||
|
||||
```
|
||||
POST /api/v1/auth/token
|
||||
Request body: LoginRequest { username: str, password: str }
|
||||
Success (200): TokenResponse { access_token: str, token_type: "bearer", expires_in: int }
|
||||
Failure (401): { detail: "Invalid credentials", code: "invalid_credentials" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Angular Module Changes
|
||||
|
||||
### `ui/src/app/auth/auth.service.ts` — new service
|
||||
|
||||
```
|
||||
AuthService
|
||||
TOKEN_KEY = 'auth_token' (sessionStorage key)
|
||||
|
||||
login(username: string, password: string): Observable<void>
|
||||
- POST /api/v1/auth/token
|
||||
- On success: stores access_token in sessionStorage, emits completion
|
||||
- On 401: propagates error for the component to handle
|
||||
|
||||
logout(): void
|
||||
- Removes token from sessionStorage
|
||||
|
||||
getToken(): string | null
|
||||
- Returns stored token or null
|
||||
|
||||
isAuthenticated(): boolean
|
||||
- Returns true if getToken() is non-null
|
||||
```
|
||||
|
||||
### `ui/src/app/auth/auth.interceptor.ts` — new functional interceptor
|
||||
|
||||
Attaches `Authorization: Bearer <token>` to every outbound `HttpRequest` if
|
||||
`AuthService.getToken()` returns a non-null value. Requests without a token
|
||||
are passed through unmodified.
|
||||
|
||||
### `ui/src/app/auth/auth.guard.ts` — new route guard
|
||||
|
||||
Functional `CanActivateFn`. If `AuthService.isAuthenticated()` is `false`,
|
||||
redirects to `/login?returnUrl=<current-url>`. Otherwise allows navigation.
|
||||
|
||||
### `ui/src/app/login/login.component.ts` — new component
|
||||
|
||||
Route: `/login`
|
||||
|
||||
```
|
||||
LoginComponent
|
||||
Fields: username (required), password (required)
|
||||
On submit:
|
||||
- Calls AuthService.login()
|
||||
- On success: navigates to returnUrl query param, or '/' if absent
|
||||
- On 401: displays inline error "Invalid username or password"
|
||||
- On other error: displays generic error message
|
||||
Shows loading state while request is in flight
|
||||
```
|
||||
|
||||
### `ui/src/app/detail/detail.component.ts` — updated
|
||||
|
||||
Injects `AuthService`. Hides tag-edit input and delete button when
|
||||
`auth.isAuthenticated()` is `false`. Shows them when authenticated.
|
||||
Read-only view (image display, tag chips) is always visible.
|
||||
|
||||
### `ui/src/app/app.routes.ts` — updated
|
||||
|
||||
`/upload` route gains `canActivate: [authGuard]`. `/login` route is added
|
||||
(unguarded). All other routes are unchanged.
|
||||
|
||||
### `ui/src/app/app.config.ts` — updated
|
||||
|
||||
`provideHttpClient()` becomes
|
||||
`provideHttpClient(withInterceptors([authInterceptor]))`.
|
||||
355
specs/004-jwt-bearer-auth/plan.md
Normal file
355
specs/004-jwt-bearer-auth/plan.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Implementation Plan: JWT Bearer Token Authentication
|
||||
|
||||
**Branch**: `004-jwt-bearer-auth` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/004-jwt-bearer-auth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement Phase 2 of the progressive auth plan (constitution §2.4): replace
|
||||
the no-op `AuthProvider` with a `JWTAuthProvider` that issues and validates
|
||||
HS256 bearer tokens. Upload, delete, and tag-update endpoints become
|
||||
protected; all read endpoints stay public. The Angular SPA gains a login page,
|
||||
a session-scoped token store, an HTTP interceptor, and a route guard for the
|
||||
upload page.
|
||||
|
||||
No database migration is required — tokens are stateless. A single new PyJWT
|
||||
dependency is added to the API. The `AuthProvider` interface gains a parameter
|
||||
so the JWT provider can access the `Authorization` header; `NoOpAuthProvider`
|
||||
is updated to match but its behaviour is unchanged.
|
||||
|
||||
Changes span: `api/app/config.py` (4 new settings), `api/app/auth/provider.py`
|
||||
(interface update), `api/app/auth/jwt_provider.py` (new), `api/app/routers/auth.py`
|
||||
(new), `api/app/dependencies.py` (new `require_auth` dependency), three route
|
||||
updates in `api/app/routers/images.py`, and on the UI side: a new `AuthService`,
|
||||
`AuthInterceptor`, `AuthGuard`, and `LoginComponent`, plus small updates to
|
||||
`app.routes.ts`, `app.config.ts`, and `detail.component.ts`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
|
||||
**Primary Dependencies**: FastAPI, PyJWT (new), pydantic-settings, Angular
|
||||
**Storage**: PostgreSQL (no schema changes); S3-compatible (no changes)
|
||||
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
|
||||
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
|
||||
**Project Type**: Web application — FastAPI API + Angular SPA
|
||||
**Performance Goals**: Login round-trip under 15 s on local network (well within
|
||||
reach; no database lookup, only in-memory credential comparison + JWT signing)
|
||||
**Constraints**: Stateless tokens — no server-side session storage; single owner
|
||||
account; no token revocation in v1
|
||||
**Scale/Scope**: Single-user personal application; auth is a deployment-time
|
||||
configuration concern, not a runtime management concern
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|---|---|---|
|
||||
| §2.1 Separation of concerns | JWT logic lives only in `JWTAuthProvider`; routes orchestrate; UI knows nothing about signing | ✅ |
|
||||
| §2.2 Dependency direction | UI → API only; no upward imports introduced | ✅ |
|
||||
| §2.3 Storage abstraction | No change to storage layer | ✅ |
|
||||
| §2.4 Auth abstraction | `JWTAuthProvider` is a second `AuthProvider` implementation — exactly the pattern §2.4 designed for | ✅ |
|
||||
| §2.5 DB abstraction | No DB changes; stateless JWTs require no session table | ✅ |
|
||||
| §2.6 No speculative abstraction | No new interfaces; only a second concrete implementation of an already-planned interface | ✅ |
|
||||
| §3.1 API versioning | New route at `/api/v1/auth/token` | ✅ |
|
||||
| §3.3 Error shape | `401` uses `{"detail": "...", "code": "unauthorized"}` / `"invalid_credentials"` | ✅ |
|
||||
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
|
||||
| §5.2 Test pyramid | Unit tests for JWT logic; integration tests for all changed routes | ✅ |
|
||||
| §5.3 Test colocation | API tests in `api/tests/`; Angular specs colocated with components | ✅ |
|
||||
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
|
||||
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
|
||||
| §7.2 Env configuration | `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`, `OWNER_USERNAME`, `OWNER_PASSWORD` added as env vars | ✅ |
|
||||
| §8 Scope boundaries | §8 lists "Username/password auth (planned Phase 2)" as deferred. This feature IS Phase 2; the deferral is now lifted. All other §8 items remain deferred. | ✅ |
|
||||
|
||||
**Post-design re-check**: All gates still pass after Phase 1 design.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/004-jwt-bearer-auth/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 decisions
|
||||
├── data-model.md # Module and interface changes
|
||||
├── contracts/
|
||||
│ └── api.md # New endpoint + changed access control
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
||||
```
|
||||
|
||||
### Files changed or created
|
||||
|
||||
```text
|
||||
api/
|
||||
├── pyproject.toml # Add PyJWT dependency
|
||||
├── app/
|
||||
│ ├── config.py # Add 4 new settings
|
||||
│ ├── dependencies.py # Add require_auth dependency
|
||||
│ ├── auth/
|
||||
│ │ ├── provider.py # get_identity(authorization) signature
|
||||
│ │ ├── noop.py # Updated signature, same behaviour
|
||||
│ │ └── jwt_provider.py # NEW — JWTAuthProvider
|
||||
│ └── routers/
|
||||
│ ├── auth.py # NEW — POST /auth/token
|
||||
│ └── images.py # Protect upload, delete, patch-tags
|
||||
├── main.py # Register auth router
|
||||
└── .env.example # Add 4 new vars
|
||||
|
||||
ui/
|
||||
└── src/
|
||||
└── app/
|
||||
├── auth/
|
||||
│ ├── auth.service.ts # NEW
|
||||
│ ├── auth.service.spec.ts # NEW
|
||||
│ ├── auth.interceptor.ts # NEW
|
||||
│ ├── auth.interceptor.spec.ts # NEW
|
||||
│ └── auth.guard.ts # NEW
|
||||
├── login/
|
||||
│ ├── login.component.ts # NEW
|
||||
│ ├── login.component.html # NEW
|
||||
│ └── login.component.spec.ts # NEW
|
||||
├── detail/
|
||||
│ └── detail.component.ts # Conditionally show edit/delete
|
||||
├── app.routes.ts # Add /login; guard /upload
|
||||
├── app.config.ts # Register authInterceptor
|
||||
└── app.component.ts # Add logout button (visible when authenticated)
|
||||
```
|
||||
|
||||
## Milestones
|
||||
|
||||
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
|
||||
> the failing test(s) first, confirm they fail, then implement until they pass.
|
||||
|
||||
---
|
||||
|
||||
### M1 — JWT provider: token signing and validation
|
||||
|
||||
**Goal**: A tested `JWTAuthProvider` that can mint tokens and validate bearer
|
||||
tokens from an `Authorization` header. The `AuthProvider` interface is updated;
|
||||
`NoOpAuthProvider` is kept compatible.
|
||||
|
||||
**Deliverables**:
|
||||
- Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`
|
||||
- Update `api/app/config.py`:
|
||||
- `jwt_secret_key: str` (required — no default; validated by pydantic)
|
||||
- `jwt_expiry_seconds: int = 86400`
|
||||
- `owner_username: str` (required)
|
||||
- `owner_password: str` (required)
|
||||
- Update `api/app/auth/provider.py`: `get_identity(self, authorization: str | None) -> Identity`
|
||||
- Update `api/app/auth/noop.py`: match new signature; behaviour unchanged
|
||||
- Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider`:
|
||||
- `create_token() -> str` — mint HS256 JWT with `sub="owner"`, `iat`, `exp`
|
||||
- `verify_credentials(username, password) -> bool` — `secrets.compare_digest`
|
||||
- `get_identity(authorization) -> Identity` — parse `"Bearer <token>"`,
|
||||
decode JWT, return `Identity(id="owner", anonymous=False)` on success, or
|
||||
raise `HTTPException(401, {"detail": "...", "code": "unauthorized"})` on
|
||||
any failure (missing header, invalid format, bad signature, expired)
|
||||
|
||||
**Unit tests** in `api/tests/unit/test_jwt_auth.py` (write first, confirm fail):
|
||||
- `test_create_token_is_valid_jwt` — minted token decodes with PyJWT without error
|
||||
- `test_get_identity_returns_owner` — valid token → non-anonymous Identity
|
||||
- `test_get_identity_raises_on_expired_token` — token with past `exp` → 401
|
||||
- `test_get_identity_raises_on_wrong_key` — token signed with different key → 401
|
||||
- `test_get_identity_raises_on_garbage` — random string as token → 401
|
||||
- `test_get_identity_raises_on_missing_header` — `authorization=None` → 401
|
||||
- `test_get_identity_raises_on_missing_bearer_prefix` — `"token-without-prefix"` → 401
|
||||
- `test_verify_credentials_true` — correct username + password → True
|
||||
- `test_verify_credentials_false_wrong_password` — wrong password → False
|
||||
- `test_verify_credentials_false_wrong_username` — wrong username → False
|
||||
|
||||
**Done criterion**: All 10 unit tests pass; `ruff check api/` passes; existing
|
||||
tests unaffected.
|
||||
|
||||
---
|
||||
|
||||
### M2 — Login endpoint
|
||||
|
||||
**Goal**: `POST /api/v1/auth/token` issues a token for valid credentials and
|
||||
rejects invalid ones.
|
||||
|
||||
**Deliverables**:
|
||||
- Create `api/app/routers/auth.py`:
|
||||
- `LoginRequest` Pydantic model: `username: str`, `password: str`
|
||||
- `POST /auth/token` route: call `auth_provider.verify_credentials()`; on
|
||||
success call `auth_provider.create_token()` and return `TokenResponse`
|
||||
`{access_token, token_type="bearer", expires_in}`; on failure raise
|
||||
`401 invalid_credentials`
|
||||
- Update `api/app/dependencies.py`: instantiate `JWTAuthProvider` (reading
|
||||
settings) instead of `NoOpAuthProvider` in `get_auth()`
|
||||
- Update `api/app/main.py`: register `auth.router` under `/api/v1`
|
||||
|
||||
**Integration tests** in `api/tests/integration/test_auth.py` (write first):
|
||||
- `test_login_success` — POST valid creds → 200, response contains
|
||||
`access_token` (non-empty string), `token_type="bearer"`, `expires_in > 0`
|
||||
- `test_login_wrong_password` — correct username, wrong password → 401,
|
||||
code `invalid_credentials`
|
||||
- `test_login_wrong_username` — wrong username → 401, code `invalid_credentials`
|
||||
- `test_login_missing_password` — body `{"username": "x"}` → 422
|
||||
- `test_login_missing_username` — body `{"password": "x"}` → 422
|
||||
|
||||
**Test infrastructure note**: The integration test `conftest.py` currently
|
||||
overrides `get_auth` with `NoOpAuthProvider`. Tests for the auth endpoint
|
||||
need to override with a test `JWTAuthProvider` (a real provider with test
|
||||
credentials). Add a `jwt_auth_provider` fixture and an `authed_client` fixture
|
||||
(with a bearer token) to `conftest.py` for use in M3.
|
||||
|
||||
**Done criterion**: All 5 new tests pass; all existing tests pass (the
|
||||
`NoOpAuthProvider` override in `conftest.py` means existing tests are
|
||||
unaffected by switching the production `get_auth()` to `JWTAuthProvider`).
|
||||
|
||||
---
|
||||
|
||||
### M3 — Protected endpoints
|
||||
|
||||
**Goal**: Upload, delete, and patch-tags reject unauthenticated requests.
|
||||
All public endpoints remain accessible without a token.
|
||||
|
||||
**Deliverables**:
|
||||
- Add `require_auth` to `api/app/dependencies.py`:
|
||||
```python
|
||||
async def require_auth(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
) -> Identity:
|
||||
identity = await auth.get_identity(authorization)
|
||||
if identity.anonymous:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"detail": "Authentication required", "code": "unauthorized"},
|
||||
)
|
||||
return identity
|
||||
```
|
||||
- In `api/app/routers/images.py`:
|
||||
- `upload_image()`: add `_: Identity = Depends(require_auth)` parameter
|
||||
- `delete_image()`: add `_: Identity = Depends(require_auth)` parameter
|
||||
- `update_image_tags()`: add `_: Identity = Depends(require_auth)` parameter
|
||||
- Remove the now-redundant `auth: AuthProvider = Depends(get_auth)` from
|
||||
`upload_image()` (it was injected but never called; `require_auth` subsumes it)
|
||||
|
||||
**Integration tests** — add to existing test files (write failing tests first):
|
||||
|
||||
In `api/tests/integration/test_upload.py`:
|
||||
- `test_upload_without_token_returns_401` — POST without `Authorization` → 401, code `unauthorized`
|
||||
- `test_upload_with_valid_token_succeeds` — POST with valid bearer token → 200/201
|
||||
|
||||
In `api/tests/integration/test_delete.py`:
|
||||
- `test_delete_without_token_returns_401` — DELETE without token → 401
|
||||
- `test_delete_with_valid_token_succeeds` — DELETE with valid token → 204
|
||||
|
||||
In `api/tests/integration/test_serving.py` (tag update lives here conceptually
|
||||
but the route is in images; add to `test_upload.py` or a new `test_tags.py`):
|
||||
- `test_patch_tags_without_token_returns_401` — PATCH without token → 401
|
||||
- `test_patch_tags_with_valid_token_succeeds` — PATCH with valid token → 200
|
||||
|
||||
Public endpoint regression tests (confirm no 401 regression):
|
||||
- `test_list_images_without_token_is_200` — GET /images → 200 (no auth)
|
||||
- `test_get_image_without_token_is_200` — GET /images/{id} → 200
|
||||
- `test_serve_file_without_token_is_200` — GET /images/{id}/file → 200
|
||||
- `test_serve_thumbnail_without_token_is_200` — GET /images/{id}/thumbnail → 200
|
||||
- `test_list_tags_without_token_is_200` — GET /tags → 200
|
||||
|
||||
**conftest.py update**: The `client` fixture already overrides `get_auth` with
|
||||
`NoOpAuthProvider`, so all existing tests (which do not send tokens) continue
|
||||
to pass without modification. The new `authed_client` fixture (from M2) uses
|
||||
a `JWTAuthProvider` override and injects a valid token via the `Authorization`
|
||||
header.
|
||||
|
||||
**Done criterion**: All new tests pass; all existing tests continue to pass.
|
||||
|
||||
---
|
||||
|
||||
### M4 — UI: `AuthService`, `AuthInterceptor`, `AuthGuard`, `LoginComponent`
|
||||
|
||||
**Goal**: Angular has a working login flow. The upload page is protected.
|
||||
The detail page shows/hides write controls based on auth state.
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
`ui/src/app/auth/auth.service.ts`:
|
||||
- `TOKEN_KEY = 'auth_token'`
|
||||
- `login(username, password): Observable<void>` — POST to `/api/v1/auth/token`,
|
||||
store `access_token` in `sessionStorage` on success
|
||||
- `logout(): void` — `sessionStorage.removeItem(TOKEN_KEY)`
|
||||
- `getToken(): string | null`
|
||||
- `isAuthenticated(): boolean`
|
||||
|
||||
`ui/src/app/auth/auth.interceptor.ts` (functional interceptor):
|
||||
- If `AuthService.getToken()` returns non-null, clone request with
|
||||
`Authorization: Bearer <token>` header; otherwise pass through
|
||||
|
||||
`ui/src/app/auth/auth.guard.ts` (`CanActivateFn`):
|
||||
- If not authenticated: `router.createUrlTree(['/login'], {queryParams: {returnUrl: state.url}})`
|
||||
- If authenticated: `true`
|
||||
|
||||
`ui/src/app/login/login.component.ts`:
|
||||
- Reactive form with `username` (required) and `password` (required) controls
|
||||
- `onSubmit()`: calls `AuthService.login()`; on success navigates to `returnUrl`
|
||||
query param (default `/`); on error displays inline "Invalid username or password"
|
||||
- Loading state while request is in flight; button disabled during loading
|
||||
|
||||
`ui/src/app/detail/detail.component.ts` (update):
|
||||
- Inject `AuthService`
|
||||
- In template: `*ngIf="auth.isAuthenticated()"` wraps the tag-edit input and
|
||||
the delete button
|
||||
|
||||
`ui/src/app/app.routes.ts` (update):
|
||||
- Add `{ path: 'login', loadComponent: () => import('./login/login.component').then(...) }`
|
||||
- Add `canActivate: [authGuard]` to the `/upload` route
|
||||
|
||||
`ui/src/app/app.config.ts` (update):
|
||||
- `provideHttpClient(withInterceptors([authInterceptor]))`
|
||||
|
||||
**Angular unit tests** (write first, confirm fail):
|
||||
|
||||
`ui/src/app/auth/auth.service.spec.ts`:
|
||||
- `test_login_stores_token` — mock HTTP, verify `sessionStorage` has token after login
|
||||
- `test_logout_clears_token` — store a token, logout, verify `sessionStorage` empty
|
||||
- `test_isAuthenticated_true_when_token_present` — set token, assert true
|
||||
- `test_isAuthenticated_false_when_no_token` — clear sessionStorage, assert false
|
||||
|
||||
`ui/src/app/auth/auth.interceptor.spec.ts`:
|
||||
- `test_adds_auth_header_when_authenticated` — authenticated state, outbound
|
||||
request has `Authorization: Bearer <token>` header
|
||||
- `test_no_auth_header_when_not_authenticated` — unauthenticated, outbound
|
||||
request has no `Authorization` header
|
||||
|
||||
`ui/src/app/login/login.component.spec.ts`:
|
||||
- `test_submit_calls_auth_service_login` — spy on `AuthService.login`, submit
|
||||
form, verify called with correct username/password
|
||||
- `test_navigates_on_success` — mock successful login, verify router navigate called
|
||||
- `test_shows_error_on_failure` — mock 401, verify error message visible
|
||||
|
||||
**Done criterion**: Angular build clean; all Angular tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M5 — `.env.example` update and final validation
|
||||
|
||||
**Goal**: New env vars documented; full test suite green.
|
||||
|
||||
**Deliverables**:
|
||||
- Update `.env.example`: add `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`,
|
||||
`OWNER_USERNAME`, `OWNER_PASSWORD` with example values and comments
|
||||
- Run `pytest api/ -v` — confirm all tests pass (expected: existing ~57 tests
|
||||
+ ~20 new tests ≈ 77 total)
|
||||
- Run `ruff check api/ && ruff format --check api/` — zero violations
|
||||
- Run `ng test` (inside UI container) — all Angular tests pass
|
||||
- Run `ng build` — Angular build succeeds
|
||||
|
||||
**Done criterion**: All tests pass; both linters pass; `docker compose up`
|
||||
starts the full stack and the login flow works end-to-end in the browser.
|
||||
|
||||
## Post-design Constitution Re-check
|
||||
|
||||
| Principle | Verdict |
|
||||
|---|---|
|
||||
| §2.4 Auth abstraction | `JWTAuthProvider` is a drop-in second implementation; business logic in routes is unchanged except for the added `require_auth` dependency | ✅ |
|
||||
| §2.6 No speculative abstraction | No new interfaces; `JWTAuthProvider` is concrete and implements an already-planned interface | ✅ |
|
||||
| §3.3 Error shape | `401` envelope uses `code` field throughout | ✅ |
|
||||
| §5.1 TDD | Failing tests precede every implementation milestone | ✅ |
|
||||
| §7.2 Env config | All four new settings come from env vars; no hardcoded credentials | ✅ |
|
||||
|
||||
All gates pass. Feature is ready for `/speckit-tasks`.
|
||||
185
specs/004-jwt-bearer-auth/research.md
Normal file
185
specs/004-jwt-bearer-auth/research.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Research: JWT Bearer Token Authentication
|
||||
|
||||
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — JWT library
|
||||
|
||||
**Decision**: `PyJWT>=2.8`
|
||||
|
||||
**Rationale**: The project needs only HS256 signing with a single symmetric
|
||||
secret key — the simplest possible JWT profile. `PyJWT` is the de-facto
|
||||
standard Python JWT library for this use case: no additional crypto
|
||||
dependencies, actively maintained, wide community adoption. `python-jose` was
|
||||
the alternative; it has broader JOSE/JWE support but has had maintenance gaps
|
||||
and brings extra dependencies that we do not need.
|
||||
|
||||
For Phase 3 (OIDC), token issuance is replaced by the external identity
|
||||
provider. The `JWTAuthProvider` will be replaced by an OIDC-aware provider;
|
||||
the library choice for Phase 2 does not constrain Phase 3.
|
||||
|
||||
**Alternatives considered**: `python-jose[cryptography]` — wider JOSE support
|
||||
but heavier dependency tree and slower maintenance cadence. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — Password storage for the single owner account
|
||||
|
||||
**Decision**: Store plaintext `OWNER_USERNAME` and `OWNER_PASSWORD` in
|
||||
environment variables; compare at login time using `secrets.compare_digest`
|
||||
to prevent timing attacks.
|
||||
|
||||
**Rationale**: This is a single-user self-hosted application accessed over a
|
||||
trusted local network. The password is already known to the person deploying
|
||||
the application (they set it). Bcrypt pre-hashing would require operators to
|
||||
run a separate tool to generate the hash before setting the env var, adding
|
||||
friction with no meaningful security benefit for this threat model. In Phase 3
|
||||
the owner credentials are replaced by an external OIDC provider entirely, so
|
||||
this is a temporary mechanism with limited lifetime.
|
||||
|
||||
`secrets.compare_digest` is used instead of `==` to prevent any theoretical
|
||||
timing oracle.
|
||||
|
||||
**Alternatives considered**: `passlib[bcrypt]` with a pre-hashed password env
|
||||
var — more "correct" in absolute terms but adds operator complexity for a
|
||||
single-user local app. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — JWT algorithm and claims
|
||||
|
||||
**Decision**: HS256 (HMAC-SHA256) with claims: `sub` (fixed string `"owner"`),
|
||||
`iat` (issued-at epoch seconds), `exp` (expiry epoch seconds).
|
||||
|
||||
**Rationale**: HS256 is symmetric — a single `JWT_SECRET_KEY` env var is used
|
||||
for both signing and verification. This is appropriate for a single-server
|
||||
deployment where only the API ever validates tokens. RS256 (asymmetric) would
|
||||
be needed if a second service needed to verify tokens independently; that is
|
||||
not the case here and would be added complexity.
|
||||
|
||||
The `sub` claim carries the owner identifier. `exp` enables configurable
|
||||
expiry. `iat` is included for auditability.
|
||||
|
||||
**Alternatives considered**: RS256 — appropriate when multiple services verify
|
||||
tokens. Overkill for this single-server deployment. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4 — Login endpoint request format
|
||||
|
||||
**Decision**: JSON body `{"username": "...", "password": "..."}` at
|
||||
`POST /api/v1/auth/token`. Response: `{"access_token": "...", "token_type": "bearer", "expires_in": <seconds>}`.
|
||||
|
||||
**Rationale**: The OAuth2 `application/x-www-form-urlencoded` format
|
||||
(`grant_type=password, username, password`) is the spec-compliant form for
|
||||
the Resource Owner Password Credentials grant. However, we are not building a
|
||||
full OAuth2 authorization server — this is a simplified login endpoint for a
|
||||
single-user SPA. A JSON body is simpler to consume from Angular's
|
||||
`HttpClient`, avoids `URLSearchParams` boilerplate, and does not mislead
|
||||
consumers into thinking this is a full OAuth2 endpoint.
|
||||
|
||||
The response shape (`access_token`, `token_type`) follows the OAuth2 bearer
|
||||
token response convention because Phase 3 (OIDC) will also produce tokens in
|
||||
this shape — the Angular `AuthService` does not need to change its
|
||||
token-parsing logic.
|
||||
|
||||
**Alternatives considered**: OAuth2 password grant form format — interoperable
|
||||
but unnecessarily strict for this use case. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5 — `AuthProvider` interface evolution
|
||||
|
||||
**Decision**: Evolve `get_identity()` to accept a single optional string
|
||||
argument: `async def get_identity(self, authorization: str | None) -> Identity`.
|
||||
`NoOpAuthProvider` ignores the argument and returns the anonymous identity as
|
||||
before. `JWTAuthProvider` parses `"Bearer <token>"`, validates the JWT, and
|
||||
returns a non-anonymous `Identity`, or raises a `401` via `HTTPException` if
|
||||
the token is invalid or expired.
|
||||
|
||||
A new `require_auth` dependency in `dependencies.py` calls
|
||||
`auth.get_identity(authorization_header)` and raises `401` if the returned
|
||||
identity is anonymous. Protected routes inject `Depends(require_auth)`.
|
||||
Public routes continue to bypass auth entirely — they neither inject auth nor
|
||||
call `get_identity`.
|
||||
|
||||
**Rationale**: Minimal interface change that preserves backward compatibility
|
||||
(`NoOpAuthProvider` continues to work unchanged) while allowing the JWT
|
||||
provider to access the request header cleanly through FastAPI's `Header`
|
||||
dependency. An alternative would be injecting `Request` directly into the
|
||||
provider, but that couples the provider to the ASGI framework; a string header
|
||||
value keeps the provider framework-agnostic.
|
||||
|
||||
**Alternatives considered**: Pass `Request` to `get_identity()` — couples the
|
||||
provider to FastAPI/ASGI. Rejected. Create a separate `validate_token(token)`
|
||||
method — more interface surface, no clear benefit over the chosen approach.
|
||||
Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 6 — Token storage in the browser
|
||||
|
||||
**Decision**: `sessionStorage` — tokens are discarded when the browser tab is
|
||||
closed.
|
||||
|
||||
**Rationale**: `localStorage` persists across browser sessions and is
|
||||
accessible to any JavaScript on the page, making it a wider XSS target.
|
||||
`sessionStorage` is scoped to the tab and cleared on close, giving better
|
||||
security for a shared or semi-public machine. For a personal app used by the
|
||||
owner on their own machine, the loss of persistence across browser restarts is
|
||||
a minor inconvenience that is well worth the security improvement.
|
||||
`HttpOnly` cookies would be more secure still but require CSRF protection and
|
||||
server-side session management, which conflicts with the stateless JWT design.
|
||||
|
||||
**Alternatives considered**: `localStorage` — persistent but wider XSS
|
||||
exposure. Rejected. `HttpOnly` cookie — strongest XSS protection but requires
|
||||
CSRF mitigation and session server state. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 7 — Angular interceptor API
|
||||
|
||||
**Decision**: Functional HTTP interceptor registered via
|
||||
`provideHttpClient(withInterceptors([authInterceptor]))` in `app.config.ts`.
|
||||
The interceptor reads the token from `AuthService.getToken()` and adds
|
||||
`Authorization: Bearer <token>` to every outbound request if a token is
|
||||
present.
|
||||
|
||||
**Rationale**: Angular 17+ prefers functional interceptors over class-based
|
||||
ones (`APP_INITIALIZER` / `HTTP_INTERCEPTORS` token). The functional pattern
|
||||
integrates with standalone components and is the current idiomatic approach.
|
||||
The interceptor attaches the token to all requests unconditionally (not just
|
||||
protected endpoints) — the API ignores the header on public endpoints, so this
|
||||
is safe and avoids the complexity of URL matching in the interceptor.
|
||||
|
||||
**Alternatives considered**: Class-based `HttpInterceptor` — legacy pattern,
|
||||
not aligned with Angular 17+ standalone idiom. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 8 — No database migration required
|
||||
|
||||
**Decision**: JWTs are stateless — no token storage or blocklist is introduced.
|
||||
|
||||
**Rationale**: The tokens carry their own expiry; the API validates them by
|
||||
signature and expiry on each request. A token blocklist (for logout
|
||||
invalidation) would require a database table and lookup on every protected
|
||||
request, adding complexity disproportionate to the threat model of a
|
||||
single-user local application. On logout, the Angular client discards the
|
||||
token from `sessionStorage`; the token technically remains valid until its
|
||||
`exp`, but since there is no other client that holds it, this is acceptable.
|
||||
|
||||
**Alternatives considered**: Token blocklist table — true server-side logout.
|
||||
Out of scope for Phase 2; noted as a potential hardening step.
|
||||
|
||||
---
|
||||
|
||||
## Summary of new dependencies
|
||||
|
||||
| Package | Where | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `PyJWT>=2.8` | `api/pyproject.toml` | JWT signing and verification |
|
||||
|
||||
No new UI dependencies — Angular's `HttpClient` and `Router` cover all
|
||||
interceptor, guard, and HTTP needs.
|
||||
253
specs/004-jwt-bearer-auth/spec.md
Normal file
253
specs/004-jwt-bearer-auth/spec.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Feature Specification: JWT Bearer Token Authentication
|
||||
|
||||
**Feature Branch**: `004-jwt-bearer-auth`
|
||||
**Created**: 2026-05-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Implement authentication with JWT bearer tokens. Image uploads, image deletion, and image tag updates should be protected. Non-authenticated users should still be able see images and tags, including searching for tags."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Log In and Receive a Token (Priority: P1)
|
||||
|
||||
The owner visits the application and logs in with their username and password.
|
||||
On success, the application silently stores a credential that it will attach to
|
||||
all future requests. The owner is taken to the library without needing to take
|
||||
any further action.
|
||||
|
||||
**Why this priority**: Every protected action depends on having a valid
|
||||
credential in hand. Without a working login flow, uploads, deletions, and tag
|
||||
edits are all inaccessible.
|
||||
|
||||
**Independent Test**: Submit valid credentials via the login form. Confirm the
|
||||
application navigates to the library and that subsequent protected actions
|
||||
(upload, delete, tag edit) succeed without a second login prompt.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the owner is not logged in, **When** they open the application,
|
||||
**Then** they are presented with a login form before they can reach any
|
||||
protected action.
|
||||
|
||||
2. **Given** the owner submits their correct username and password, **When**
|
||||
the submission is processed, **Then** they are authenticated, taken to the
|
||||
library, and the credential is retained for the current session.
|
||||
|
||||
3. **Given** the owner submits an incorrect username or password, **When**
|
||||
the submission is processed, **Then** an inline error is shown ("Invalid
|
||||
credentials"), the credential is not stored, and the user remains on the
|
||||
login page.
|
||||
|
||||
4. **Given** the owner is logged in and their session has expired, **When**
|
||||
they attempt a protected action, **Then** they are redirected to the login
|
||||
page and informed their session has ended.
|
||||
|
||||
5. **Given** the owner submits the login form with an empty username or
|
||||
password field, **When** the submission is attempted, **Then** a validation
|
||||
error is shown and no authentication request is made.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Protected Write Actions Require Authentication (Priority: P1)
|
||||
|
||||
An authenticated owner can upload images, delete images, and update tags as
|
||||
before. An unauthenticated visitor who attempts these actions is turned away.
|
||||
|
||||
**Why this priority**: This is the core security requirement. Until protected
|
||||
actions reliably reject unauthenticated requests, the feature has not delivered
|
||||
its value.
|
||||
|
||||
**Independent Test**: Without logging in, attempt to upload an image via the
|
||||
API or UI. Confirm the attempt is rejected with an authentication error. Log in
|
||||
and repeat — confirm the upload succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authenticated owner, **When** they upload an image, delete an
|
||||
image, or update tags on an image, **Then** the action succeeds as it did
|
||||
before authentication was introduced.
|
||||
|
||||
2. **Given** an unauthenticated visitor, **When** they attempt to upload an
|
||||
image via the UI or API, **Then** the request is rejected with a clear
|
||||
authentication error and no image is stored.
|
||||
|
||||
3. **Given** an unauthenticated visitor, **When** they attempt to delete an
|
||||
image via the UI or API, **Then** the request is rejected and the image
|
||||
remains in the library.
|
||||
|
||||
4. **Given** an unauthenticated visitor, **When** they attempt to update tags
|
||||
on an image via the UI or API, **Then** the request is rejected and the
|
||||
tags are unchanged.
|
||||
|
||||
5. **Given** a request that carries a malformed, expired, or tampered
|
||||
credential, **When** it reaches a protected endpoint, **Then** it is
|
||||
rejected with an authentication error, not silently ignored.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Public Read Access (Priority: P1)
|
||||
|
||||
Unauthenticated visitors can browse the image library, view individual images,
|
||||
and search or filter by tags — no login required for read-only use.
|
||||
|
||||
**Why this priority**: The user explicitly requires this behaviour. Forcing
|
||||
login for read-only access would break the browse-without-an-account use case
|
||||
and is not part of the security model for this application.
|
||||
|
||||
**Independent Test**: Without a credential, call the list-images, get-image,
|
||||
serve-image, serve-thumbnail, and list-tags endpoints. All should return
|
||||
successful responses.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an unauthenticated visitor, **When** they open the library,
|
||||
**Then** all images and their tags are visible without a login prompt.
|
||||
|
||||
2. **Given** an unauthenticated visitor, **When** they apply tag filters,
|
||||
**Then** the filtered results are shown without requiring authentication.
|
||||
|
||||
3. **Given** an unauthenticated visitor, **When** they open an image detail
|
||||
page, **Then** the full-size image and its tags are displayed without a
|
||||
login prompt.
|
||||
|
||||
4. **Given** an unauthenticated visitor, **When** they browse the tag list
|
||||
or search for tags by prefix, **Then** results are returned without
|
||||
requiring authentication.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Log Out (Priority: P2)
|
||||
|
||||
The owner can end their authenticated session. After logging out, the browser
|
||||
no longer retains their credential and protected actions are blocked until
|
||||
they log in again.
|
||||
|
||||
**Why this priority**: Important for shared or public machines, but secondary
|
||||
to the core login and protection flows.
|
||||
|
||||
**Independent Test**: Log in, then log out. Attempt a protected action and
|
||||
confirm it is rejected. Refresh the page and confirm the login screen is shown.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the owner is logged in, **When** they choose to log out,
|
||||
**Then** their credential is discarded, they are returned to the login page,
|
||||
and subsequent protected actions are rejected.
|
||||
|
||||
2. **Given** the owner has logged out, **When** they navigate directly to a
|
||||
protected page (e.g., the upload form), **Then** they are redirected to the
|
||||
login page.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the owner's credential expires mid-session? → The next
|
||||
protected action fails with an authentication error; the UI redirects to
|
||||
the login page.
|
||||
- What happens when an attacker replays a valid but expired credential? → The
|
||||
request is rejected; expired credentials are never accepted.
|
||||
- What happens when the login endpoint is called many times with wrong
|
||||
credentials? → The spec does not require rate limiting or lockout in v1;
|
||||
this is noted as a future hardening concern.
|
||||
- What happens if the owner forgets their password? → Password reset is out of
|
||||
scope for v1; credentials are set via server-side configuration only.
|
||||
- What happens if the login endpoint is called while already authenticated? →
|
||||
A new credential is issued; the old one may be discarded by the client.
|
||||
- What happens when the UI receives a 401 on a read (public) endpoint? → This
|
||||
should not occur; read endpoints must never require authentication.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an endpoint that accepts a username and
|
||||
password and, on success, returns a time-limited credential the client can
|
||||
use to prove identity on subsequent requests.
|
||||
- **FR-002**: The system MUST reject login attempts that supply an incorrect
|
||||
username or password with a clear error; no credential is issued.
|
||||
- **FR-003**: The following actions MUST be protected — they MUST reject any
|
||||
request that does not carry a valid, unexpired credential:
|
||||
- Upload an image
|
||||
- Delete an image
|
||||
- Update the tags on an image
|
||||
- **FR-004**: The following actions MUST remain publicly accessible without any
|
||||
credential:
|
||||
- List images (with or without tag filters)
|
||||
- Retrieve a single image's metadata
|
||||
- Retrieve image file content
|
||||
- Retrieve image thumbnail content
|
||||
- List tags (with or without prefix filter)
|
||||
- **FR-005**: Credentials MUST have a finite lifetime; a credential issued
|
||||
before a configurable expiry window MUST be rejected.
|
||||
- **FR-006**: The system MUST reject credentials that have been tampered with
|
||||
or are otherwise invalid.
|
||||
- **FR-007**: The UI MUST automatically attach the owner's credential to every
|
||||
request that targets a protected action, without requiring the owner to
|
||||
manually supply it each time.
|
||||
- **FR-008**: The UI MUST redirect unauthenticated users to the login page when
|
||||
they attempt to reach a protected action or page.
|
||||
- **FR-009**: After a successful login, the UI MUST navigate the owner to the
|
||||
library (or to the page they originally tried to reach, if redirected from
|
||||
there).
|
||||
- **FR-010**: The owner MUST be able to log out; after logout the credential is
|
||||
discarded and protected actions are blocked until the owner logs in again.
|
||||
- **FR-011**: The owner's username and password MUST be configurable without
|
||||
changing application code (e.g., via environment variables or a configuration
|
||||
file read at startup).
|
||||
- **FR-012**: Only one set of owner credentials is required in v1; multi-user
|
||||
support is explicitly out of scope.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Credential**: A time-limited proof of identity issued to the owner after
|
||||
successful login. Key attributes: subject (owner identifier), issued-at
|
||||
timestamp, expiry timestamp, validity state (valid / expired / invalid).
|
||||
- **Login Request**: The combination of username and password submitted by the
|
||||
user to obtain a credential.
|
||||
- **Protected Endpoint**: An API endpoint that MUST reject requests that lack a
|
||||
valid credential.
|
||||
- **Public Endpoint**: An API endpoint that MUST accept requests regardless of
|
||||
whether a credential is present.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An unauthenticated visitor can browse the full image library and
|
||||
tag list without being prompted to log in.
|
||||
- **SC-002**: An unauthenticated attempt to upload, delete, or edit tags is
|
||||
rejected every time — 0% of such attempts succeed.
|
||||
- **SC-003**: An authenticated owner can complete a login-to-upload round trip
|
||||
in under 15 seconds on a local network connection.
|
||||
- **SC-004**: An expired credential is rejected on the first use after expiry;
|
||||
no grace period or retry is granted.
|
||||
- **SC-005**: After logging out, 100% of subsequent protected actions are
|
||||
rejected until the owner logs in again.
|
||||
- **SC-006**: The library, detail, tag list, and image-serving pages all load
|
||||
correctly without a credential present.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A single owner account is sufficient for v1. No user registration flow is
|
||||
required; credentials are set via environment variables or configuration at
|
||||
deployment time.
|
||||
- The application is accessed over a trusted local network connection for v1;
|
||||
HTTPS is not mandated by this spec but is assumed for any production
|
||||
deployment.
|
||||
- Credential lifetime is configurable but defaults to 24 hours. The exact
|
||||
value is a deployment decision, not a product requirement.
|
||||
- Password reset, account management, and credential revocation are out of
|
||||
scope for v1.
|
||||
- Rate limiting and account lockout after repeated failed login attempts are
|
||||
out of scope for v1; they are noted as future hardening work.
|
||||
- The UI maintains the owner's credential for the duration of the browser
|
||||
session. Behaviour after the browser is closed (persist vs. discard) follows
|
||||
a secure default for the credential storage mechanism chosen during
|
||||
implementation.
|
||||
- This is Phase 2 of a planned three-phase auth progression (no-auth →
|
||||
username/password → OIDC). The implementation MUST be structured so that
|
||||
replacing the credential issuance and validation mechanism in Phase 3 does
|
||||
not require changes to protected business logic.
|
||||
- The detail page and upload form are considered "protected pages" in the UI
|
||||
sense (require login to interact with write actions), but their read content
|
||||
(viewing image, viewing tags) remains publicly accessible at the API level.
|
||||
322
specs/004-jwt-bearer-auth/tasks.md
Normal file
322
specs/004-jwt-bearer-auth/tasks.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Tasks: JWT Bearer Token Authentication
|
||||
|
||||
**Input**: Design documents from `specs/004-jwt-bearer-auth/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
|
||||
|
||||
**TDD**: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.
|
||||
|
||||
**Organization**: Tasks follow user story priority order (US1 P1 → US2 P1 → US3 P1 → US4 P2). API milestones run first in each story, then Angular.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1–US4)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
```
|
||||
api/app/ API source
|
||||
api/tests/unit/ API unit tests
|
||||
api/tests/integration/ API integration tests
|
||||
ui/src/app/ Angular source
|
||||
ui/src/app/auth/ New auth module
|
||||
ui/src/app/login/ New login component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: New dependency, updated config, updated interfaces, and test fixtures
|
||||
that all user stories depend on. No user story work can begin until this is complete.
|
||||
|
||||
**⚠️ CRITICAL**: Complete all setup tasks before starting any user story phase.
|
||||
|
||||
- [X] T001 Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`; rebuild the Docker API image (`docker compose build api`) so PyJWT is available inside the container for all subsequent test runs
|
||||
|
||||
- [X] T002 Add four new settings to `api/app/config.py` (pydantic-settings `BaseSettings`): `jwt_secret_key: str` (required — no default, startup fails if absent), `jwt_expiry_seconds: int = 86400`, `owner_username: str` (required), `owner_password: str` (required); confirm `get_settings()` still loads from env vars via the existing `SettingsConfigDict`
|
||||
|
||||
- [X] T003 [P] Update `api/app/auth/provider.py`: change `get_identity(self)` to `get_identity(self, authorization: str | None) -> Identity`; this is a breaking interface change that will cause the `NoOpAuthProvider` to fail type-checking until T004 is done
|
||||
|
||||
- [X] T004 [P] Update `api/app/auth/noop.py`: match the new `get_identity(self, authorization: str | None) -> Identity` signature; the implementation still returns `_ANONYMOUS` and ignores `authorization`; run `pytest api/` to confirm all existing tests still pass (the conftest overrides get_auth so the interface change is invisible to running tests)
|
||||
|
||||
- [X] T005 Update `api/tests/integration/conftest.py`: add a `# TODO: complete after T007` comment block where the `jwt_auth_provider` and `authed_client` fixtures will live — do not add the import of `JWTAuthProvider` yet (it does not exist until T007 and would break `pytest api/` if imported now); the existing `client` fixture (with `NoOpAuthProvider`) must remain unchanged. After T007 is done, complete this task: add `jwt_auth_provider` fixture that constructs a `JWTAuthProvider` with test credentials, and `authed_client` fixture that overrides `get_auth` with that provider and yields `(client, valid_token)` where `valid_token = auth.create_token()`
|
||||
|
||||
**Checkpoint**: PyJWT installed, four new settings wired, interface updated, `NoOpAuthProvider` adapted, conftest ready. All existing tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (JWTAuthProvider — Blocks All Stories)
|
||||
|
||||
**Purpose**: The `JWTAuthProvider` must exist before the login endpoint (US1),
|
||||
protected endpoints (US2), or public-read regression tests (US3) can be built.
|
||||
|
||||
**⚠️ CRITICAL**: All tasks in this phase must complete before any user story work begins.
|
||||
|
||||
### Tests for JWTAuthProvider (write FIRST — must FAIL before T007) ⚠️
|
||||
|
||||
- [X] T006 Write 10 unit tests in `api/tests/unit/test_jwt_auth.py` for the (not-yet-existing) `JWTAuthProvider`: `test_create_token_is_valid_jwt` (minted token decodes with PyJWT without error), `test_get_identity_returns_owner` (valid bearer token → non-anonymous `Identity` with `id="owner"`), `test_get_identity_raises_on_expired_token` (token with past `exp` → `HTTPException` 401), `test_get_identity_raises_on_wrong_key` (token signed with different secret → 401), `test_get_identity_raises_on_garbage` (random string as token value → 401), `test_get_identity_raises_on_missing_header` (`authorization=None` → 401), `test_get_identity_raises_on_missing_bearer_prefix` (`"token-without-prefix"` → 401), `test_verify_credentials_true` (matching username + password → `True`), `test_verify_credentials_false_wrong_password` (wrong password → `False`), `test_verify_credentials_false_wrong_username` (wrong username → `False`); run `pytest api/tests/unit/test_jwt_auth.py` and confirm all 10 **fail** with `ImportError` or `AttributeError` (not-yet-implemented)
|
||||
|
||||
### JWTAuthProvider implementation
|
||||
|
||||
- [X] T007 Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider(AuthProvider)`: constructor takes `secret_key: str`, `expiry_seconds: int`, `owner_username: str`, `owner_password: str`; implement `create_token() -> str` using `jwt.encode({"sub": "owner", "iat": now, "exp": now + expiry_seconds}, secret_key, algorithm="HS256")`; implement `verify_credentials(username, password) -> bool` using `secrets.compare_digest`; implement `get_identity(authorization: str | None) -> Identity` — parse `"Bearer <token>"` (raise 401 `unauthorized` if missing or wrong prefix), decode with `jwt.decode()` (raise 401 on `ExpiredSignatureError`, `InvalidTokenError`, or any exception), return `Identity(id="owner", anonymous=False)` on success; run `pytest api/tests/unit/test_jwt_auth.py` and confirm all 10 pass; then run `pytest api/` to confirm no regressions
|
||||
|
||||
**Checkpoint**: `JWTAuthProvider` is fully implemented and tested. Login endpoint, protected-endpoint guard, and conftest fixtures can now be built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Log In and Receive a Token (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: The owner can log in with username and password and receive a bearer
|
||||
token. The Angular SPA has a working login page backed by a real API endpoint.
|
||||
|
||||
**Independent Test**: POST `{"username": "owner", "password": "correct"}` to
|
||||
`/api/v1/auth/token`. Confirm a `200` response with a non-empty `access_token`.
|
||||
Then open the browser, enter credentials in the login form, and confirm navigation
|
||||
to the library. Subsequent protected requests in the browser include the token.
|
||||
|
||||
### Tests for User Story 1 API (write FIRST — must FAIL before T009) ⚠️
|
||||
|
||||
- [X] T008 [US1] Write 5 integration tests in `api/tests/integration/test_auth.py` for the (not-yet-existing) `POST /api/v1/auth/token` endpoint: `test_login_success` (POST valid creds → 200, response has `access_token` as non-empty string, `token_type="bearer"`, `expires_in > 0`), `test_login_wrong_password` (correct username, wrong password → 401, code `invalid_credentials`), `test_login_wrong_username` (wrong username → 401, code `invalid_credentials`), `test_login_missing_password` (body `{"username": "x"}` → 422), `test_login_missing_username` (body `{"password": "x"}` → 422); these tests should use a fixture with the `JWTAuthProvider` override; run `pytest api/tests/integration/test_auth.py` and confirm all 5 **fail** with `404` (route not yet registered)
|
||||
|
||||
### Implementation for User Story 1 (API)
|
||||
|
||||
- [X] T009 [US1] In `api/app/dependencies.py`, add `get_jwt_auth() -> JWTAuthProvider` — a typed dependency that returns the same `JWTAuthProvider` instance as `get_auth()` but with the concrete type, so the auth router can call `verify_credentials()` and `create_token()` without a downcast (the login endpoint is inherently tied to token issuance and is replaced wholesale in Phase 3, so it is correct for it to depend on the concrete type rather than the `AuthProvider` abstraction). Then create `api/app/routers/auth.py`: define `LoginRequest` Pydantic model (`username: str`, `password: str`), define `TokenResponse` Pydantic model (`access_token: str`, `token_type: str = "bearer"`, `expires_in: int`), add `POST /auth/token` route that injects `auth: JWTAuthProvider = Depends(get_jwt_auth)` — calls `auth.verify_credentials(username, password)`, raises `HTTPException(401, {"detail": "Invalid credentials", "code": "invalid_credentials"})` on failure, calls `auth.create_token()` and returns `TokenResponse` on success; complete T005's `conftest.py` `jwt_auth_provider` fixture import now that the module exists
|
||||
|
||||
- [X] T010 [US1] Update `api/app/dependencies.py`: in `get_auth()`, replace `NoOpAuthProvider()` with `JWTAuthProvider(secret_key=s.jwt_secret_key, expiry_seconds=s.jwt_expiry_seconds, owner_username=s.owner_username, owner_password=s.owner_password)` (loading settings via `get_settings()`); the existing `client` fixture in `conftest.py` still overrides `get_auth` with `NoOpAuthProvider`, so all existing tests remain unaffected
|
||||
|
||||
- [X] T011 [US1] Update `api/app/main.py`: import `auth` router from `app.routers.auth` and register it with `app.include_router(auth.router, prefix="/api/v1")`; run `pytest api/tests/integration/test_auth.py` and confirm all 5 tests pass; run `pytest api/` and confirm no regressions
|
||||
|
||||
### Tests for User Story 1 (Angular — write FIRST — must FAIL before T014) ⚠️
|
||||
|
||||
- [X] T012 [P] [US1] Write 3 unit tests in `ui/src/app/auth/auth.service.spec.ts` for the (not-yet-existing) `AuthService`: `test_login_stores_token` (mock `HttpClient` POST returning `{access_token: "tok"}`, verify `sessionStorage.getItem("auth_token") === "tok"` after `login()` completes), `test_isAuthenticated_true_when_token_present` (set token in sessionStorage, assert `isAuthenticated()` returns true), `test_isAuthenticated_false_when_no_token` (clear sessionStorage, assert `isAuthenticated()` returns false); run `ng test` and confirm all 3 **fail** with `Cannot find module` or similar. Note: logout tests belong to US4 and are written in T025.
|
||||
|
||||
- [X] T013 [P] [US1] Write 4 unit tests in `ui/src/app/login/login.component.spec.ts` for the (not-yet-existing) `LoginComponent`: `test_submit_calls_auth_service_login` (spy on `AuthService.login`, fill form, submit, verify `login` called with correct username and password), `test_navigates_to_library_on_success` (mock `AuthService.login` returning `of(void 0)`, submit, verify `Router.navigate` called with `['/']`), `test_shows_error_on_401` (mock `AuthService.login` throwing `HttpErrorResponse` with status 401, submit, verify error message element is visible in the template), `test_shows_validation_error_on_empty_fields` (disable browser-native validation via `novalidate`, leave username and password blank, click submit, verify no `HttpClient.post` call was made and a validation error element is visible in the DOM); run `ng test` and confirm all 4 **fail**
|
||||
|
||||
### Implementation for User Story 1 (Angular)
|
||||
|
||||
- [X] T014 [P] [US1] Create `ui/src/app/auth/auth.service.ts`: `TOKEN_KEY = 'auth_token'`; `login(username: string, password: string): Observable<void>` — POST `/api/v1/auth/token`, pipe `tap(res => sessionStorage.setItem(this.TOKEN_KEY, res.access_token))`, map to void; `logout(): void` — `sessionStorage.removeItem(this.TOKEN_KEY)`; `getToken(): string | null` — `sessionStorage.getItem(this.TOKEN_KEY)`; `isAuthenticated(): boolean` — `this.getToken() !== null`; decorate with `@Injectable({ providedIn: 'root' })`
|
||||
|
||||
- [X] T015 [P] [US1] Create `ui/src/app/login/login.component.ts` (standalone component, route `/login`): reactive form with `username` (required) and `password` (required) validators; `onSubmit()` calls `AuthService.login()`, sets `loading = true` while in-flight, on success reads `returnUrl` query param (default `'/'`) and calls `router.navigateByUrl(returnUrl)`, on error sets `errorMessage = 'Invalid username or password'`; template (`login.component.html`) includes a form with username input, password input, submit button (disabled while loading), and an error paragraph (`*ngIf="errorMessage"`)
|
||||
|
||||
- [X] T016 [US1] Update `ui/src/app/app.routes.ts`: add `{ path: 'login', loadComponent: () => import('./login/login.component').then(m => m.LoginComponent) }` before the wildcard route; run `ng test` and confirm T012 and T013 tests now pass; run `ng build` to confirm no build errors
|
||||
|
||||
**Checkpoint**: `POST /api/v1/auth/token` works end-to-end. Angular login form posts credentials, stores token, navigates to library. All 12 new tests (5 API + 3 Angular service + 4 Angular login) pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Protected Write Actions Require Authentication (Priority: P1)
|
||||
|
||||
**Goal**: Upload, delete, and tag-update endpoints reject unauthenticated requests
|
||||
with a `401`. Angular automatically attaches the stored token to all requests.
|
||||
|
||||
**Independent Test**: Without logging in, attempt `POST /api/v1/images` — confirm
|
||||
`401` with code `unauthorized`. Log in, then upload again — confirm `200/201`. In
|
||||
the browser, verify that after login the upload form submits successfully.
|
||||
|
||||
### Tests for User Story 2 API (write FIRST — must FAIL before T019) ⚠️
|
||||
|
||||
- [X] T017 [US2] Add 6 integration tests using the `authed_client` fixture across existing test files: in `api/tests/integration/test_upload.py` add `test_upload_without_token_returns_401` (POST with no `Authorization` header → 401, code `unauthorized`) and `test_upload_with_valid_token_succeeds` (POST with `Authorization: Bearer <token>` → 200/201); in `api/tests/integration/test_delete.py` add `test_delete_without_token_returns_401` (DELETE with no token → 401) and `test_delete_with_valid_token_succeeds` (DELETE with valid token → 204); add `test_patch_tags_without_token_returns_401` (PATCH `/images/{id}/tags` with no token → 401) and `test_patch_tags_with_valid_token_succeeds` (PATCH with valid token → 200) to `api/tests/integration/test_upload.py` (or a new `test_protected.py`); run these 6 tests and confirm they all **fail** (currently return 200/204 without auth, or fixture not yet usable)
|
||||
|
||||
### Implementation for User Story 2 (API)
|
||||
|
||||
- [X] T018 [US2] Add `require_auth` async dependency to `api/app/dependencies.py`: `async def require_auth(authorization: str | None = Header(None, alias="Authorization"), auth: AuthProvider = Depends(get_auth)) -> Identity` — calls `await auth.get_identity(authorization)`, raises `HTTPException(401, {"detail": "Authentication required", "code": "unauthorized"})` if `identity.anonymous` is True, otherwise returns `Identity`
|
||||
|
||||
- [X] T019 [US2] In `api/app/routers/images.py`: add `_: Identity = Depends(require_auth)` parameter to `upload_image()`, `delete_image()`, and `update_image_tags()`; also remove the existing `auth: AuthProvider = Depends(get_auth)` from `upload_image()` (it was injected but never called — `require_auth` now subsumes it); add `from app.dependencies import require_auth` and `from app.auth.provider import Identity` to imports; run `pytest api/tests/integration/` and confirm all 6 new protected-endpoint tests pass and all pre-existing tests (which use the `client` fixture with `NoOpAuthProvider` override) still pass
|
||||
|
||||
### Tests for User Story 2 (Angular — write FIRST — must FAIL before T021) ⚠️
|
||||
|
||||
- [X] T020 [US2] Write 3 unit tests in `ui/src/app/auth/auth.interceptor.spec.ts` for the (not-yet-existing) `authInterceptor`: `test_adds_auth_header_when_authenticated` (configure `TestBed` with `authInterceptor`, spy `AuthService.getToken()` returning `"test-token"`, make any HTTP request via `HttpClient`, verify the outgoing request in `HttpTestingController` has header `Authorization: Bearer test-token`), `test_no_auth_header_when_not_authenticated` (spy `AuthService.getToken()` returning `null`, make HTTP request, verify `Authorization` header is absent), `test_interceptor_redirects_to_login_on_401` (spy `AuthService.getToken()` returning `"test-token"`, flush the HTTP response with status 401, spy `AuthService.logout()` and `Router.navigate`, verify `logout()` was called and `router.navigate(['/login'])` was called); run `ng test` and confirm all 3 **fail**
|
||||
|
||||
### Implementation for User Story 2 (Angular)
|
||||
|
||||
- [X] T021 [US2] Create `ui/src/app/auth/auth.interceptor.ts` as a functional interceptor that handles both outbound token injection and inbound 401 responses: `export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); const token = auth.getToken(); if (token) { req = req.clone({ setHeaders: { Authorization: \`Bearer \${token}\` } }); } return next(req).pipe(catchError(err => { if (err instanceof HttpErrorResponse && err.status === 401) { auth.logout(); router.navigate(['/login']); } return throwError(() => err); })); }`; import `catchError`, `throwError` from `rxjs/operators` and `HttpErrorResponse` from `@angular/common/http`
|
||||
|
||||
- [X] T022 [US2] Update `ui/src/app/app.config.ts`: change `provideHttpClient()` to `provideHttpClient(withInterceptors([authInterceptor]))`; add the necessary imports; run `ng test` and confirm T020's 3 tests now pass; run `ng build` to confirm no build errors; run `pytest api/` to confirm all API tests still pass
|
||||
|
||||
**Checkpoint**: Upload, delete, and tag-update reject unauthenticated requests. The Angular interceptor attaches the token on outbound requests and redirects to `/login` on any 401 response (covering expired mid-session tokens). All 9 new tests (6 API + 3 Angular) pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Public Read Access (Priority: P1)
|
||||
|
||||
**Goal**: All read endpoints remain accessible without any credential. Verify no
|
||||
401 regression was introduced by the changes in US2.
|
||||
|
||||
**Independent Test**: Without providing any `Authorization` header, call
|
||||
`GET /api/v1/images`, `GET /api/v1/images/{id}`, `GET /api/v1/images/{id}/file`,
|
||||
`GET /api/v1/images/{id}/thumbnail`, and `GET /api/v1/tags`. All must return `200`.
|
||||
|
||||
### Tests for User Story 3 (write FIRST — must FAIL before T024) ⚠️
|
||||
|
||||
- [X] T023 [US3] Add 5 regression integration tests using the `authed_client` fixture (which uses `JWTAuthProvider` but no `Authorization` header in the request) in a new `api/tests/integration/test_public_access.py`: `test_list_images_without_token_is_200` (GET `/api/v1/images` with no auth header → 200), `test_get_image_without_token_is_200` (upload image first using `authed_client` with token, then GET `/api/v1/images/{id}` with no auth header → 200), `test_serve_file_without_token_is_200` (GET `/api/v1/images/{id}/file` with no auth header → 200), `test_serve_thumbnail_without_token_is_200` (GET `/api/v1/images/{id}/thumbnail` with no auth header → 200), `test_list_tags_without_token_is_200` (GET `/api/v1/tags` with no auth header → 200); run these tests and confirm they all **fail** (they will fail until T019 is complete because the `authed_client` fixture may not yet be fully wired — or they may pass if the fixture is ready, in which case document as already-green)
|
||||
|
||||
### Verification for User Story 3
|
||||
|
||||
- [X] T024 [US3] Run `pytest api/tests/integration/test_public_access.py` and confirm all 5 pass; run `pytest api/ -v` to confirm the full API suite passes without regressions; document the passing test count
|
||||
|
||||
**Checkpoint**: All public read endpoints confirmed accessible without a token. No 401 regression introduced by the protected-write changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Log Out (Priority: P2)
|
||||
|
||||
**Goal**: The owner can end their session. After logout, the token is gone from
|
||||
the browser and the upload page redirects to login.
|
||||
|
||||
**Independent Test**: Log in, verify the upload page is accessible. Click the
|
||||
logout control. Verify the application navigates to `/login`. Navigate directly
|
||||
to `/upload` — confirm redirect to `/login`.
|
||||
|
||||
### Tests for User Story 4 (write FIRST — must FAIL before T027) ⚠️
|
||||
|
||||
- [X] T025 [P] [US4] Add 2 unit tests to `ui/src/app/auth/auth.service.spec.ts`: `test_logout_removes_token_from_storage` (set a token in sessionStorage, call `logout()`, confirm `sessionStorage.getItem("auth_token")` is null), `test_isAuthenticated_false_after_logout` (set token, call `logout()`, confirm `isAuthenticated()` returns false); these tests cover logout behaviour which belongs to US4 and was intentionally excluded from T012; `logout()` is implemented in T014 so these tests should pass immediately — confirm they pass before proceeding
|
||||
|
||||
- [X] T026 [P] [US4] Write 1 unit test in a new `ui/src/app/auth/auth.guard.spec.ts` for the (not-yet-existing) `authGuard`: `test_redirects_to_login_when_not_authenticated` — configure `TestBed` with `provideRouter([])` and `provideLocationMocks()` (standalone Angular 17+ pattern; do NOT use the deprecated `RouterTestingModule`), spy `AuthService.isAuthenticated()` returning `false`, execute the guard function directly with a mock `ActivatedRouteSnapshot` and `RouterStateSnapshot` with `url = '/upload'`, assert the returned value is a `UrlTree` whose `toString()` starts with `/login`; run `ng test` and confirm it **fails**
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T027 [P] [US4] Create `ui/src/app/auth/auth.guard.ts` as a functional `CanActivateFn`: inject `AuthService` and `Router`; if `auth.isAuthenticated()` return `true`; else return `router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })`
|
||||
|
||||
- [X] T028 [P] [US4] Update `ui/src/app/app.routes.ts`: add `canActivate: [authGuard]` to the `/upload` route entry; add import for `authGuard`; add `CanActivateFn` guard to the route object
|
||||
|
||||
- [X] T029 [P] [US4] Update `ui/src/app/detail/detail.component.ts`: inject `AuthService` as `public auth: AuthService`; in the template, wrap the tag-edit input block and the delete button with `*ngIf="auth.isAuthenticated()"` so they are hidden for unauthenticated visitors; the image display and read-only tag chips remain visible to all
|
||||
|
||||
- [X] T030 [US4] Add a logout link/button to the application shell (`ui/src/app/app.component.ts` and its template): inject `AuthService` and `Router`; add `onLogout()` method that calls `auth.logout()` then `router.navigate(['/login'])`; render the button only when `auth.isAuthenticated()` is true; run `ng test` and confirm T025 and T026 tests pass; run `ng build` to confirm no build errors
|
||||
|
||||
**Checkpoint**: Logout works. Upload page is guarded. Detail page hides write controls for unauthenticated visitors. All 3 new Angular tests (2 service + 1 guard) pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Environment documentation, final linting, and complete test run.
|
||||
|
||||
- [X] T031 [P] Update `.env.example`: add four new variables with comments:
|
||||
```
|
||||
# Owner credentials and JWT signing secret
|
||||
JWT_SECRET_KEY=change-me-to-a-long-random-string
|
||||
JWT_EXPIRY_SECONDS=86400
|
||||
OWNER_USERNAME=owner
|
||||
OWNER_PASSWORD=change-me
|
||||
```
|
||||
|
||||
- [X] T032 [P] Run `~/.local/bin/ruff check api/app/auth/jwt_provider.py api/app/routers/auth.py api/app/dependencies.py api/app/config.py api/app/routers/images.py` and `ruff format --check` on the same files; fix any lint or formatting violations
|
||||
|
||||
- [X] T033 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: ~57 existing + ~18 new ≈ 75 total)
|
||||
|
||||
- [X] T034 Run `ng test` inside the UI container (or locally) and confirm all Angular unit tests pass; run `ng build` and confirm the Angular build succeeds with no errors
|
||||
|
||||
- [X] T035 End-to-end smoke test: `docker compose up`, open the browser, verify: (a) the library loads without login, (b) navigating to `/upload` redirects to `/login`, (c) logging in navigates to the library, (d) uploading an image succeeds, (e) logging out redirects to `/login`, (f) attempting `/upload` again redirects to `/login`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 complete (PyJWT must be installed before tests can import `JWTAuthProvider`)
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 complete (`JWTAuthProvider` must exist before login endpoint tests reference it)
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 complete (Angular interceptor needs `AuthService`; API `require_auth` needs `JWTAuthProvider`)
|
||||
- **US3 (Phase 5)**: Depends on Phase 4 complete (`require_auth` must be wired before public-access regression tests are meaningful)
|
||||
- **US4 (Phase 6)**: Depends on Phase 3 complete (`AuthService.logout()` may already be implemented in T014; guard and route changes depend on login route existing from T016)
|
||||
- **Polish (Phase 7)**: Depends on all feature phases complete
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- T006 (write failing tests) MUST precede T007 (implement JWTAuthProvider)
|
||||
- T008 (write failing API auth tests) MUST precede T009 (implement login route)
|
||||
- T012 and T013 (write failing Angular tests) MUST precede T014 and T015
|
||||
- T017 (write failing 401 tests) MUST precede T018 + T019
|
||||
- T020 (write failing interceptor tests) MUST precede T021
|
||||
- T023 (write failing public-access tests) MUST precede T024
|
||||
- T025 and T026 (write failing US4 tests) MUST precede T027–T030
|
||||
|
||||
### Parallel Opportunities (within phases)
|
||||
|
||||
- T003 and T004 can run in parallel (different files in `api/app/auth/`)
|
||||
- T009, T010, T011 are sequential (dependencies.py → auth.py → main.py)
|
||||
- T012 and T013 can run in parallel (different spec files)
|
||||
- T014 and T015 can run in parallel after T012 and T013 (different component files)
|
||||
- T020 MUST precede T021 (TDD: confirm 3 tests fail before implementing interceptor)
|
||||
- T025 and T026 can run in parallel (different spec files)
|
||||
- T027, T028, T029 can run in parallel (different files: guard, routes, detail component)
|
||||
- T031 and T032 can run in parallel
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 1 (Setup)
|
||||
|
||||
```bash
|
||||
# T003 and T004 touch different files — run together:
|
||||
Task: "Update AuthProvider interface signature in api/app/auth/provider.py"
|
||||
Task: "Update NoOpAuthProvider signature in api/app/auth/noop.py"
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 3 / US1 (Angular)
|
||||
|
||||
```bash
|
||||
# T012 and T013 touch different spec files — run together:
|
||||
Task: "Write 3 failing AuthService unit tests in ui/src/app/auth/auth.service.spec.ts"
|
||||
Task: "Write 4 failing LoginComponent unit tests in ui/src/app/login/login.component.spec.ts"
|
||||
|
||||
# T014 and T015 touch different source files — run together after T012/T013:
|
||||
Task: "Create AuthService in ui/src/app/auth/auth.service.ts"
|
||||
Task: "Create LoginComponent in ui/src/app/login/login.component.ts"
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 4 / US2 (Angular)
|
||||
|
||||
```bash
|
||||
# T020 MUST precede T021 (TDD). Within US2 they are sequential.
|
||||
# T020 can run in parallel with other US2 API tasks (T017, T018, T019 touch different files):
|
||||
Task: "Write 3 failing interceptor tests in ui/src/app/auth/auth.interceptor.spec.ts" # T020
|
||||
# (after T020 confirms failing):
|
||||
Task: "Create authInterceptor in ui/src/app/auth/auth.interceptor.ts" # T021
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 6 / US4
|
||||
|
||||
```bash
|
||||
# T027, T028, T029 touch different files — run together:
|
||||
Task: "Create authGuard in ui/src/app/auth/auth.guard.ts"
|
||||
Task: "Add canActivate guard to /upload route in ui/src/app/app.routes.ts"
|
||||
Task: "Conditionally show write controls in ui/src/app/detail/detail.component.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (User Stories 1 + 2 + 3 — minimum shippable auth)
|
||||
|
||||
All three P1 stories are interdependent: login (US1) enables write-protection
|
||||
(US2), and write-protection must not break public reads (US3). Complete phases
|
||||
in order:
|
||||
|
||||
1. Phase 1: Setup (T001–T005)
|
||||
2. Phase 2: Foundational JWT provider (T006–T007)
|
||||
3. Phase 3: US1 Login API + Angular (T008–T016)
|
||||
4. Phase 4: US2 Protected writes API + Angular interceptor (T017–T022)
|
||||
5. Phase 5: US3 Public-read regression (T023–T024)
|
||||
6. **STOP and VALIDATE**: Login, upload (authenticated), and public browse all work
|
||||
|
||||
### Incremental add-on: Logout (US4)
|
||||
|
||||
Once MVP is validated, add Phase 6 (T025–T030) to complete the session
|
||||
lifecycle. This is independently addable without revisiting previous phases.
|
||||
|
||||
### Total tasks: 35 (T001–T035)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and have no mutual dependencies within their phase
|
||||
- T006, T008, T012, T013, T017, T020, T023, T025, T026 are all "write failing test" steps — always confirm failure before implementing
|
||||
- The `client` fixture in `conftest.py` uses `NoOpAuthProvider` and MUST NOT be changed — all existing tests depend on it passing without a token
|
||||
- The `authed_client` fixture returns `(client, valid_token)` — tests choose whether to include the token, enabling both 401 and success scenarios from the same fixture
|
||||
- The `authInterceptor` attaches the token unconditionally to all requests; the API silently ignores the `Authorization` header on public endpoints — no URL matching needed in the interceptor
|
||||
- Logout in the UI invalidates the client-side session only; the JWT technically remains valid until its `exp` (acceptable for a single-user local app with no token revocation)
|
||||
0
specs/005-ui-polish/SHIPPED
Normal file
0
specs/005-ui-polish/SHIPPED
Normal file
34
specs/005-ui-polish/checklists/requirements.md
Normal file
34
specs/005-ui-polish/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: UI Polish & Design System
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit-plan`.
|
||||
242
specs/005-ui-polish/plan.md
Normal file
242
specs/005-ui-polish/plan.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Implementation Plan: UI Polish & Design System
|
||||
|
||||
**Branch**: `005-ui-polish` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/005-ui-polish/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Refine the existing Angular SPA from functional-but-bare to intentional and
|
||||
finished. All changes are purely front-end: a shared design-token layer
|
||||
(CSS custom properties) is introduced in `styles.css`, and each of the five
|
||||
views (library, upload, detail, login, app shell) is updated to use those tokens
|
||||
and to handle loading, empty, and error states consistently. No new dependencies,
|
||||
no new API endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5 / Angular 19 (standalone components, no NgModules)
|
||||
**Primary Dependencies**: Angular 19, RxJS 7 (already installed; no new deps added)
|
||||
**Storage**: N/A — UI-only feature
|
||||
**Testing**: Karma / Jasmine (Angular CLI default; `npm test`)
|
||||
**Target Platform**: Browser SPA (desktop-primary, 375 px minimum viewport)
|
||||
**Project Type**: Web application — UI layer only
|
||||
**Performance Goals**: Loading indicators must not flash on sub-150 ms responses
|
||||
**Constraints**: No new npm dependencies; no external icon or component library
|
||||
**Scale/Scope**: Five component files + one global CSS file
|
||||
|
||||
---
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.1 Strict separation of concerns — UI knows nothing about storage or DB | ✅ Pass | No API or storage changes |
|
||||
| §2.2 Dependency direction — UI → API only | ✅ Pass | No new API calls introduced |
|
||||
| §2.3 Storage abstraction | ✅ Pass | Not touched |
|
||||
| §2.4 Auth abstraction — identity resolution via AuthProvider | ✅ Pass | Auth logic unchanged; FR-006 (hide write controls) already implemented |
|
||||
| §2.6 No speculative abstraction | ✅ Pass | Tokens centralised because all five views use them; no hypothetical interfaces |
|
||||
| §3.3 Error shape | ✅ Pass | UI consumes existing error envelopes; no API change |
|
||||
| §5.1 TDD non-negotiable | ✅ Pass | All template and state changes will have Angular component tests written first |
|
||||
| §5.2 Test pyramid | ✅ Pass | Unit tests (Karma) cover state logic; E2E visual check is the acceptance gate |
|
||||
| §6 Tech stack | ✅ Pass | Angular + TypeScript; no new languages or frameworks added |
|
||||
| §7.2 No hardcoded values | ✅ Pass | Colours/spacing moved to CSS custom properties, not hardcoded further |
|
||||
| §7.3 Linting non-optional | ✅ Pass | ESLint + Prettier enforced; `ng build` type-check must pass |
|
||||
| §8 Scope boundaries | ✅ Pass | UI-only; no multi-user, no OR/NOT tags, no OIDC |
|
||||
|
||||
**Constitution Check result: ALL GATES PASS**
|
||||
|
||||
No violations. No complexity justification table required.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/005-ui-polish/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← Phase 0 output (complete)
|
||||
├── quickstart.md ← Phase 1 output (visual acceptance scenarios)
|
||||
└── tasks.md ← Phase 2 output (/speckit-tasks — not yet created)
|
||||
```
|
||||
|
||||
*No `data-model.md` or `contracts/` — this feature introduces no new data
|
||||
entities and no API surface changes.*
|
||||
|
||||
### Source Code (affected files)
|
||||
|
||||
```text
|
||||
ui/
|
||||
└── src/
|
||||
├── styles.css ← Add CSS custom properties (design tokens)
|
||||
└── app/
|
||||
├── app.component.ts ← Polish header shell
|
||||
├── library/
|
||||
│ └── library.component.ts ← Skeleton load, empty state, error state, card polish
|
||||
├── upload/
|
||||
│ └── upload.component.ts ← Drop-zone polish, in-progress state, success/error states
|
||||
├── detail/
|
||||
│ └── detail.component.ts ← Loading state, not-found state, section organisation
|
||||
└── login/
|
||||
└── login.component.ts ← Visual alignment with design system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
### Milestone 1 — Design Token Layer (blocks all other milestones)
|
||||
|
||||
Extract the shared colour, spacing, and motion values already present across the
|
||||
five components into CSS custom properties on `:root` in `styles.css`.
|
||||
|
||||
**Deliverable**: `:root` block in `styles.css` with 13 named tokens (see
|
||||
research.md Decision 5). Each existing component still renders identically
|
||||
(tokens match current hard-coded values exactly). `ng build` passes.
|
||||
|
||||
**Token set**:
|
||||
```
|
||||
--bg, --surface, --surface-raised, --border, --border-focus,
|
||||
--text, --text-muted, --accent, --accent-text, --danger, --danger-text,
|
||||
--radius, --radius-chip, --transition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Milestone 2 — Library View (US1)
|
||||
|
||||
**Loading state**: Replace the current `loading = true` boolean with the
|
||||
150 ms–debounced spinner pattern (see research.md Decision 3). While loading,
|
||||
render a grid of skeleton cards (same dimensions as real cards) using the
|
||||
shimmer CSS class (see research.md Decision 2).
|
||||
|
||||
**Empty state**: The existing empty-state `<p>` is already functional. Polish it:
|
||||
centred layout, muted icon (✦ or similar Unicode), larger text, and a prominent
|
||||
"Upload your first image" link that navigates to `/upload`.
|
||||
|
||||
**Error state**: Add an `error: boolean` flag to the component. If the `list()`
|
||||
call errors, set `error = true` and render an error card with a retry button
|
||||
that calls `load()` again.
|
||||
|
||||
**Card polish**: Apply tokens to card background, border-radius, and tag chips.
|
||||
Add a subtle `box-shadow` and `transform: translateY(-2px)` on hover (using
|
||||
`--transition`). Ensure the card thumbnail `<img>` has an `(error)` fallback
|
||||
(see research.md Decision 4).
|
||||
|
||||
**Responsive**: The existing `auto-fill minmax(200px, 1fr)` grid already handles
|
||||
narrow viewports. Verify it does not overflow at 375 px; reduce min card width
|
||||
to 160 px if needed.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 3 — Upload View (US2)
|
||||
|
||||
**Drop-zone polish**: Apply token-based border and background to the existing
|
||||
drag-and-drop zone. Add a dashed border accent colour (`--accent` at 40%
|
||||
opacity) on active drag state.
|
||||
|
||||
**In-progress state**: The existing `loading` flag already disables the button.
|
||||
Add a visible spinner or animated label ("Uploading…") inside the button while
|
||||
in-flight so the state change is unmistakable.
|
||||
|
||||
**Success state**: After a successful upload, show a brief success banner
|
||||
(green-tinted surface, tick character) with a "Upload another" link and a "View
|
||||
in library" link. Auto-dismiss after 4 seconds or on navigation.
|
||||
|
||||
**Error states**: Distinct messages for validation errors (wrong type/size —
|
||||
already returned by API) vs. network/server errors (generic retry). Both
|
||||
displayed inline below the form, not in a modal.
|
||||
|
||||
**Double-submit prevention**: Already implemented (button disabled while
|
||||
`loading`). Confirm the disabled style is visually clear using `--text-muted`
|
||||
and reduced opacity.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 4 — Detail View (US3)
|
||||
|
||||
**Loading state**: Add a skeleton layout while `loading = true`: a grey
|
||||
rectangle at full width for the image area, and two skeleton chip rows below.
|
||||
|
||||
**Not-found state**: The existing `!image && !loading` condition renders a
|
||||
plain text paragraph. Replace with a styled not-found card: centred layout,
|
||||
muted icon, "Image not found" heading, and a "Back to library" button.
|
||||
|
||||
**Section organisation**: Visually separate the image area, tags section, and
|
||||
write controls with consistent spacing using `--surface` panels and token-based
|
||||
gaps. Write controls (tag input + delete button) should be grouped in a visually
|
||||
distinct "Owner actions" area when visible.
|
||||
|
||||
**Tag error**: The existing `tagError` renders inline. Apply `--danger` colour
|
||||
and a left border accent to make it unmistakable.
|
||||
|
||||
**Broken image**: Add `(error)` handler on the full-size `<img>` in the detail
|
||||
view (inline SVG placeholder showing a broken-link icon).
|
||||
|
||||
---
|
||||
|
||||
### Milestone 5 — Login View (US4)
|
||||
|
||||
Apply the token-based design system to the login form:
|
||||
- Centre the card vertically and horizontally on the page
|
||||
- Wrap the form in a `--surface` card with `--radius` and a subtle border
|
||||
- Use token-based input styles matching the library filter bar
|
||||
- Display field-level validation errors using `--danger` colour
|
||||
- The submit button uses the same `--accent` style as the library upload button
|
||||
- In-progress state: button text changes to "Signing in…", button disabled
|
||||
|
||||
No layout changes to the existing reactive-form structure.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 6 — App Shell (US5)
|
||||
|
||||
The existing `app.component.ts` header already conditionally renders the
|
||||
sign-out button. Polish:
|
||||
- Slim top bar: `--surface` background, bottom border using `--border`
|
||||
- App name / logo mark on the left (text only, no image asset)
|
||||
- Sign-out button aligned right using `--text-muted` colour and a simple
|
||||
hover state
|
||||
- Header height: `48px` fixed; does not reflow page content on state change
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
Milestone 1 (tokens)
|
||||
↓
|
||||
Milestone 2 (library) ─┐
|
||||
Milestone 3 (upload) ├─ can proceed in parallel after M1
|
||||
Milestone 4 (detail) │
|
||||
Milestone 5 (login) ─┘
|
||||
Milestone 6 (shell) ← last (touches app.component which wraps all views)
|
||||
```
|
||||
|
||||
M2–M5 are independent of each other (different component files). M6 is last
|
||||
because the app shell wraps all views and its final state is easiest to validate
|
||||
once the inner views are stable.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Unit tests (Karma/Jasmine)**:
|
||||
- All new state variables (`error`, `showSpinner`, skeleton visibility) are
|
||||
tested via component spec files.
|
||||
- Template conditionals (`*ngIf="error"`, `*ngIf="loading"`) are verified with
|
||||
fixture queries.
|
||||
- The `(error)` image fallback handler is tested by simulating an error event.
|
||||
- Existing tests must continue to pass — no regressions.
|
||||
|
||||
**Visual acceptance (manual, quickstart.md)**:
|
||||
- Each milestone has a corresponding scenario in quickstart.md.
|
||||
- Visual checks are performed in a running `docker compose up` stack.
|
||||
- 375 px viewport check: Chrome DevTools → device toolbar → iPhone SE.
|
||||
|
||||
**Build gate**: `ng build` must pass with zero errors after every milestone.
|
||||
155
specs/005-ui-polish/quickstart.md
Normal file
155
specs/005-ui-polish/quickstart.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Quickstart: UI Polish Visual Acceptance Scenarios
|
||||
|
||||
Use this guide to manually verify each milestone after implementation.
|
||||
Run `docker compose up` before starting. Open the browser at `http://localhost:4200`.
|
||||
|
||||
---
|
||||
|
||||
## M1 — Design Token Layer
|
||||
|
||||
**Goal**: Tokens exist; visual output is identical to before.
|
||||
|
||||
1. Open the library, upload, detail, and login pages.
|
||||
2. Open browser DevTools → Elements → `<html>` or `<body>`.
|
||||
3. Confirm `--bg`, `--surface`, `--accent`, `--danger` etc. are visible in
|
||||
computed styles.
|
||||
4. Confirm no visible change in any view compared to before M1.
|
||||
|
||||
---
|
||||
|
||||
## M2 — Library View
|
||||
|
||||
### Loading skeleton
|
||||
1. Open DevTools → Network → set throttle to "Slow 3G".
|
||||
2. Hard-refresh the library page.
|
||||
3. **Expect**: A grid of grey shimmer cards appears immediately; no blank white
|
||||
space; no layout jump when real images load in.
|
||||
|
||||
### Empty state
|
||||
1. Ensure no images are uploaded (or use a fresh test database).
|
||||
2. Open the library.
|
||||
3. **Expect**: A centred empty-state panel with explanatory text and a prominent
|
||||
"Upload your first image" link. Clicking the link navigates to `/upload`.
|
||||
|
||||
### Error state
|
||||
1. Stop the API container (`docker compose stop api`).
|
||||
2. Hard-refresh the library.
|
||||
3. **Expect**: An error card with a plain-language message and a "Retry" button.
|
||||
No blank grid, no raw status code.
|
||||
4. Restart the API (`docker compose start api`) and click "Retry".
|
||||
5. **Expect**: Images load successfully.
|
||||
|
||||
### Card polish
|
||||
1. Hover over an image card.
|
||||
2. **Expect**: Card lifts slightly (2 px translate) with a smooth transition.
|
||||
|
||||
### Broken image
|
||||
1. Manually corrupt a storage key in the database (or unplug MinIO) and reload.
|
||||
2. **Expect**: Card shows a grey placeholder graphic, not a broken-image browser icon.
|
||||
|
||||
### 375 px viewport
|
||||
1. DevTools → Device toolbar → iPhone SE (375 × 667).
|
||||
2. **Expect**: Cards stack, no horizontal scrollbar, all content readable.
|
||||
|
||||
---
|
||||
|
||||
## M3 — Upload View
|
||||
|
||||
### Drop-zone idle
|
||||
1. Navigate to `/upload` (must be logged in).
|
||||
2. **Expect**: A visually distinct drop-zone with dashed border and clear
|
||||
instructions. Submit button is disabled/greyed.
|
||||
|
||||
### In-progress
|
||||
1. Select a large image file.
|
||||
2. Click upload.
|
||||
3. **Expect**: Button label changes to "Uploading…" and is disabled. A spinner
|
||||
or animated indicator is visible.
|
||||
|
||||
### Success
|
||||
1. After a successful upload completes.
|
||||
2. **Expect**: A green-tinted success banner with the filename, "Upload another"
|
||||
link, and "View in library" link. Banner disappears after 4 seconds.
|
||||
|
||||
### Validation error
|
||||
1. Attempt to upload a `.txt` file.
|
||||
2. **Expect**: An inline error message names the problem ("Unsupported file type").
|
||||
The form is still usable — no page reload required.
|
||||
|
||||
### Network error
|
||||
1. Stop the API mid-upload (or use DevTools → block the upload request).
|
||||
2. **Expect**: A generic inline error with guidance to retry. Form remains usable.
|
||||
|
||||
---
|
||||
|
||||
## M4 — Detail View
|
||||
|
||||
### Loading skeleton
|
||||
1. Set network throttle to Slow 3G.
|
||||
2. Navigate directly to an image URL (e.g., `http://localhost:4200/images/<id>`).
|
||||
3. **Expect**: A grey rectangle skeleton for the image area and chip skeletons
|
||||
below. No blank page.
|
||||
|
||||
### Not-found state
|
||||
1. Navigate to `http://localhost:4200/images/00000000-0000-0000-0000-000000000000`.
|
||||
2. **Expect**: A styled not-found card with "Image not found" heading and a
|
||||
"Back to library" button. No blank page, no raw 404 text.
|
||||
|
||||
### Authenticated write controls
|
||||
1. Log in and open a detail page.
|
||||
2. **Expect**: Tag editing input and delete button are visible and clearly grouped.
|
||||
|
||||
### Unauthenticated view
|
||||
1. Open the detail page in a private/incognito window (not logged in).
|
||||
2. **Expect**: Image and read-only tag chips are visible. No tag input, no
|
||||
delete button.
|
||||
|
||||
### Tag error
|
||||
1. While logged in, attempt to add a tag with invalid characters (e.g., `TAG!`).
|
||||
2. **Expect**: An inline error in danger colour with a left accent border.
|
||||
Other tags and the image remain visible.
|
||||
|
||||
### Broken image
|
||||
1. Open a detail page for an image whose storage object has been deleted.
|
||||
2. **Expect**: A placeholder graphic replaces the image. Page layout is not broken.
|
||||
|
||||
---
|
||||
|
||||
## M5 — Login View
|
||||
|
||||
### Visual consistency
|
||||
1. Open `/login` without being logged in.
|
||||
2. **Expect**: Dark background matching the library; form centred in a surface
|
||||
card; same font and spacing as other pages.
|
||||
|
||||
### Field validation
|
||||
1. Click the Sign In button without entering any credentials.
|
||||
2. **Expect**: Inline validation messages appear on the username and password
|
||||
fields without a page reload.
|
||||
|
||||
### Invalid credentials error
|
||||
1. Enter wrong credentials and submit.
|
||||
2. **Expect**: A single error message below the form. Fields retain their values.
|
||||
|
||||
### In-progress state
|
||||
1. Submit valid credentials (throttle network if needed to see the state).
|
||||
2. **Expect**: Button label changes to "Signing in…" and is disabled while the
|
||||
request is in flight.
|
||||
|
||||
---
|
||||
|
||||
## M6 — App Shell
|
||||
|
||||
### Authenticated header
|
||||
1. Log in and navigate between library, upload, and an image detail page.
|
||||
2. **Expect**: A consistent 48 px header is present on all pages. Sign-out
|
||||
control is visible on the right. Header does not reflow content.
|
||||
|
||||
### Unauthenticated header
|
||||
1. Open the library or detail page without logging in.
|
||||
2. **Expect**: Header is present but sign-out control is absent.
|
||||
|
||||
### Sign out
|
||||
1. Click the sign-out control in the header.
|
||||
2. **Expect**: Redirected to `/login`. Header no longer shows sign-out control.
|
||||
Navigating to `/upload` redirects back to `/login`.
|
||||
137
specs/005-ui-polish/research.md
Normal file
137
specs/005-ui-polish/research.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Research: UI Polish & Design System
|
||||
|
||||
## Decision 1: Design token delivery mechanism
|
||||
|
||||
**Decision**: CSS custom properties declared on `:root` in `styles.css`.
|
||||
|
||||
**Rationale**: Angular's default ViewEncapsulation.Emulated scopes component
|
||||
selectors with attribute hashes but does not block CSS custom property
|
||||
inheritance. `:root` variables cascade into every inline component style block
|
||||
without any special configuration. This is now the standard Angular 2025+ token
|
||||
approach and requires zero new dependencies.
|
||||
|
||||
**Alternatives considered**:
|
||||
- SCSS variables — require a preprocessor; project currently uses plain CSS.
|
||||
- A shared `.css` import per component — works but adds per-component boilerplate
|
||||
and duplicates the token surface unnecessarily.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Skeleton loading pattern
|
||||
|
||||
**Decision**: Pure-CSS shimmer using `::after` pseudo-element animated gradient;
|
||||
no third-party library.
|
||||
|
||||
**Rationale**: The spec assumption explicitly prohibits new icon/component
|
||||
libraries. The standard pure-CSS skeleton pattern (placeholder divs with a
|
||||
light-to-dark horizontal gradient sweeping via `@keyframes`) produces the same
|
||||
visual result as library skeletons with zero added dependencies. The `::after`
|
||||
approach requires no extra DOM nodes per skeleton block.
|
||||
|
||||
**Pattern**:
|
||||
```css
|
||||
@keyframes shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- `@angular/material` skeleton — adds the entire Material library as a dep.
|
||||
- CSS opacity pulse — simpler but less visually informative than a shimmer.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: Loading-flash debounce
|
||||
|
||||
**Decision**: `timer(150).pipe(takeUntil(response$))` to gate the visibility of
|
||||
loading indicators.
|
||||
|
||||
**Rationale**: Showing a spinner immediately causes a visible flash when the
|
||||
server responds in under ~150 ms (common on localhost). The idiomatic RxJS
|
||||
approach is to start a 150 ms timer alongside the real request; if the request
|
||||
completes first (`takeUntil`), the timer never fires and the spinner never
|
||||
appears. This avoids `race()` complexity and cleanly unsubscribes.
|
||||
|
||||
**Implementation sketch**:
|
||||
```typescript
|
||||
showSpinner = false;
|
||||
load(): void {
|
||||
const req$ = this.service.fetch().pipe(share());
|
||||
timer(150).pipe(takeUntil(req$)).subscribe(() => { this.showSpinner = true; });
|
||||
req$.subscribe(data => { this.showSpinner = false; /* handle data */ });
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- `race([req$, timer(150)])` — fires timer regardless of req$ speed on certain
|
||||
race conditions; harder to reason about.
|
||||
- CSS `animation-delay` — cannot easily tie delay to actual response time.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: Broken-image fallback
|
||||
|
||||
**Decision**: Inline `(error)` event binding on `<img>` elements, guarded
|
||||
against recursive fallback.
|
||||
|
||||
**Rationale**: For a small number of distinct image elements (card thumbnail,
|
||||
detail full-image), an event binding is the minimal idiomatic pattern and
|
||||
avoids the complexity of a directive. Recursive fallback is prevented by
|
||||
checking that the current `src` is not already the placeholder before
|
||||
reassigning.
|
||||
|
||||
**Pattern**:
|
||||
```html
|
||||
<img [src]="url" (error)="onImgError($event)" />
|
||||
```
|
||||
```typescript
|
||||
onImgError(e: Event): void {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (!img.src.endsWith('placeholder')) {
|
||||
img.src = 'data:image/svg+xml,...'; // inline SVG placeholder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom `ImageFallbackDirective` — reusable but over-engineered for two call
|
||||
sites; can be extracted later if the pattern spreads.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: Global design token set
|
||||
|
||||
**Decision**: Seven semantic tokens defined on `:root` in `styles.css`, derived
|
||||
from colours already present in the components.
|
||||
|
||||
| Token | Value | Meaning |
|
||||
|--------------------|-----------|----------------------------------------------|
|
||||
| `--bg` | `#0f0f0f` | Page background (already in body) |
|
||||
| `--surface` | `#1a1a1a` | Card / input / panel background |
|
||||
| `--surface-raised` | `#252525` | Hover state, skeleton highlight |
|
||||
| `--border` | `#333` | Subtle dividers, input borders |
|
||||
| `--border-focus` | `#555` | Input focus ring |
|
||||
| `--text` | `#e0e0e0` | Primary text (already on body) |
|
||||
| `--text-muted` | `#777` | Secondary text, placeholders |
|
||||
| `--accent` | `#4a9eff` | CTAs, active chips (already in upload-btn) |
|
||||
| `--accent-text` | `#000` | Text on accent backgrounds |
|
||||
| `--danger` | `#c0392b` | Destructive actions (already in delete-btn) |
|
||||
| `--danger-text` | `#fff` | Text on danger backgrounds |
|
||||
| `--radius` | `6px` | Standard border radius |
|
||||
| `--radius-chip` | `12px` | Pill-shaped chips |
|
||||
| `--transition` | `200ms ease` | Standard animation duration |
|
||||
|
||||
**Rationale**: All values are already present in the components but hard-coded
|
||||
per file. Centralising them eliminates drift without introducing a new colour
|
||||
palette — the visual result is identical to the current state, but consistent.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introducing a new darker/lighter palette — unnecessary scope creep; the
|
||||
existing colours are well-chosen for a dark personal tool.
|
||||
180
specs/005-ui-polish/spec.md
Normal file
180
specs/005-ui-polish/spec.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Feature Specification: UI Polish & Design System
|
||||
|
||||
**Feature Branch**: `005-ui-polish`
|
||||
**Created**: 2026-05-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Polish the Angular UI with a cohesive visual design. The three main views — library (image grid), upload form, and image detail — should feel intentional and finished. Add proper loading states, empty states, and error states to each view. The overall aesthetic should be dark-themed and minimal, fitting a personal tool used frequently. The login page should also match the design system."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Library Feels Complete (Priority: P1)
|
||||
|
||||
The owner opens the app and is greeted by a polished image grid. While images
|
||||
load, something visually coherent fills the space so the page doesn't feel
|
||||
broken. If the library is empty, a helpful prompt explains what to do. If the
|
||||
request fails, a clear error message appears with a way to retry.
|
||||
|
||||
**Why this priority**: The library is the landing page and the most-visited
|
||||
view. Its quality sets first impressions for every session.
|
||||
|
||||
**Independent Test**: Open the app with no images uploaded — confirm an
|
||||
intentional empty state is shown. Upload an image, return to the library —
|
||||
confirm the grid renders cleanly with consistent card sizing. Throttle the
|
||||
network — confirm a loading indicator appears before images arrive.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the library is loading, **When** the page first renders, **Then** a skeleton or spinner occupies the grid area so the layout does not jump or appear blank.
|
||||
2. **Given** no images have been uploaded, **When** the library loads successfully, **Then** an empty-state message is shown explaining that no images exist yet, with a visible prompt to upload the first image.
|
||||
3. **Given** the image fetch fails (network error), **When** the library loads, **Then** an error message is shown with a retry action; the page does not display a blank grid or a raw error code.
|
||||
4. **Given** images exist, **When** the library renders, **Then** all image cards have consistent size, spacing, and visual weight; tag chips are readable and do not overflow their cards.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Upload Form Feels Finished (Priority: P1)
|
||||
|
||||
The owner navigates to the upload page and finds a form that clearly communicates
|
||||
its state at every step: idle with a helpful drop-zone, active while uploading
|
||||
with visible progress, and resolved with success or a plain-language error.
|
||||
|
||||
**Why this priority**: Upload is the primary write action. A rough upload
|
||||
experience erodes confidence in the whole tool.
|
||||
|
||||
**Independent Test**: Upload a valid image and confirm the flow from drop-zone
|
||||
through in-progress indicator to success result is smooth and clearly
|
||||
communicated. Attempt an upload with an invalid file type and confirm a
|
||||
plain-language validation error appears without a page reload.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the upload page is idle, **When** no file is selected, **Then** a drop-zone with clear instructions is visible; the submit button is visibly disabled.
|
||||
2. **Given** a file is selected and uploading, **When** the upload is in progress, **Then** the submit button is disabled and a visible in-progress indicator is shown; the user cannot accidentally submit twice.
|
||||
3. **Given** an upload succeeds, **When** the server responds, **Then** a success confirmation is shown and the owner can navigate onward without confusion.
|
||||
4. **Given** an upload fails due to an invalid file type or size, **When** the server responds, **Then** a plain-language error message is shown identifying the problem; the form remains usable for another attempt.
|
||||
5. **Given** an upload fails due to a network or server error, **When** the server responds, **Then** a generic error message is shown with guidance to retry.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Detail Page Is Well Organised (Priority: P1)
|
||||
|
||||
The owner opens an image's detail page and finds the image prominently displayed,
|
||||
tag management clearly grouped, and write controls (edit tags, delete) visually
|
||||
distinct from read content. Visitors who are not logged in see the image and
|
||||
tags but no write controls. Loading and error states are handled gracefully.
|
||||
|
||||
**Why this priority**: The detail page is where tag curation and deletion
|
||||
happen — the two most common editing actions after upload.
|
||||
|
||||
**Independent Test**: Open a detail page while logged in — confirm write
|
||||
controls are visible and clearly grouped. Open the same page while logged out —
|
||||
confirm write controls are hidden. Navigate to a non-existent image ID — confirm
|
||||
a not-found state is shown rather than a blank or broken page.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the detail page is loading, **When** the route is first entered, **Then** a loading indicator is shown in place of the image and metadata.
|
||||
2. **Given** the image exists and the owner is logged in, **When** the page renders, **Then** the image is the focal point; tags are displayed below; tag editing and delete controls are clearly grouped and visually differentiated from read content.
|
||||
3. **Given** the image exists and the visitor is not logged in, **When** the page renders, **Then** the image and tags are visible; no tag-edit input or delete button is present.
|
||||
4. **Given** a non-existent image ID is requested, **When** the page loads, **Then** a not-found state is shown with a link back to the library; no raw error code or blank area is displayed.
|
||||
5. **Given** a tag update fails, **When** the owner submits a tag change, **Then** an inline error message explains the failure; the image and other tags remain visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Login Page Matches the Design (Priority: P2)
|
||||
|
||||
The owner lands on the login page (directly or after a redirect) and finds a
|
||||
form that visually belongs to the same application as the library and detail
|
||||
page. The form clearly communicates validation errors and submission state.
|
||||
|
||||
**Why this priority**: Login is visited infrequently. A consistent visual
|
||||
treatment matters, but functional correctness (already implemented) is more
|
||||
critical than aesthetic alignment.
|
||||
|
||||
**Independent Test**: Navigate to `/login` directly — confirm the page uses the
|
||||
same colour scheme, typography, and spacing as the rest of the app. Submit with
|
||||
empty fields — confirm visible validation errors appear without a page reload.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the login page loads, **When** the owner views it, **Then** the page uses the same dark background, colour palette, and typographic scale as all other views.
|
||||
2. **Given** the owner submits with empty username or password, **When** the form is submitted, **Then** inline validation messages appear on the relevant fields without a page reload or server round-trip.
|
||||
3. **Given** the owner submits invalid credentials, **When** the server rejects them, **Then** a single error message is shown below the form; the fields are not cleared.
|
||||
4. **Given** the form is submitting, **When** the request is in-flight, **Then** the submit button is disabled and shows an in-progress label so the owner cannot submit twice.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — App Shell Is Consistent (Priority: P2)
|
||||
|
||||
Every page shares a consistent outer frame: a slim header that shows the
|
||||
sign-out control when logged in. The header does not compete with page content
|
||||
for visual attention but is always present and usable.
|
||||
|
||||
**Why this priority**: A coherent shell ties the individual views together into
|
||||
a single application rather than a collection of pages.
|
||||
|
||||
**Independent Test**: Navigate between library, detail, and upload while logged
|
||||
in — confirm the header is consistent across all views. Sign out and visit a
|
||||
public page — confirm the sign-out control is absent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the owner is logged in, **When** viewing any page, **Then** a slim header is present with a sign-out control; it does not draw excessive visual attention away from the page content.
|
||||
2. **Given** the visitor is not logged in, **When** viewing the library or a detail page, **Then** the header is present but contains no sign-out control.
|
||||
3. **Given** the owner clicks sign out in the header, **When** the action completes, **Then** they are redirected to the login page and the header no longer shows the sign-out control.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if an image fails to load (broken URL or storage outage)? The card or detail view should show a placeholder, not the browser's default broken-image icon.
|
||||
- What happens on a very narrow viewport (mobile browser)? Cards should stack or resize; the layout must not overflow horizontally.
|
||||
- What happens if a tag is very long? Tag chips must truncate or wrap without breaking the card or detail layout.
|
||||
- What happens during slow network conditions? Loading states must appear promptly and not flash on fast connections.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: Every view (library, upload, detail, login) MUST display a loading indicator while async data or actions are in progress.
|
||||
- **FR-002**: The library MUST display a meaningful empty-state message with a call to action when no images exist.
|
||||
- **FR-003**: All four views MUST display plain-language error messages when an operation fails; raw HTTP status codes or stack traces MUST NOT be shown to the user.
|
||||
- **FR-004**: The upload form MUST disable the submit control while an upload is in progress to prevent duplicate submissions.
|
||||
- **FR-005**: The detail page MUST show a not-found state (with a back link) when the requested image does not exist.
|
||||
- **FR-006**: Write controls on the detail page (tag editing, delete) MUST be hidden for unauthenticated visitors and visible only to the logged-in owner.
|
||||
- **FR-007**: All views MUST share a consistent set of visual tokens: background colours, text colours, spacing scale, border radii, and interactive-element styles.
|
||||
- **FR-008**: The application MUST be usable on viewports as narrow as 375 px (iPhone SE width) without horizontal overflow.
|
||||
- **FR-009**: Loading indicators MUST NOT flash on connections fast enough to resolve in under 150 ms; debounced or skeleton-based approaches are preferred.
|
||||
- **FR-010**: Broken image assets (failed loads) MUST display a visible placeholder rather than the browser's default broken-image icon.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Design token set**: The shared palette, spacing scale, and typographic rules that all views derive from (background, surface, border, text-primary, text-muted, accent, danger).
|
||||
- **Loading state**: A visual treatment applied to any view or element while data is being fetched or an action is in progress.
|
||||
- **Empty state**: A purposeful layout shown when a collection has zero items, including explanatory text and a next-action prompt.
|
||||
- **Error state**: A purposeful layout shown when an operation fails, including a plain-language description and (where applicable) a retry action.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every view transitions from loading to content (or error/empty) without a layout shift visible to the naked eye.
|
||||
- **SC-002**: All five views pass a visual consistency check: an observer can identify them as belonging to the same application by colour, typography, and spacing alone.
|
||||
- **SC-003**: The library, upload, and detail views each render without horizontal scrollbars on a 375 px wide viewport.
|
||||
- **SC-004**: Each error condition (network failure, validation failure, not-found) produces a user-visible message within the current view — zero conditions result in a silent failure or blank screen.
|
||||
- **SC-005**: Loading indicators do not appear on responses that complete in under 150 ms in a local development environment (no flicker on fast connections).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing dark colour palette already in the components (#1a1a1a backgrounds, #e0e0e0 text, #4a9eff accent) is the correct base; the polish work refines and extends it rather than replacing it wholesale.
|
||||
- No external component library or icon set is introduced; any icons needed are either inline SVG or Unicode characters to avoid new dependencies.
|
||||
- The app remains a single-page application; no server-side rendering or route-level transitions are in scope.
|
||||
- Mobile layout is "good enough to use" at 375 px rather than a fully optimised mobile-first redesign; a dedicated mobile redesign is out of scope.
|
||||
- No new API endpoints are needed; all changes are purely front-end.
|
||||
- Animations and transitions are minimal — a single standard duration applied consistently; no complex motion design.
|
||||
- FR-006 (hiding write controls for unauthenticated visitors) is already implemented in the detail component; this spec confirms the behaviour is preserved and visually correct, not that it needs to be built from scratch.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user