Compare commits
80 Commits
002-api-im
...
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f953c88984 |
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/002-api-image-proxy"
|
||||
}
|
||||
{"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/002-api-image-proxy/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"]
|
||||
23
api/alembic/versions/002_add_thumbnail_key.py
Normal file
23
api/alembic/versions/002_add_thumbnail_key.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""add thumbnail_key column to images
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-05-03
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("images", sa.Column("thumbnail_key", sa.String(70), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("images", "thumbnail_key")
|
||||
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,10 +22,16 @@ class Image(Base):
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)
|
||||
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan")
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(
|
||||
back_populates="image", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def tags(self) -> list[str]:
|
||||
@@ -37,7 +43,9 @@ class Tag(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,15 +11,27 @@ class ImageRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
|
||||
async def get_by_hash(self, hash_hex: str) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
select(Image)
|
||||
.where(Image.hash == hash_hex)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
|
||||
async def get_by_id(self, image_id: uuid.UUID) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
select(Image)
|
||||
.where(Image.id == image_id)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_short_id(self, short_id: str) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image)
|
||||
.where(Image.short_id == short_id)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -34,6 +45,8 @@ class ImageRepository:
|
||||
width: int,
|
||||
height: int,
|
||||
storage_key: str,
|
||||
short_id: str,
|
||||
thumbnail_key: str | None = None,
|
||||
) -> Image:
|
||||
image = Image(
|
||||
hash=hash_hex,
|
||||
@@ -43,6 +56,8 @@ class ImageRepository:
|
||||
width=width,
|
||||
height=height,
|
||||
storage_key=storage_key,
|
||||
short_id=short_id,
|
||||
thumbnail_key=thumbnail_key,
|
||||
)
|
||||
self._session.add(image)
|
||||
await self._session.flush()
|
||||
@@ -55,7 +70,7 @@ class ImageRepository:
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Image], int]:
|
||||
from sqlalchemy import func, and_
|
||||
from sqlalchemy import func
|
||||
|
||||
base_query = select(Image).options(
|
||||
selectinload(Image.image_tags).selectinload(ImageTag.tag)
|
||||
|
||||
@@ -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,30 +1,57 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.provider import AuthProvider
|
||||
from app.auth.provider import Identity
|
||||
from app.config import get_settings
|
||||
from app.dependencies import get_auth, get_db, get_storage
|
||||
from app.dependencies import get_db, get_storage, require_auth
|
||||
from app.models import Image
|
||||
from app.repositories.image_repo import ImageRepository
|
||||
from app.repositories.tag_repo import TagRepository
|
||||
from app.storage.backend import StorageBackend
|
||||
from app.utils import compute_sha256
|
||||
from app.thumbnail import generate_thumbnail
|
||||
from app.utils import compute_sha256, generate_short_id
|
||||
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["images"])
|
||||
|
||||
|
||||
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
|
||||
|
||||
|
||||
def _error(detail: str, code: str, status: int):
|
||||
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
|
||||
|
||||
|
||||
def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str, Any]:
|
||||
def _validate_short_id(short_id: str) -> str:
|
||||
if not _SHORT_ID_RE.match(short_id):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"detail": "Invalid image ID", "code": "invalid_short_id"},
|
||||
)
|
||||
return short_id
|
||||
|
||||
|
||||
def _image_to_dict(
|
||||
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
|
||||
) -> dict[str, Any]:
|
||||
_base = cdn_base.strip().rstrip("/") if cdn_base else None
|
||||
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/i/{image.short_id}/file"
|
||||
thumbnail_url = (
|
||||
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/i/{image.short_id}/thumbnail")
|
||||
if image.thumbnail_key
|
||||
else None
|
||||
)
|
||||
data: dict[str, Any] = {
|
||||
"id": str(image.id),
|
||||
"short_id": image.short_id,
|
||||
"hash": image.hash,
|
||||
"filename": image.filename,
|
||||
"mime_type": image.mime_type,
|
||||
@@ -32,6 +59,9 @@ def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"storage_key": image.storage_key,
|
||||
"thumbnail_key": image.thumbnail_key,
|
||||
"file_url": file_url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"created_at": image.created_at.isoformat(),
|
||||
"tags": image.tags,
|
||||
}
|
||||
@@ -103,7 +133,7 @@ async def upload_image(
|
||||
tags: str | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
_: Identity = Depends(require_auth),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
data = await file.read()
|
||||
@@ -127,10 +157,13 @@ async def upload_image(
|
||||
|
||||
hash_hex = compute_sha256(data)
|
||||
image_repo = ImageRepository(db)
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
existing = await image_repo.get_by_hash(hash_hex)
|
||||
if existing:
|
||||
return Response(
|
||||
content=__import__("json").dumps(_image_to_dict(existing, duplicate=True)),
|
||||
content=__import__("json").dumps(
|
||||
_image_to_dict(existing, cdn_base=_cdn_base, duplicate=True)
|
||||
),
|
||||
status_code=200,
|
||||
media_type="application/json",
|
||||
)
|
||||
@@ -149,24 +182,55 @@ async def upload_image(
|
||||
)
|
||||
|
||||
width, height = _read_image_dimensions(data, mime_type)
|
||||
await storage.put(hash_hex, data, mime_type)
|
||||
|
||||
image = await image_repo.create(
|
||||
hash_hex=hash_hex,
|
||||
filename=file.filename or "upload",
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(data),
|
||||
width=width,
|
||||
height=height,
|
||||
storage_key=hash_hex,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
for _ in range(10):
|
||||
short_id = generate_short_id()
|
||||
await storage.put(short_id, data, mime_type)
|
||||
|
||||
thumbnail_key: str | None = None
|
||||
try:
|
||||
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
|
||||
await storage.put(f"{short_id}-thumb", thumb_bytes, "image/webp")
|
||||
thumbnail_key = f"{short_id}-thumb"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Thumbnail generation failed for %s; proceeding without thumbnail", short_id
|
||||
)
|
||||
|
||||
try:
|
||||
image = await image_repo.create(
|
||||
hash_hex=hash_hex,
|
||||
filename=file.filename or "upload",
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(data),
|
||||
width=width,
|
||||
height=height,
|
||||
storage_key=short_id,
|
||||
short_id=short_id,
|
||||
thumbnail_key=thumbnail_key,
|
||||
)
|
||||
break
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
await storage.delete(short_id)
|
||||
if thumbnail_key:
|
||||
await storage.delete(thumbnail_key)
|
||||
thumbnail_key = None
|
||||
continue
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"detail": "Failed to assign unique ID", "code": "id_collision"},
|
||||
)
|
||||
|
||||
if tag_names:
|
||||
tag_repo = TagRepository(db)
|
||||
await tag_repo.attach_tags(image, tag_names)
|
||||
image = await image_repo.reload_with_tags(image.id)
|
||||
|
||||
return _image_to_dict(image, duplicate=False)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base, duplicate=False)
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
@@ -175,42 +239,48 @@ async def list_images(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
limit = min(limit, 100)
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
tag_names = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
||||
image_repo = ImageRepository(db)
|
||||
images, total = await image_repo.list_images(tag_names=tag_names, limit=limit, offset=offset)
|
||||
return {
|
||||
"items": [_image_to_dict(img) for img in images],
|
||||
"items": [_image_to_dict(img, cdn_base=_cdn_base) for img in images],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/images/{image_id}")
|
||||
@router.get("/i/{short_id}")
|
||||
async def get_image(
|
||||
image_id: uuid.UUID,
|
||||
short_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
_validate_short_id(short_id)
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
image = await image_repo.get_by_short_id(short_id)
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"detail": "Image not found", "code": "image_not_found"},
|
||||
)
|
||||
return _image_to_dict(image)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base)
|
||||
|
||||
|
||||
@router.get("/images/{image_id}/file")
|
||||
@router.get("/i/{short_id}/file")
|
||||
async def serve_image_file(
|
||||
image_id: uuid.UUID,
|
||||
short_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
):
|
||||
_validate_short_id(short_id)
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
image = await image_repo.get_by_short_id(short_id)
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -233,14 +303,51 @@ async def serve_image_file(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/images/{image_id}/tags")
|
||||
@router.get("/i/{short_id}/thumbnail")
|
||||
async def serve_image_thumbnail(
|
||||
short_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
):
|
||||
_validate_short_id(short_id)
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_short_id(short_id)
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"detail": "Image not found", "code": "image_not_found"},
|
||||
)
|
||||
key = image.thumbnail_key or image.storage_key
|
||||
media_type = "image/webp" if image.thumbnail_key else image.mime_type
|
||||
try:
|
||||
data = await storage.get(key)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"detail": "Failed to retrieve image content", "code": "storage_error"},
|
||||
) from None
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"ETag": f'"{image.hash}"',
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/i/{short_id}/tags")
|
||||
async def update_image_tags(
|
||||
image_id: uuid.UUID,
|
||||
short_id: str,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: Identity = Depends(require_auth),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
_validate_short_id(short_id)
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
image = await image_repo.get_by_short_id(short_id)
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -259,23 +366,28 @@ async def update_image_tags(
|
||||
|
||||
await tag_repo.replace_tags_on_image(image, tag_names)
|
||||
image = await image_repo.reload_with_tags(image.id)
|
||||
return _image_to_dict(image)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base)
|
||||
|
||||
|
||||
@router.delete("/images/{image_id}", status_code=204)
|
||||
@router.delete("/i/{short_id}", status_code=204)
|
||||
async def delete_image(
|
||||
image_id: uuid.UUID,
|
||||
short_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
_: Identity = Depends(require_auth),
|
||||
):
|
||||
_validate_short_id(short_id)
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
image = await image_repo.get_by_short_id(short_id)
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"detail": "Image not found", "code": "image_not_found"},
|
||||
)
|
||||
storage_key = image.storage_key
|
||||
thumbnail_key = image.thumbnail_key
|
||||
await image_repo.delete(image)
|
||||
await storage.delete(storage_key)
|
||||
if thumbnail_key:
|
||||
await storage.delete(thumbnail_key)
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -12,9 +12,13 @@ async def list_tags(
|
||||
q: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
sort: str = "name",
|
||||
min_count: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit = min(limit, 200)
|
||||
limit = min(limit, 500)
|
||||
tag_repo = TagRepository(db)
|
||||
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset)
|
||||
items, total = await tag_repo.list_tags(
|
||||
prefix=q, limit=limit, offset=offset, sort=sort, min_count=min_count
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
16
api/app/thumbnail.py
Normal file
16
api/app/thumbnail.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def generate_thumbnail(data: bytes, mime_type: str) -> bytes:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
with contextlib.suppress(EOFError):
|
||||
img.seek(0)
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA" if img.mode == "P" and "transparency" in img.info else "RGB")
|
||||
img.thumbnail((400, 400), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="WEBP", quality=80)
|
||||
return buf.getvalue()
|
||||
@@ -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))
|
||||
|
||||
@@ -15,6 +15,8 @@ dependencies = [
|
||||
"aiobotocore>=2.13",
|
||||
"pydantic-settings>=2.2",
|
||||
"python-multipart>=0.0.9",
|
||||
"pillow>=10.0",
|
||||
"PyJWT>=2.8",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -28,10 +30,14 @@ dev = [
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
exclude = ["alembic/"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = []
|
||||
ignore = [
|
||||
"B008", # FastAPI Depends/File/Form in function signatures — intentional
|
||||
"B904", # raise-without-from inside except — HTTPException re-raise pattern
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
@@ -42,3 +48,11 @@ testpaths = ["tests"]
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"anyio>=4.13.0",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
0
api/scripts/__init__.py
Normal file
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,11 +1,12 @@
|
||||
"""
|
||||
T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404
|
||||
T065 — DELETE /api/v1/i/{short_id} → 204; subsequent GET returns 404
|
||||
T066 — DELETE verifies MinIO object is removed
|
||||
T067 — DELETE of unknown ID → 404 image_not_found
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def _minimal_jpeg_v2() -> bytes:
|
||||
@@ -17,44 +18,78 @@ def _minimal_jpeg_v2() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_record(client):
|
||||
async def test_delete_removes_record(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_jpeg_v2()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
get_resp = await client.get(f"/api/v1/images/{image_id}")
|
||||
get_resp = await client.get(f"/api/v1/i/{image_id}")
|
||||
assert get_resp.status_code == 404
|
||||
assert get_resp.json()["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_storage_object(client):
|
||||
async def test_delete_removes_storage_object(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _minimal_jpeg_v2() + b"\x00"
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
storage_key = upload.json()["hash"]
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
# Confirm storage redirect no longer works (404 since record is gone)
|
||||
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
file_resp = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert file_resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unknown_id_returns_404(client):
|
||||
response = await client.delete(f"/api/v1/images/{uuid.uuid4()}")
|
||||
async def test_delete_unknown_id_returns_404(authed_client):
|
||||
client, token = authed_client
|
||||
response = await client.delete(
|
||||
"/api/v1/i/NotFound",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_thumbnail(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
|
||||
data = buf.getvalue()
|
||||
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
image_id = upload.json()["short_id"]
|
||||
assert upload.json()["thumbnail_key"] is not None
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
thumb_resp = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||
assert thumb_resp.status_code == 404
|
||||
assert thumb_resp.json()["code"] == "image_not_found"
|
||||
|
||||
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,12 +1,21 @@
|
||||
"""
|
||||
T055 — GET /api/v1/images/{id}/file → 200 with binary content, ETag, Cache-Control
|
||||
T055 — GET /api/v1/i/{short_id}/file → 200 with binary content, ETag, Cache-Control
|
||||
T056 — /file for unknown ID → 404 image_not_found
|
||||
T057 — /file response exposes no storage-specific details
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models import Image
|
||||
|
||||
|
||||
def _real_jpeg() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", (200, 150), color=(120, 80, 200)).save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _minimal_webp() -> bytes:
|
||||
@@ -19,18 +28,20 @@ def _minimal_webp() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_returns_200_with_content(client):
|
||||
async def test_file_returns_200_with_content(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_webp()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
upload_body = upload.json()
|
||||
image_id = upload_body["id"]
|
||||
image_id = upload_body["short_id"]
|
||||
image_hash = upload_body["hash"]
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("image/")
|
||||
assert response.headers["etag"] == f'"{image_hash}"'
|
||||
@@ -40,25 +51,81 @@ async def test_file_returns_200_with_content(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_unknown_id_returns_404(client):
|
||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
|
||||
response = await client.get("/api/v1/i/NotFound/file")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_response_exposes_no_storage_details(client):
|
||||
async def test_file_response_exposes_no_storage_details(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_webp()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert "minio" not in response.text.lower()
|
||||
assert "s3://" not in response.text.lower()
|
||||
assert "amazonaws.com" not in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_returns_webp(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
body = upload.json()
|
||||
image_id = body["short_id"]
|
||||
image_hash = body["hash"]
|
||||
|
||||
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/webp"
|
||||
assert response.headers["etag"] == f'"{image_hash}"'
|
||||
assert "immutable" in response.headers["cache-control"]
|
||||
assert len(response.content) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_fallback_returns_original(authed_client, db_session):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
image_id = upload.json()["short_id"]
|
||||
|
||||
await db_session.execute(
|
||||
update(Image).where(Image.short_id == image_id).values(thumbnail_key=None)
|
||||
)
|
||||
await db_session.flush()
|
||||
db_session.expire_all()
|
||||
|
||||
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
assert "image/jpeg" in response.headers["content-type"]
|
||||
assert len(response.content) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_unknown_id_returns_404(client):
|
||||
response = await client.get("/api/v1/i/NotFound/thumbnail")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
@@ -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,10 +3,20 @@ T026 — valid JPEG upload → 201, record in DB, object in MinIO
|
||||
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
|
||||
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
|
||||
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
|
||||
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
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def _real_jpeg(color: tuple = (100, 150, 200), size: tuple = (200, 150)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", size, color=color).save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _minimal_jpeg() -> bytes:
|
||||
@@ -18,11 +28,13 @@ def _minimal_jpeg() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_new_image_returns_201(client):
|
||||
async def test_upload_new_image_returns_201(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
@@ -35,12 +47,15 @@ async def test_upload_new_image_returns_201(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_duplicate_returns_200_with_flag(client):
|
||||
async def test_upload_duplicate_returns_200_with_flag(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# First upload
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
|
||||
@@ -48,6 +63,7 @@ async def test_upload_duplicate_returns_200_with_flag(client):
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
@@ -56,10 +72,12 @@ async def test_upload_duplicate_returns_200_with_flag(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_invalid_mime_type_returns_422(client):
|
||||
async def test_upload_invalid_mime_type_returns_422(authed_client):
|
||||
client, token = authed_client
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
@@ -68,10 +86,12 @@ async def test_upload_invalid_mime_type_returns_422(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_oversized_file_returns_422(client):
|
||||
async def test_upload_oversized_file_returns_422(authed_client):
|
||||
import os
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
client, token = authed_client
|
||||
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -79,6 +99,7 @@ async def test_upload_oversized_file_returns_422(client):
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
@@ -90,9 +111,139 @@ async def test_upload_oversized_file_returns_422(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unknown_image_returns_404_with_envelope(client):
|
||||
import uuid
|
||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
|
||||
response = await client.get("/api/v1/i/NotFound")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
assert "detail" in body
|
||||
|
||||
|
||||
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_returns_short_id(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("s1.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "short_id" in body
|
||||
assert _SHORT_ID_RE.match(body["short_id"]), f"short_id invalid: {body['short_id']}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_storage_key_equals_short_id(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(10, 20, 30))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("s2.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["storage_key"] == body["short_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_thumbnail_key_equals_short_id_thumb(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(30, 60, 90))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("s3.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
if body["thumbnail_key"] is not None:
|
||||
assert body["thumbnail_key"] == f"{body['short_id']}-thumb"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_upload_returns_same_short_id(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(200, 100, 50))
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicate"] is True
|
||||
assert r2.json()["short_id"] == r1.json()["short_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_returns_thumbnail_key(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(100, 150, 200))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "thumbnail_key" in body
|
||||
assert body["thumbnail_key"] is not None
|
||||
assert body["thumbnail_key"].endswith("-thumb")
|
||||
assert "file_url" in body
|
||||
assert body["file_url"].startswith("/api/v1/i/")
|
||||
assert "thumbnail_url" in body
|
||||
assert body["thumbnail_url"].startswith("/api/v1/i/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_upload_reuses_thumbnail_key(authed_client):
|
||||
client, token = authed_client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = _real_jpeg(color=(200, 100, 50))
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
tk1 = r1.json()["thumbnail_key"]
|
||||
tk2 = r2.json()["thumbnail_key"]
|
||||
assert tk1 is not None
|
||||
assert tk1 == tk2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_succeeds_when_thumbnail_fails(authed_client):
|
||||
client, token = authed_client
|
||||
data = _real_jpeg(color=(50, 200, 150))
|
||||
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code in (200, 201)
|
||||
body = response.json()
|
||||
assert body["thumbnail_key"] is None
|
||||
assert "file_url" in body
|
||||
assert body["file_url"].startswith("/api/v1/i/")
|
||||
assert body["thumbnail_url"] is None
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
80
api/tests/unit/test_thumbnail.py
Normal file
80
api/tests/unit/test_thumbnail.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Unit tests for thumbnail generation utility."""
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.thumbnail import generate_thumbnail
|
||||
|
||||
|
||||
def _make_jpeg(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("RGB", (width, height), color=(128, 64, 32))
|
||||
img.save(buf, format="JPEG", quality=80)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_png_rgba(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("RGBA", (width, height), color=(10, 20, 30, 180))
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_gif(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("P", (width, height))
|
||||
img.save(buf, format="GIF")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_thumbnail_is_webp():
|
||||
data = _make_jpeg(600, 400)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
assert result[:4] == b"RIFF"
|
||||
assert result[8:12] == b"WEBP"
|
||||
|
||||
|
||||
def test_thumbnail_fits_within_400px():
|
||||
data = _make_jpeg(800, 600)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
assert w <= 400
|
||||
assert h <= 400
|
||||
|
||||
|
||||
def test_thumbnail_preserves_aspect_ratio():
|
||||
original_w, original_h = 800, 300
|
||||
data = _make_jpeg(original_w, original_h)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
original_ratio = original_w / original_h
|
||||
thumb_ratio = w / h
|
||||
assert abs(original_ratio - thumb_ratio) / original_ratio < 0.01
|
||||
|
||||
|
||||
def test_thumbnail_handles_gif_first_frame():
|
||||
data = _make_gif(500, 500)
|
||||
result = generate_thumbnail(data, "image/gif")
|
||||
assert result[8:12] == b"WEBP"
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
assert not getattr(img, "is_animated", False)
|
||||
|
||||
|
||||
def test_thumbnail_handles_png_with_alpha():
|
||||
data = _make_png_rgba(300, 300)
|
||||
result = generate_thumbnail(data, "image/png")
|
||||
assert result[8:12] == b"WEBP"
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
assert img.format == "WEBP"
|
||||
|
||||
|
||||
def test_thumbnail_does_not_upscale():
|
||||
data = _make_jpeg(100, 100)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
assert w <= 100
|
||||
assert h <= 100
|
||||
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.1
|
||||
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.1
|
||||
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.1
|
||||
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
34
specs/003-upload-thumbnails/checklists/requirements.md
Normal file
34
specs/003-upload-thumbnails/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Upload Thumbnails
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All checklist items pass. Spec is ready for `/speckit-plan`.
|
||||
90
specs/003-upload-thumbnails/contracts/api.md
Normal file
90
specs/003-upload-thumbnails/contracts/api.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# API Contract: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## New endpoint
|
||||
|
||||
### `GET /api/v1/images/{image_id}/thumbnail`
|
||||
|
||||
Returns the thumbnail content for the given image. If no thumbnail was generated
|
||||
(image pre-dates the feature or generation failed), falls back to the full-size
|
||||
original.
|
||||
|
||||
**Path parameters**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `image_id` | UUID | Unique identifier of the image |
|
||||
|
||||
**Responses**
|
||||
|
||||
#### `200 OK` — Thumbnail (or original fallback) content
|
||||
|
||||
| Header | Value | Notes |
|
||||
|--------|-------|-------|
|
||||
| `Content-Type` | `image/webp` | Always WebP when a thumbnail exists; original `mime_type` when falling back to the original |
|
||||
| `ETag` | `"{sha256-hex}"` | Same hash as the original image — content is immutable |
|
||||
| `Cache-Control` | `public, max-age=31536000, immutable` | Safe: thumbnail content never changes |
|
||||
|
||||
Body: raw image bytes (WebP thumbnail, or original bytes as fallback).
|
||||
|
||||
#### `404 Not Found` — Image not found
|
||||
|
||||
```json
|
||||
{ "detail": "Image not found", "code": "image_not_found" }
|
||||
```
|
||||
|
||||
#### `500 Internal Server Error` — Storage retrieval failure
|
||||
|
||||
```json
|
||||
{ "detail": "Failed to retrieve image content", "code": "storage_error" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changed endpoint: `POST /api/v1/images`
|
||||
|
||||
The upload response body gains one new field:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `thumbnail_key` | `string \| null` | S3 key of the generated thumbnail. `null` if generation failed. |
|
||||
|
||||
All existing fields are unchanged.
|
||||
|
||||
**Example response** (new field only shown):
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"thumbnail_key": "abc123…-thumb",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changed endpoint: `GET /api/v1/images` and `GET /api/v1/images/{id}`
|
||||
|
||||
Both metadata responses gain the same `thumbnail_key` field (`string | null`).
|
||||
|
||||
---
|
||||
|
||||
## UI contract
|
||||
|
||||
The Angular `ImageService` gains one new method:
|
||||
|
||||
```
|
||||
getThumbnailUrl(id: string): string
|
||||
→ '/api/v1/images/{id}/thumbnail'
|
||||
```
|
||||
|
||||
The `ImageRecord` interface gains:
|
||||
|
||||
```
|
||||
thumbnail_key: string | null;
|
||||
```
|
||||
|
||||
The library grid component uses `getThumbnailUrl(image.id)` as the `src` for
|
||||
every grid cell. The detail component continues using `getFileUrl(image.id)`.
|
||||
79
specs/003-upload-thumbnails/data-model.md
Normal file
79
specs/003-upload-thumbnails/data-model.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Data Model: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
## Schema change: `images` table
|
||||
|
||||
One nullable column is added to the existing `images` table.
|
||||
|
||||
| Column | Type | Nullable | Default | Notes |
|
||||
|--------|------|----------|---------|-------|
|
||||
| `thumbnail_key` | `VARCHAR(70)` | YES | `NULL` | S3 object key for the WebP thumbnail. `NULL` = no thumbnail available (generation failed or pre-dates this feature). Derived value: `{image.hash}-thumb`. |
|
||||
|
||||
No other tables change. No new tables are added.
|
||||
|
||||
### Migration
|
||||
|
||||
**File**: `api/alembic/versions/002_add_thumbnail_key.py`
|
||||
|
||||
```
|
||||
upgrade: ALTER TABLE images ADD COLUMN thumbnail_key VARCHAR(70);
|
||||
downgrade: ALTER TABLE images DROP COLUMN thumbnail_key;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ORM model change: `Image`
|
||||
|
||||
`api/app/models.py` — `Image` class gains one field:
|
||||
|
||||
```
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New module: `api/app/thumbnail.py`
|
||||
|
||||
Contains the thumbnail generation logic. Not a model, but documented here because
|
||||
it defines the thumbnail's shape:
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Output format | WebP |
|
||||
| Max dimension (longest side) | 400 px |
|
||||
| Aspect ratio | Preserved (never upscaled) |
|
||||
| Source formats supported | JPEG, PNG, GIF (frame 0), WebP |
|
||||
| Key signature | `async def generate_thumbnail(data: bytes, mime_type: str) -> bytes` |
|
||||
|
||||
---
|
||||
|
||||
## API response shape change
|
||||
|
||||
`_image_to_dict()` in `api/app/routers/images.py` adds `"thumbnail_key"` to its
|
||||
output so the UI can determine whether a thumbnail is available:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"hash": "...",
|
||||
"thumbnail_key": "abc123...-thumb", ← new (null if no thumbnail)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The UI uses the presence of `thumbnail_key` to decide whether to call
|
||||
`/api/v1/images/{id}/thumbnail` (with thumbnail) or fall back to
|
||||
`/api/v1/images/{id}/file` (without). In practice the endpoint itself
|
||||
handles the fallback, so the UI can always call `/thumbnail`.
|
||||
|
||||
---
|
||||
|
||||
## Storage objects per image (after this feature)
|
||||
|
||||
| Object | Key | Format | Created at |
|
||||
|--------|-----|--------|-----------|
|
||||
| Original | `{sha256_hash}` | Original mime_type | Upload |
|
||||
| Thumbnail | `{sha256_hash}-thumb` | `image/webp` | Upload (same request) |
|
||||
|
||||
Thumbnail object is deleted alongside original on image deletion.
|
||||
246
specs/003-upload-thumbnails/plan.md
Normal file
246
specs/003-upload-thumbnails/plan.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Implementation Plan: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/003-upload-thumbnails/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
When an image is uploaded, generate a WebP thumbnail (longest side ≤ 400 px,
|
||||
aspect ratio preserved) and store it in S3 alongside the original. Add a
|
||||
`GET /api/v1/images/{id}/thumbnail` endpoint that serves the thumbnail (or falls
|
||||
back to the original for images that pre-date the feature). The Angular library
|
||||
grid switches from `/file` to `/thumbnail`. The detail page is unchanged.
|
||||
|
||||
Changes span: a new Pillow dependency, a new `thumbnail.py` utility module, one
|
||||
Alembic migration, the upload and delete routes, a new thumbnail serve route, and
|
||||
the Angular image service and library component.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
|
||||
**Primary Dependencies**: FastAPI, Pillow (new — thumbnail generation), aiobotocore,
|
||||
SQLAlchemy 2.x async, Alembic, Angular
|
||||
**Storage**: S3-compatible object storage via `StorageBackend.put()` and `.get()`;
|
||||
thumbnails stored at key `{sha256_hash}-thumb` in the same bucket
|
||||
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
|
||||
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
|
||||
**Project Type**: Web application — FastAPI API + Angular SPA
|
||||
**Performance Goals**: 20-image grid transfers ≥ 80% less data than full-size;
|
||||
first page of 1,000-image library loads in under 2 s
|
||||
**Constraints**: Thumbnail generation is synchronous within the upload request;
|
||||
thumbnail failure must not block upload success; no backfill of existing images in v1
|
||||
**Scale/Scope**: Single-user personal application; upload frequency low
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|-----------|-------|--------|
|
||||
| §2.1 Separation of concerns | `thumbnail.py` owns resize logic; router owns orchestration; UI knows nothing about S3 keys | ✅ |
|
||||
| §2.2 Dependency direction | UI → API → Storage; thumbnail stored via `StorageBackend`; no upward imports | ✅ |
|
||||
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend.put()` and `.get()`; no raw S3 SDK calls in routes or `thumbnail.py` | ✅ |
|
||||
| §2.4 Auth abstraction | No change to auth flow | ✅ |
|
||||
| §2.5 DB abstraction | New `thumbnail_key` column accessed only through `ImageRepository`; migration added | ✅ |
|
||||
| §2.6 No speculative abstraction | `thumbnail.py` is a concrete module-level function; no interface added because one implementation exists | ✅ |
|
||||
| §3.1 API versioning | New route at `/api/v1/images/{id}/thumbnail` | ✅ |
|
||||
| §3.3 Error shape | `image_not_found` and `storage_error` codes used consistently | ✅ |
|
||||
| §4.2 Images immutable after upload | Thumbnail is generated at upload time only; never mutated | ✅ |
|
||||
| §4.3 Dedup by hash | Duplicate upload returns existing record including existing `thumbnail_key`; no re-generation | ✅ |
|
||||
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
|
||||
| §5.2 Test pyramid | Unit test for `thumbnail.py`; integration tests for new route + upload + delete | ✅ |
|
||||
| §5.3 Test colocation | API tests in `api/tests/`; Angular spec files colocated with components | ✅ |
|
||||
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
|
||||
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
|
||||
| §7.2 Env configuration | No new env vars; Pillow is a build dependency, not a runtime config | ✅ |
|
||||
| §8 Scope boundaries | Backfill of existing images, multiple thumbnail sizes, animated WebP — all deferred | ✅ |
|
||||
|
||||
**Post-design re-check**: All gates still pass after Phase 1 design.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/003-upload-thumbnails/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 decisions
|
||||
├── data-model.md # Schema and module changes
|
||||
├── contracts/
|
||||
│ └── api.md # New endpoint + changed response shapes
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
||||
```
|
||||
|
||||
### Files changed or created
|
||||
|
||||
```text
|
||||
api/
|
||||
├── pyproject.toml # Add Pillow dependency
|
||||
├── app/
|
||||
│ ├── thumbnail.py # NEW — generate_thumbnail()
|
||||
│ ├── models.py # Add thumbnail_key column to Image
|
||||
│ ├── repositories/
|
||||
│ │ └── image_repo.py # Pass thumbnail_key on create
|
||||
│ └── routers/
|
||||
│ └── images.py # Upload: generate+store thumbnail
|
||||
│ # Delete: remove thumbnail
|
||||
│ # New route: GET /images/{id}/thumbnail
|
||||
│ # _image_to_dict: add thumbnail_key field
|
||||
└── alembic/
|
||||
└── versions/
|
||||
└── 002_add_thumbnail_key.py # NEW migration
|
||||
|
||||
ui/
|
||||
└── src/
|
||||
└── app/
|
||||
├── services/
|
||||
│ └── image.service.ts # Add getThumbnailUrl(); add thumbnail_key to ImageRecord
|
||||
└── library/
|
||||
└── library.component.ts # Use getThumbnailUrl() for grid image src
|
||||
```
|
||||
|
||||
## Milestones
|
||||
|
||||
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
|
||||
> the failing test(s) first, confirm they fail, then implement until they pass.
|
||||
|
||||
---
|
||||
|
||||
### M1 — Thumbnail generation utility
|
||||
|
||||
**Goal**: A tested, self-contained function that produces a WebP thumbnail from
|
||||
raw image bytes.
|
||||
|
||||
**Deliverables**:
|
||||
- Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`
|
||||
- Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`:
|
||||
- Open image bytes with Pillow
|
||||
- Seek to frame 0 (handles animated GIFs)
|
||||
- Convert mode as needed for WebP output
|
||||
- Resize to fit within 400×400 using LANCZOS resampling (never upscale)
|
||||
- Encode as WebP quality 80 and return bytes
|
||||
- Unit tests in `api/tests/unit/test_thumbnail.py`:
|
||||
- `test_thumbnail_is_webp` — output starts with WebP magic bytes
|
||||
- `test_thumbnail_fits_within_400px` — both dimensions ≤ 400
|
||||
- `test_thumbnail_preserves_aspect_ratio` — ratio within 1% of original
|
||||
- `test_thumbnail_handles_gif_first_frame` — GIF input produces static WebP
|
||||
- `test_thumbnail_handles_png_with_alpha` — RGBA PNG produces valid WebP
|
||||
- `test_thumbnail_does_not_upscale` — 100×100 image stays ≤ 100×100
|
||||
|
||||
**Done criterion**: All unit tests pass; `ruff check api/` passes.
|
||||
|
||||
---
|
||||
|
||||
### M2 — Database migration
|
||||
|
||||
**Goal**: The `images` table has a nullable `thumbnail_key` column; the ORM model
|
||||
and repository reflect it.
|
||||
|
||||
**Deliverables**:
|
||||
- `api/alembic/versions/002_add_thumbnail_key.py` — `upgrade` adds
|
||||
`VARCHAR(70) NULLABLE` column; `downgrade` drops it
|
||||
- `api/app/models.py`: add `thumbnail_key: Mapped[str | None]` mapped to
|
||||
`String(70)`, `nullable=True`, `default=None`
|
||||
- `api/app/repositories/image_repo.py`: add `thumbnail_key: str | None = None`
|
||||
parameter to `create()`; persist it on the new `Image` instance
|
||||
|
||||
**Done criterion**: `alembic upgrade head` runs cleanly inside Docker; all
|
||||
existing 46 integration tests still pass (new column is nullable, no existing
|
||||
test breaks).
|
||||
|
||||
---
|
||||
|
||||
### M3 — Upload route: generate and store thumbnail
|
||||
|
||||
**Goal**: Every new upload generates a thumbnail; duplicates reuse the existing
|
||||
record; failures are tolerated without blocking the upload.
|
||||
|
||||
**TDD first** — new tests in `api/tests/integration/test_upload.py`:
|
||||
- `test_upload_returns_thumbnail_key` — upload response includes non-null
|
||||
`thumbnail_key` ending in `-thumb`
|
||||
- `test_duplicate_upload_reuses_thumbnail_key` — second upload of the same
|
||||
file returns the same `thumbnail_key` as the first
|
||||
- `test_upload_succeeds_when_thumbnail_fails` — patch `generate_thumbnail` to
|
||||
raise, upload returns 200/201 with `thumbnail_key: null`
|
||||
|
||||
**Implementation** in `api/app/routers/images.py` `upload_image()`:
|
||||
1. After `await storage.put(hash_hex, data, mime_type)`, attempt thumbnail generation
|
||||
and storage in a try/except; catch any exception and leave `thumbnail_key` as `None`
|
||||
2. Call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)`
|
||||
— `generate_thumbnail` is CPU-bound (Pillow); `asyncio.to_thread` runs it in the
|
||||
default thread pool executor so it does not block the async event loop
|
||||
3. Pass `thumbnail_key` to `image_repo.create()`
|
||||
4. Add `"thumbnail_key": image.thumbnail_key` to `_image_to_dict()`
|
||||
|
||||
**Done criterion**: New tests pass; all 46 existing tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M4 — New `GET /api/v1/images/{id}/thumbnail` endpoint
|
||||
|
||||
**Goal**: Clients fetch thumbnail content; falls back to original if no thumbnail exists.
|
||||
|
||||
**TDD first** — new tests in `api/tests/integration/test_serving.py`:
|
||||
- `test_thumbnail_returns_webp` — upload image, call `/thumbnail`, assert
|
||||
200, `content-type: image/webp`, ETag, `Cache-Control` with immutable
|
||||
- `test_thumbnail_fallback_returns_original` — set `thumbnail_key=None` on a
|
||||
record, call `/thumbnail`, assert 200 with original mime_type
|
||||
- `test_thumbnail_unknown_id_returns_404` — unknown UUID → 404 `image_not_found`
|
||||
|
||||
**Implementation**: new route `GET /images/{image_id}/thumbnail` in
|
||||
`api/app/routers/images.py` using `image.thumbnail_key or image.storage_key`
|
||||
to select the key, and `"image/webp" if image.thumbnail_key else image.mime_type`
|
||||
for the content type. Same `ETag` + `Cache-Control` headers as `/file`.
|
||||
|
||||
**Done criterion**: All new tests pass; all existing tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M5 — Delete route: remove thumbnail from storage
|
||||
|
||||
**Goal**: Deleting an image also removes its thumbnail; no orphaned objects left.
|
||||
|
||||
**TDD first** — new test in `api/tests/integration/test_delete.py`:
|
||||
- `test_delete_removes_thumbnail` — upload image, delete it, then verify
|
||||
`GET /images/{id}/thumbnail` returns 404
|
||||
|
||||
**Implementation** in `api/app/routers/images.py` `delete_image()`: after
|
||||
deleting the DB record and the original object, call `await storage.delete(image.thumbnail_key)`
|
||||
if `image.thumbnail_key` is not None.
|
||||
|
||||
**Done criterion**: New test passes; all existing delete tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M6 — UI: library grid uses thumbnail endpoint
|
||||
|
||||
**Goal**: Library grid fetches thumbnails instead of full-size originals; detail
|
||||
page is unchanged.
|
||||
|
||||
**Deliverables**:
|
||||
- `ui/src/app/services/image.service.ts`:
|
||||
- Add `thumbnail_key: string | null` to `ImageRecord` interface
|
||||
- Add `getThumbnailUrl(id: string): string` returning `/api/v1/images/${id}/thumbnail`
|
||||
- `ui/src/app/library/library.component.ts` + template: replace
|
||||
`getFileUrl(image.id)` with `getThumbnailUrl(image.id)` for grid `<img src>`
|
||||
- Update Angular spec files: add `thumbnail_key: null` to all `ImageRecord`
|
||||
mock objects
|
||||
- Verify `ng test` passes and `ng build` succeeds
|
||||
|
||||
**Done criterion**: Angular build clean; all Angular tests pass; library grid
|
||||
`<img>` elements reference `/thumbnail` not `/file`.
|
||||
|
||||
## Post-design Constitution Re-check
|
||||
|
||||
| Principle | Verdict |
|
||||
|-----------|---------|
|
||||
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend`; `thumbnail.py` never touches S3 directly | ✅ |
|
||||
| §2.5 DB abstraction | `thumbnail_key` persisted only through `ImageRepository.create()` | ✅ |
|
||||
| §2.6 No speculative abstraction | One concrete function; no interface | ✅ |
|
||||
| §4.2 Immutability | Thumbnail written once at upload; never mutated | ✅ |
|
||||
| §5.1 TDD | Failing tests written before each milestone's implementation | ✅ |
|
||||
|
||||
All gates pass. Feature is ready for `/speckit-tasks`.
|
||||
130
specs/003-upload-thumbnails/research.md
Normal file
130
specs/003-upload-thumbnails/research.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Research: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
## Decision 1: Image processing library
|
||||
|
||||
**Decision**: Add `Pillow` as a runtime dependency to the API.
|
||||
|
||||
**Rationale**: Pillow is the standard Python image processing library. It supports
|
||||
reading JPEG, PNG, GIF (frame extraction), and WebP, and can encode output as WebP.
|
||||
It handles aspect-ratio-preserving resize natively via `Image.thumbnail()`. No
|
||||
other dependency is needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `wand` (ImageMagick binding): More powerful but much heavier; overkill for a
|
||||
fixed-size resize operation.
|
||||
- `opencv-python`: ML-focused, large binary; not justified for simple resize.
|
||||
- Pure `aiobotocore` + external service: Adds operational complexity with no benefit
|
||||
over a local library for a single-user app.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Thumbnail dimensions and format
|
||||
|
||||
**Decision**: Longest side ≤ 400 px, WebP output, aspect ratio preserved. This
|
||||
matches FR-003 and FR-004 exactly and the user's stated preference.
|
||||
|
||||
**Rationale**: WebP produces smaller files than JPEG/PNG at equivalent visual quality.
|
||||
400 px covers a typical grid thumbnail at 1× and 2× display density without being
|
||||
oversized. Pillow's `Image.thumbnail((400, 400))` implements this constraint directly
|
||||
(it shrinks to fit within the bounding box, never upscaling).
|
||||
|
||||
**Alternatives considered**:
|
||||
- JPEG thumbnails: Larger file sizes; no alpha channel support.
|
||||
- Multiple sizes: Out of scope for v1 per spec Assumptions.
|
||||
- On-demand resize: Rejected by user in favour of pre-generation.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: Thumbnail storage key convention
|
||||
|
||||
**Decision**: `{sha256_hash}-thumb` (e.g., the 64-char hash hex string + literal
|
||||
`-thumb`, giving a 70-char key). Stored under the same S3 bucket as originals.
|
||||
|
||||
**Rationale**: Deterministic from the image hash — no new random state needed and the
|
||||
key can always be reconstructed from the `Image.hash` field. The `-thumb` suffix
|
||||
clearly distinguishes it from the original key. Fits within a `String(70)` column.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate bucket for thumbnails: More complex bucket policy management with no benefit
|
||||
for a single-user app.
|
||||
- UUID-based key: Non-deterministic; requires an extra DB round-trip to look up.
|
||||
- `{hash}/thumb.webp` (path prefix): Works, but adds key parsing complexity for no gain.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: Database schema change
|
||||
|
||||
**Decision**: Add a nullable `thumbnail_key: String(70)` column to the `images`
|
||||
table. `NULL` means no thumbnail exists (either generation failed or the image
|
||||
pre-dates this feature). Add a new Alembic migration `002_add_thumbnail_key.py`.
|
||||
|
||||
**Rationale**: Explicitly tracking the thumbnail key in the DB makes the "does a
|
||||
thumbnail exist?" question a simple `IS NOT NULL` check rather than an S3 head
|
||||
request. Also allows the delete route to skip the thumbnail delete if the column
|
||||
is NULL, avoiding a storage error for legacy images.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Derive key at runtime from `image.hash + "-thumb"` without a DB column: Simpler but
|
||||
means no way to distinguish "thumbnail was generated" from "thumbnail was never
|
||||
attempted", and delete would need a conditional S3 head request.
|
||||
- Separate `thumbnails` table: Over-engineered; one thumbnail per image with no
|
||||
additional attributes doesn't warrant its own table.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: Where thumbnail generation lives in the code
|
||||
|
||||
**Decision**: A standalone async function `generate_thumbnail(data: bytes, mime_type: str) -> bytes`
|
||||
in a new module `api/app/thumbnail.py`. Called from the upload route after the original
|
||||
is stored, before the Image record is created.
|
||||
|
||||
**Rationale**: Keeps the thumbnail logic self-contained and independently testable.
|
||||
The upload route calls it but doesn't own it. Constitution §2.6 allows concrete
|
||||
functions when no second implementation is needed — no abstract interface is warranted.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Method on `StorageBackend`: Wrong layer; storage knows nothing about image content.
|
||||
- Inline in the upload route: Makes the route harder to test and read.
|
||||
- A `ThumbnailService` class: No justification for a class when a module-level function suffices.
|
||||
|
||||
---
|
||||
|
||||
## Decision 6: Failure handling during upload
|
||||
|
||||
**Decision**: If `generate_thumbnail()` raises, log the exception, set `thumbnail_key`
|
||||
to `NULL` on the Image record, and continue. The upload response succeeds. The
|
||||
`GET /api/v1/images/{id}/thumbnail` endpoint falls back to the original when
|
||||
`thumbnail_key` is NULL (FR-009).
|
||||
|
||||
**Rationale**: A thumbnail failure should not block the upload — the user still gets
|
||||
their image in the library. The fallback in the thumbnail endpoint ensures the grid
|
||||
still renders something.
|
||||
|
||||
---
|
||||
|
||||
## Decision 7: Thumbnail endpoint response
|
||||
|
||||
**Decision**: `GET /api/v1/images/{id}/thumbnail` follows the same pattern as
|
||||
`GET /api/v1/images/{id}/file`:
|
||||
- Returns `200` with binary content, `Content-Type: image/webp` (or original
|
||||
`mime_type` when falling back to original), `ETag`, and
|
||||
`Cache-Control: public, max-age=31536000, immutable`.
|
||||
- Returns `404` with `{"detail": "...", "code": "image_not_found"}` if the image
|
||||
does not exist.
|
||||
- Falls back to the original when `thumbnail_key IS NULL`.
|
||||
|
||||
**Rationale**: Consistent with the existing `/file` endpoint pattern established in
|
||||
feature 002. The UI only needs to know one URL per image for the grid.
|
||||
|
||||
---
|
||||
|
||||
## Decision 8: GIF handling
|
||||
|
||||
**Decision**: For GIF uploads, `generate_thumbnail()` extracts frame 0 via
|
||||
`Image.seek(0)` before resizing. The output is always WebP (static, not animated).
|
||||
|
||||
**Rationale**: Matches spec assumption: "Animated GIF thumbnails capture only the
|
||||
first frame; animation is not preserved in the thumbnail." Pillow supports this
|
||||
with `im.seek(0)`.
|
||||
180
specs/003-upload-thumbnails/spec.md
Normal file
180
specs/003-upload-thumbnails/spec.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Feature Specification: Upload Thumbnails
|
||||
|
||||
**Feature Branch**: `003-upload-thumbnails`
|
||||
**Created**: 2026-05-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "When users load the UI, full size images are fetched, which may cause considerable load time when there are a lot of images -- a grid of 20 images could silently pull several hundred megabytes. We will solve this by pre-generating thumbnails on upload: when an image is uploaded, immediately produce one (or a few) fixed-size thumbnail variants and store them alongside the original. The library always fetches the thumbnail key, the detail page fetches the original key. Zero resize cost at serve time. A single fixed-size re-encoded as WebP for smaller bytes will cover the grid view."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Fast Library Load (Priority: P1)
|
||||
|
||||
A user opens the application or runs a tag-filtered search. The image grid
|
||||
loads quickly even when the library contains many images, because each grid
|
||||
cell fetches a compact thumbnail rather than the full-size original.
|
||||
|
||||
**Why this priority**: This is the core motivation. A grid of 20 images today
|
||||
could pull hundreds of megabytes; thumbnails bring that down to a few
|
||||
megabytes, making the library usable on slow or metered connections.
|
||||
|
||||
**Independent Test**: Upload 20 images of varying sizes (including some near
|
||||
the 50 MB limit). Open the library. Measure total bytes transferred while the
|
||||
grid loads. Compare against loading the same library before this feature.
|
||||
Verify the grid renders fully and that each thumbnail is visually recognisable
|
||||
as the correct image.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a library with multiple images, **When** the user opens the
|
||||
library page, **Then** each grid cell displays a thumbnail that is visually
|
||||
recognisable as its image, and the total data transferred to render the
|
||||
full grid is substantially less than the sum of the original file sizes.
|
||||
|
||||
2. **Given** a user applies one or more tag filters, **When** the filtered
|
||||
results are displayed, **Then** thumbnails are shown for all matching
|
||||
images with the same reduced data footprint.
|
||||
|
||||
3. **Given** a library with images of mixed types (JPEG, PNG, GIF, WebP),
|
||||
**When** the grid loads, **Then** thumbnails for all types display
|
||||
correctly.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Full-Size Image on Detail Page (Priority: P1)
|
||||
|
||||
A user clicks an image in the library grid to open its detail page. The
|
||||
full-size original is displayed, not the thumbnail. The experience is
|
||||
unchanged from before this feature.
|
||||
|
||||
**Why this priority**: The detail page is where the user inspects or copies
|
||||
an image; showing the thumbnail there would degrade the product's core value.
|
||||
|
||||
**Independent Test**: Open any image's detail page. Verify the image
|
||||
displayed matches the original resolution and file size, not the thumbnail
|
||||
dimensions.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user clicks an image thumbnail in the library, **When** the
|
||||
detail page loads, **Then** the full-size original image is displayed at
|
||||
its native resolution.
|
||||
|
||||
2. **Given** the user navigates directly to an image's detail URL, **When**
|
||||
the page loads, **Then** the full-size original is displayed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1)
|
||||
|
||||
A user uploads a new image. Without any additional action, a thumbnail is
|
||||
available immediately. There is no separate step or explicit request to
|
||||
generate a thumbnail.
|
||||
|
||||
**Why this priority**: The value of the feature depends entirely on thumbnails
|
||||
being present for every image. Manual generation or lazy generation would
|
||||
create inconsistencies in the grid.
|
||||
|
||||
**Independent Test**: Upload a new image. Immediately open the library. Verify
|
||||
the new image's thumbnail appears in the grid without any extra action.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user uploads a supported image, **When** the upload
|
||||
completes, **Then** a thumbnail is available and appears correctly in
|
||||
the library grid.
|
||||
|
||||
2. **Given** the user uploads a duplicate image (already in the library),
|
||||
**When** the upload completes, **Then** no redundant thumbnail is
|
||||
generated — the existing thumbnail is reused.
|
||||
|
||||
3. **Given** the user uploads an image at or near the maximum supported
|
||||
file size (50 MB), **When** the upload completes, **Then** the thumbnail
|
||||
is generated successfully and the upload response time remains acceptable.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when thumbnail generation fails during upload? → The upload
|
||||
still succeeds and the original image is stored; a fallback to the original
|
||||
is shown in the grid, or the item is hidden until the thumbnail is available
|
||||
(assumption: fall back to original rather than silently drop the image).
|
||||
- What happens when an image is deleted? → Both the original and its thumbnail
|
||||
are removed from storage.
|
||||
- What happens with existing images that were uploaded before this feature? →
|
||||
Those images have no pre-generated thumbnail; the grid falls back to the
|
||||
original for those entries until a backfill is performed (backfill is out of
|
||||
scope for v1 of this feature).
|
||||
- What happens with animated GIFs? → A static thumbnail is generated from the
|
||||
first frame.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST generate a thumbnail for every newly uploaded
|
||||
image as part of the upload operation, before the upload response is
|
||||
returned to the caller.
|
||||
- **FR-002**: Thumbnails MUST be stored in the same object storage as the
|
||||
original, addressable by a distinct key derived from the image.
|
||||
- **FR-003**: The thumbnail MUST be encoded as WebP regardless of the original
|
||||
image format.
|
||||
- **FR-004**: The thumbnail MUST fit within a fixed maximum dimension on its
|
||||
longest side, preserving the original aspect ratio; no dimension of the
|
||||
thumbnail MAY exceed 400 pixels.
|
||||
- **FR-005**: The library grid view MUST fetch and display thumbnails instead
|
||||
of original images.
|
||||
- **FR-006**: The image detail view MUST continue to fetch and display the
|
||||
full-size original.
|
||||
- **FR-007**: When a duplicate image is uploaded, the thumbnail MUST NOT be
|
||||
regenerated or re-stored; the existing thumbnail is reused.
|
||||
- **FR-008**: When an image is deleted, its thumbnail MUST also be deleted
|
||||
from storage.
|
||||
- **FR-009**: If thumbnail generation fails during upload, the upload MUST
|
||||
still succeed; the system MUST fall back to serving the original image in
|
||||
the grid for that entry.
|
||||
- **FR-010**: The API MUST expose a way for clients to retrieve the thumbnail
|
||||
content for a given image, distinct from the full-size content endpoint.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Image**: Gains a new optional attribute indicating whether a thumbnail is
|
||||
available and the key under which the thumbnail is stored.
|
||||
- **Thumbnail**: A derived, smaller representation of an Image. Key
|
||||
attributes: storage key, dimensions (width × height), format (WebP),
|
||||
relationship to its source Image.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The total data transferred to render a 20-image library grid is
|
||||
reduced by at least 80% compared to fetching full-size originals for the
|
||||
same images.
|
||||
- **SC-002**: The library grid's first page loads in under 2 seconds on a
|
||||
local network connection for a library of 1,000 images, with thumbnails
|
||||
visible without a second load.
|
||||
- **SC-003**: Thumbnails are available immediately after upload completes —
|
||||
no polling or manual refresh is required.
|
||||
- **SC-004**: The detail page continues to show the full-size original; no
|
||||
regression in detail-page image quality is introduced.
|
||||
- **SC-005**: Deleting an image removes both the original and its thumbnail;
|
||||
no orphaned thumbnail objects remain in storage after deletion.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A single thumbnail size (longest side ≤ 400 px, WebP) is sufficient for
|
||||
the library grid view in v1. Additional sizes or formats are out of scope.
|
||||
- Thumbnail generation happens synchronously during the upload request. Async
|
||||
background processing is not required for v1.
|
||||
- Existing images uploaded before this feature are not automatically
|
||||
backfilled with thumbnails in v1; the grid falls back to the original for
|
||||
those entries.
|
||||
- Animated GIF thumbnails capture only the first frame; animation is not
|
||||
preserved in the thumbnail.
|
||||
- The thumbnail storage key is derived deterministically from the image's
|
||||
existing content hash, so no additional database column is strictly required
|
||||
to locate it — however the Image record will track thumbnail availability
|
||||
for correctness.
|
||||
- No change is required to tag management, duplicate detection, or any other
|
||||
upload behaviour beyond adding thumbnail generation.
|
||||
186
specs/003-upload-thumbnails/tasks.md
Normal file
186
specs/003-upload-thumbnails/tasks.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Tasks: Upload Thumbnails
|
||||
|
||||
**Input**: Design documents from `specs/003-upload-thumbnails/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
|
||||
|
||||
**TDD**: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.
|
||||
|
||||
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [X] T001 Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`; rebuild the Docker API image (`docker compose build api`) so Pillow is available inside the container for all subsequent test runs
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Thumbnail generation logic and the DB schema change that all three user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: All tasks in this phase must complete before any user story work begins.
|
||||
|
||||
### Tests for thumbnail utility (write FIRST — must FAIL before T005) ⚠️
|
||||
|
||||
- [X] T002 Write 6 unit tests in `api/tests/unit/test_thumbnail.py` — `test_thumbnail_is_webp` (output begins with WebP magic bytes `RIFF...WEBP`), `test_thumbnail_fits_within_400px` (both dimensions ≤ 400), `test_thumbnail_preserves_aspect_ratio` (ratio within 1% of original), `test_thumbnail_handles_gif_first_frame` (GIF input → static WebP, no animation), `test_thumbnail_handles_png_with_alpha` (RGBA PNG → valid WebP output), `test_thumbnail_does_not_upscale` (100×100 input stays ≤ 100×100); to confirm the TDD red state, first create an empty stub `api/app/thumbnail.py` (so pytest can collect tests), then run `pytest api/tests/unit/test_thumbnail.py` and confirm all 6 **fail** with assertion errors (not import errors)
|
||||
|
||||
### Thumbnail utility implementation
|
||||
|
||||
- [X] T003 Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`: open bytes with `PIL.Image.open(BytesIO(data))`, call `.seek(0)` to target frame 0 (GIF support), convert mode to RGB or RGBA as needed for WebP, call `.thumbnail((400, 400), PIL.Image.LANCZOS)` (never upscales), save to a `BytesIO` buffer as WebP quality=80, return bytes; run `pytest api/tests/unit/test_thumbnail.py` and confirm all 6 pass
|
||||
|
||||
### Database migration
|
||||
|
||||
- [X] T004 [P] Create `api/alembic/versions/002_add_thumbnail_key.py` with `upgrade()` calling `op.add_column("images", sa.Column("thumbnail_key", sa.String(70), nullable=True))` and `downgrade()` calling `op.drop_column("images", "thumbnail_key")`; set `revision="002"`, `down_revision="001"`
|
||||
- [X] T005 [P] Add `thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)` to the `Image` class in `api/app/models.py`
|
||||
- [X] T006 Add `thumbnail_key: str | None = None` keyword argument to `ImageRepository.create()` in `api/app/repositories/image_repo.py`; include it in the `Image(...)` constructor call; run `docker compose run --rm api alembic upgrade head` inside the container and confirm migration applies cleanly; run `pytest api/` to confirm all 46 existing tests still pass
|
||||
|
||||
**Checkpoint**: Pillow available, thumbnail.py works, schema migrated, all existing tests green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1) 🎯
|
||||
|
||||
**Goal**: Every new upload triggers thumbnail generation and storage as part of the same request. No extra step required from the user.
|
||||
|
||||
**Independent Test**: Upload any supported image. Immediately check the upload response — it includes a non-null `thumbnail_key`. Call `GET /api/v1/images/{id}/thumbnail` — it returns 200 with `content-type: image/webp`.
|
||||
|
||||
### Tests for User Story 3 (write FIRST — must FAIL before T008) ⚠️
|
||||
|
||||
- [X] T007 [US3] Add three tests to `api/tests/integration/test_upload.py`: `test_upload_returns_thumbnail_key` (upload a JPEG/PNG/WebP, assert response JSON contains `thumbnail_key` ending in `-thumb`), `test_duplicate_upload_reuses_thumbnail_key` (upload same file twice, assert both responses have equal, non-null `thumbnail_key`), and `test_upload_succeeds_when_thumbnail_fails` (patch `generate_thumbnail` to raise an exception, upload an image, assert response is 200/201 with `thumbnail_key: null` — upload must not be blocked by thumbnail failure); run `pytest api/tests/integration/test_upload.py` and confirm all three new tests **fail**
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T008 [US3] In `api/app/routers/images.py` `upload_image()`: import `asyncio` and `generate_thumbnail` from `app.thumbnail`; after `await storage.put(hash_hex, data, mime_type)`, wrap thumbnail generation in a try/except — call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)` (runs CPU-bound Pillow work off the async event loop), store result via `await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")`, set `thumbnail_key = f"{hash_hex}-thumb"`; on any exception log a warning and leave `thumbnail_key = None`; pass `thumbnail_key=thumbnail_key` to `image_repo.create()`
|
||||
- [X] T009 [US3] Add `"thumbnail_key": image.thumbnail_key` to the dict returned by `_image_to_dict()` in `api/app/routers/images.py`
|
||||
- [X] T010 [US3] Run `pytest api/tests/integration/test_upload.py` to confirm both new tests pass; then run `pytest api/` to confirm no regressions
|
||||
|
||||
**Checkpoint**: Upload generates and stores a thumbnail. Duplicate uploads reuse the existing thumbnail. `thumbnail_key` appears in all image metadata responses.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 1 — Fast Library Load (Priority: P1)
|
||||
|
||||
**Goal**: The library grid fetches compact WebP thumbnails instead of full-size originals, dramatically reducing page-load bandwidth.
|
||||
|
||||
**Independent Test**: Open the library in a browser with DevTools network tab open. All grid `<img>` elements request `/api/v1/images/{id}/thumbnail`. Total bytes transferred for a 20-image grid is a small fraction of what the originals would cost.
|
||||
|
||||
### Tests for User Story 1 (write FIRST — must FAIL before T013) ⚠️
|
||||
|
||||
- [X] T011 [US1] Add `test_thumbnail_returns_webp` (upload image, GET `/thumbnail`, assert 200, `content-type: image/webp`, ETag header matches `f'"{image_hash}"'`, `"immutable"` in `cache-control`, non-empty content), `test_thumbnail_fallback_returns_original` (manually set `thumbnail_key=None` on a DB record via the session fixture, GET `/thumbnail`, assert 200 with original `mime_type` in content-type), and `test_thumbnail_unknown_id_returns_404` (unknown UUID, assert 404 `image_not_found`) to `api/tests/integration/test_serving.py`; run and confirm all three **fail**
|
||||
- [X] T012 [P] [US1] In `ui/src/app/services/image.service.ts`: add `thumbnail_key: string | null` field to the `ImageRecord` interface; add `getThumbnailUrl(id: string): string { return \`${this.base}/images/${id}/thumbnail\`; }` method to `ImageService`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Add `GET /api/v1/images/{image_id}/thumbnail` route to `api/app/routers/images.py`: look up image (404 if missing), select `key = image.thumbnail_key or image.storage_key` and `media_type = "image/webp" if image.thumbnail_key else image.mime_type`, call `await storage.get(key)` in a try/except (500 `storage_error` on failure), return `Response(content=data, media_type=media_type, headers={"ETag": f'"{image.hash}"', "Cache-Control": "public, max-age=31536000, immutable"})`
|
||||
- [X] T014 [US1] In `ui/src/app/library/library.component.ts` and its HTML template: replace every use of `imageService.getFileUrl(image.id)` (or equivalent) with `imageService.getThumbnailUrl(image.id)` for grid cell `<img src>` bindings
|
||||
- [X] T015 [US1] Add `thumbnail_key: null` to every `ImageRecord` mock/stub object in `ui/src/app/services/image.service.spec.ts`, `ui/src/app/library/library.component.spec.ts`, `ui/src/app/detail/detail.component.spec.ts`, and `ui/src/app/upload/upload.component.spec.ts`
|
||||
- [X] T016 [US1] Run `pytest api/tests/integration/test_serving.py` to confirm all three new thumbnail tests pass and no existing serving tests regress
|
||||
|
||||
**Checkpoint**: `GET /api/v1/images/{id}/thumbnail` serves WebP with caching headers. Falls back to original for legacy images. Library grid `<img>` elements all use the thumbnail endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 2 — Full-Size Image on Detail Page (Priority: P1)
|
||||
|
||||
**Goal**: The detail page continues to display the full-size original. No regression introduced by the thumbnail work.
|
||||
|
||||
**Independent Test**: Navigate to any image detail page. The image displayed is full-resolution. Browser DevTools shows the detail `<img>` requests `/api/v1/images/{id}/file`, not `/thumbnail`.
|
||||
|
||||
### Verification for User Story 2
|
||||
|
||||
- [X] T017 [US2] Confirm `ui/src/app/detail/detail.component.ts` still calls `imageService.getFileUrl(image.id)` (not `getThumbnailUrl`) for its `<img src>` — no code change expected; if the file was accidentally updated in T014 or T015, revert the detail component to `getFileUrl`
|
||||
- [X] T018 [US2] Run `ng test` (inside the UI container or locally) and confirm all Angular unit tests pass including the detail component spec; run `ng build` to confirm the Angular build succeeds
|
||||
|
||||
**Checkpoint**: Detail page verified unchanged. Angular build and tests clean.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Delete cleanup, final test run, linting.
|
||||
|
||||
### Delete thumbnail on image deletion
|
||||
|
||||
- [X] T019 Write `test_delete_removes_thumbnail` in `api/tests/integration/test_delete.py`: upload an image, delete it, then `GET /api/v1/images/{id}/thumbnail` and assert 404; run and confirm it **fails** (currently delete does not remove the thumbnail object)
|
||||
- [X] T020 In `api/app/routers/images.py` `delete_image()`: capture `thumbnail_key = image.thumbnail_key` before `image_repo.delete(image)`; after deleting the original via `await storage.delete(storage_key)`, add `if thumbnail_key: await storage.delete(thumbnail_key)`; run `pytest api/tests/integration/test_delete.py` to confirm new test and all existing delete tests pass
|
||||
|
||||
### Final validation
|
||||
|
||||
- [X] T021 [P] Run `~/.local/bin/ruff check api/app/thumbnail.py api/app/routers/images.py api/app/models.py api/app/repositories/image_repo.py api/tests/unit/test_thumbnail.py` and fix any lint issues in the changed files
|
||||
- [X] T022 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: 46 existing + ~10 new = ~56 total)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (Pillow must be installed before tests can import it)
|
||||
- **US3 (Phase 3)**: Depends on Phase 2 complete (T002–T006 all done)
|
||||
- **US1 (Phase 4)**: Depends on Phase 3 complete (upload must set `thumbnail_key` before the endpoint can serve one)
|
||||
- **US2 (Phase 5)**: Depends on Phase 4 complete (Angular changes in T014/T015 must be done before verifying no regression)
|
||||
- **Polish (Phase 6)**: Depends on Phases 3, 4, and 5 complete
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- T002 (write failing tests) MUST precede T003 (implement thumbnail.py)
|
||||
- T004 and T005 can run in parallel (different files)
|
||||
- T006 (repo change) depends on T005 (model must compile first)
|
||||
- T007 (write failing upload tests) MUST precede T008 (implement upload change)
|
||||
- T011 (write failing serving tests) and T012 (UI service) can run in parallel
|
||||
- T011 MUST precede T013 (implement thumbnail route)
|
||||
- T019 (write failing delete test) MUST precede T020 (implement delete cleanup)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 (Foundational)
|
||||
|
||||
```bash
|
||||
# After T003 is done, run T004 and T005 together:
|
||||
Task: "Create 002_add_thumbnail_key.py migration in api/alembic/versions/"
|
||||
Task: "Add thumbnail_key column to Image ORM in api/app/models.py"
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 4 (US1)
|
||||
|
||||
```bash
|
||||
# T011 and T012 touch different layers — run together:
|
||||
Task: "Write 3 failing thumbnail serving tests in api/tests/integration/test_serving.py"
|
||||
Task: "Add getThumbnailUrl() and thumbnail_key field to ui/src/app/services/image.service.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (All three user stories — tightly coupled)
|
||||
|
||||
All three user stories are P1 and interdependent: US3 (generation) enables US1 (grid) which proves US2 (detail unchanged) by contrast. Complete all phases in order.
|
||||
|
||||
1. Phase 1: T001 (Pillow setup)
|
||||
2. Phase 2: T002–T006 (core infrastructure)
|
||||
3. Phase 3: T007–T010 (upload generates thumbnail)
|
||||
4. Phase 4: T011–T016 (thumbnail endpoint + UI)
|
||||
5. Phase 5: T017–T018 (detail page verification)
|
||||
6. Phase 6: T019–T022 (delete cleanup + polish)
|
||||
7. **STOP and VALIDATE**: Open library in browser; DevTools shows `/thumbnail` requests; bandwidth used is a fraction of original file sizes
|
||||
|
||||
### Total tasks: 22 (T001–T022)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and have no mutual dependencies within their phase
|
||||
- T002 must be run **before** T003 and confirmed failing — this is the TDD red step
|
||||
- T007, T011, T019 are all "write failing test" steps — confirm failure before implementing
|
||||
- `thumbnail_key` in the API response is informational; the UI always calls `/thumbnail` and lets the endpoint handle the fallback — no client-side conditional logic needed
|
||||
- Existing images (pre-dating this feature) will have `thumbnail_key: null`; the `/thumbnail` endpoint serves their original transparently
|
||||
- The backfill migration for existing images is explicitly out of scope for this feature
|
||||
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`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user