Update admin theme/layout and refresh README details.

Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Git / editor noise
.git
.gitignore
.cursor
.vscode
*.md
# Dependencies & build artifacts (installed inside the image)
**/node_modules
**/dist
**/coverage
**/.turbo
# Python API — not needed for the web image
apps/api
# Infra / CI helpers
infra
# Env & secrets (injected via compose or mounted at runtime)
**/.env
**/.env.*
!**/.env.example
# Test / e2e artifacts
**/playwright-report
**/test-results
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install frontend deps
run: pnpm install --frozen-lockfile=false
- name: Install backend deps
run: pip install -r apps/api/requirements-dev.txt
- name: Lint
run: pnpm lint
- name: Types
run: pnpm typecheck
- name: Mypy
working-directory: apps/api
run: python -m mypy app
- name: Frontend tests with coverage
run: pnpm --filter web test:ci
- name: Backend tests with coverage
working-directory: apps/api
run: python -m pytest --cov=app --cov-fail-under=90
- name: Security scan (python deps)
run: pip-audit
- name: Bandit scan
run: bandit -q -r apps/api/app
- name: Security scan (node deps)
run: pnpm audit --audit-level high
- name: Gitleaks scan
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v
- name: Install Playwright browsers
run: pnpm --filter web exec playwright install chromium
- name: E2E smoke
run: pnpm --filter web e2e
+19
View File
@@ -0,0 +1,19 @@
node_modules/
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
coverage/
.coverage
dist/
playwright-report/
test-results/
.DS_Store
.env
*.pyc
apps/web/.env
apps/api/.env
apps/api/.e2e.sqlite
apps/api/.e2e-test.sqlite
apps/api/data/secrets/install.env
apps/api/data/secrets/install.meta.json
+426
View File
@@ -0,0 +1,426 @@
# Compton Platform
Monorepo-lite project that follows the `docs/TZ.md` specification for the Compton platform.
## Stack
- **Frontend:** React 19, TypeScript, Vite, React Router, TanStack Query, Zustand, RHF + Zod, Ant Design (admin panel)
- **Backend:** FastAPI, SQLAlchemy 2, Alembic, Pydantic v2
- **Data/Infra:** PostgreSQL, Redis, MinIO (S3-compatible), Docker Compose
- **Quality:** Vitest, Testing Library, Playwright, pytest, coverage gates in CI
## Repository Layout
- `apps/web` — frontend SPA
- `apps/api` — backend API (`app/core/crypto.py` — unified crypto; `app/core/install_secrets.py` — bootstrap/lock)
- `apps/api/data/secrets/` — per-install secrets (`install.env`, gitignored)
- `apps/api/data/compton_settings.json` — runtime panel settings (superuser-editable via API)
- `packages/shared-types` — generated API types contract target
- `packages/eslint-config` — shared eslint config package
- `infra` — docker/nginx/ci helper files
- `docker-compose.dev-ports.yml` — optional override to expose DB/Redis/MinIO on host
## Quick Start
**Full stack in Docker** (API + DB + frontend — no local `pnpm install` required):
```bash
# 1. Bootstrap install secrets — REQUIRED before the first docker compose up
python apps/api/scripts/bootstrap_install.py
# 2. Build and start everything
docker compose --profile docker-web up -d --build
# 3. Verify (Windows PowerShell: use curl.exe, not curl — it is an alias for Invoke-WebRequest)
curl.exe http://localhost:8000/api/v1/health
```
Open [http://localhost:5173](http://localhost:5173) — static landing (`index.html`). SPA routes (`/login`, `/admin`, `/profile`, …) are served via `app.html` fallback in Vite dev/preview.
The `web` container runs Vite with hot-reload; dependencies are installed inside the container automatically.
Log in as `admin@compton.example` (password `Admin1234` by default) and open `/admin`. See [Seed data](#seed-data) for all demo accounts.
**Optional** — copy env files if you also run API or frontend locally (hybrid mode):
```bash
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
```
> **Important:** Run bootstrap **before** the first `docker compose up`. If Postgres was started without `install.env`, the API will fail with `password authentication failed for user "compton_app"`. Fix: `docker compose --profile docker-web down -v`, then bootstrap + up again. See [Troubleshooting](#troubleshooting).
**Hybrid setup** (Docker API + local frontend):
```bash
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
pnpm install
python apps/api/scripts/bootstrap_install.py
docker compose up -d
pnpm --filter web dev
```
## Local Setup
### Prerequisites
- **Docker + Docker Compose** — enough for the full-stack Docker workflow
- **Node.js 22+ and pnpm 9+** — only if you run the frontend locally (`pnpm --filter web dev`)
- **Python 3.12** — bootstrap script, local API, or tests outside Docker
### Start services
| Mode | Command | What runs |
| ---- | ------- | --------- |
| **Full Docker (recommended)** | `docker compose --profile docker-web up -d --build` | API, Postgres, Redis, MinIO, Vite on `:5173` |
| **API + infra only** | `docker compose up -d` | API, Postgres, Redis, MinIO — frontend locally |
| **Local API + local frontend** | see [Run applications](#run-applications) | everything on host |
| Service | URL / Port | Notes |
| --------- | ---------------------------------------------- | ----- |
| API | [http://localhost:8000](http://localhost:8000) | migrations + seed on startup |
| Web (dev) | [http://localhost:5173](http://localhost:5173) | Docker `--profile docker-web` or `pnpm --filter web dev` |
| Postgres | internal docker network | host access via `docker-compose.dev-ports.yml` |
| Redis | internal docker network | host access via `docker-compose.dev-ports.yml` |
| MinIO | internal docker network | host access via `docker-compose.dev-ports.yml` |
> Do not run Docker `web` and `pnpm --filter web dev` at the same time — both bind port **5173**.
The Docker `web` container bind-mounts source for live-reload on Windows/macOS (`CHOKIDAR_USEPOLLING=true`); `node_modules` stay isolated inside the container.
### Install secrets
Each project copy gets unique runtime secrets generated once and locked forever (prevents accidental rotation and DB credential mismatch).
| File | Purpose |
| ---- | ------- |
| `apps/api/data/secrets/install.env` | PostgreSQL, JWT, S3/MinIO credentials (gitignored) |
| `apps/api/data/secrets/install.meta.json` | Install ID and lock timestamp (gitignored) |
**Bootstrap (required before first `docker compose up`):**
```bash
python apps/api/scripts/bootstrap_install.py
```
Generated keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL`, `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER`, `S3_SECRET_KEY`, `MINIO_ROOT_PASSWORD`.
Docker Compose passes `install.env` directly into the `api`, `postgres`, and `minio` services — no root `.env` or `--env-file` flag needed.
- Re-run is safe: existing locked secrets are never overwritten.
- API entrypoint also calls `ensure_install_secrets()` on startup (adopts env vars when migrating from an older setup).
- **Do not rotate** `POSTGRES_PASSWORD` / JWT secrets after first bootstrap without a coordinated DB migration — see [docs/secrets-recovery.md](docs/secrets-recovery.md).
**Host access to DB/Redis/MinIO** (DBeaver, pgAdmin, MinIO console):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d
```
| Exposed port | Service |
| ------------ | ------- |
| 5432 | PostgreSQL |
| 6379 | Redis |
| 9000 / 9001 | MinIO API / console |
**Reveal credentials:** Admin → Security → Install Secrets (superuser only, audited). Supports `database_password`, `jwt_access_secret`, `jwt_refresh_pepper`, `s3_secret_key`.
### Configure environment
For hybrid or fully local dev, copy example env files:
```bash
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
```
Key variables:
| Variable | App | Purpose |
| ----------------------------------------- | --- | ---------------------------------------------------- |
| `DATABASE_URL` | api | PostgreSQL connection string |
| `APP_ENV` | api | `development` / `production` startup guards |
| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | Token signing (32+ bytes) |
| `VITE_API_URL` | web | Local dev: `http://localhost:8000`. Docker `web`: `http://api:8000` |
| `VITE_USE_API_PROXY` | web | `true` — proxy `/api` through Vite (default in dev) |
| `EMAIL_DELIVERY_MODE` | api | `memory` (Docker dev) or `smtp` (real mail) |
| `STORAGE_MODE` | api | `s3` (MinIO) or `memory` (tests) |
| `S3_*` | api | MinIO/S3 credentials and bucket |
| `CORS_ORIGINS` | api | Must include `http://localhost:5173` |
| `ENABLE_TEST_ROUTES` | api | `true` only for E2E (email token helper) |
| `TRUSTED_PROXY_IPS` | api | Which proxy IPs may set `X-Forwarded-For` |
| `ADMIN_INITIAL_PASSWORD` | api | Seed password for superuser admin |
| `DEMO_USER_PASSWORD` | api | Seed password for regular demo user |
| `DEMO_OPS_PASSWORD` | api | Seed password for ops admin (no superuser) |
| `ENABLE_RATE_LIMIT` | api | Rate limiting (required `true` in production) |
| `COOKIE_SECURE` | api | HttpOnly refresh cookie `Secure` flag |
| `COMPTON_SETTINGS_PATH` | api | Path to runtime settings JSON (default `data/compton_settings.json`) |
In dev the frontend proxies API requests through Vite (`/api` → backend). Locally the target is `localhost:8000`; in Docker Compose it is the `api` service.
### Runtime settings (`compton_settings.json`)
Non-secret runtime toggles live in `apps/api/data/compton_settings.json` and are applied on API startup. Superusers can read/update them via `GET/PATCH /admin/settings`.
Env vars with the same keys (e.g. `ENABLE_RATE_LIMIT`, `CORS_ORIGINS`) act as **locks** — when set, the corresponding JSON field cannot be changed from the admin panel.
Typical fields: rate limit, API docs, secure cookie, JWT TTL, CORS origins, SMTP host/port, avatar limits, audit retention.
### Install dependencies (hybrid / local dev only)
Skip if you use `docker compose --profile docker-web` — dependencies are installed inside the `web` container.
```bash
pnpm install
pip install -r apps/api/requirements-dev.txt
```
### Run applications
**Option A — Full Docker stack:**
```bash
python apps/api/scripts/bootstrap_install.py # first run only
docker compose --profile docker-web up -d --build
```
**Option B — Docker API + local frontend:**
```bash
python apps/api/scripts/bootstrap_install.py # first run only
docker compose up -d
pnpm --filter web dev
```
**Option C — Local API + local frontend:**
```bash
cd apps/api
alembic upgrade head
uvicorn app.main:app --reload --app-dir .
```
In another terminal:
```bash
pnpm --filter web dev
```
Open [http://localhost:5173](http://localhost:5173).
### Seed data
After migrations the database is seeded on every API startup with demo users and CMS pages. Passwords come from env vars (defaults in `.env.example`):
| Email | Env variable | Default (dev) | Role | Superuser | Access |
| ----- | ------------ | --------------- | ---- | --------- | ------ |
| `admin@compton.example` | `ADMIN_INITIAL_PASSWORD` | `Admin1234` | `admin` | yes | Full admin + Security/Diagnostics/Install Secrets |
| `ops@compton.example` | `DEMO_OPS_PASSWORD` | `OpsAdmin1234` | `admin` | no | Users, Content, Activity (no Security/Diagnostics) |
| `user@compton.example` | `DEMO_USER_PASSWORD` | `User1234` | `user` | no | Profile only |
**Content pages:** `about`, `privacy`, `terms` (published).
> Change demo passwords via env before first seed in production. Admin-created users must pass the shared password policy (length, complexity, denylist).
## Frontend Routes
The project uses a **dual-entry** frontend:
| Entry | Served at | Purpose |
| ----- | --------- | ------- |
| `index.html` | `/` | Public marketing landing (static HTML/CSS/JS in `main/`) |
| `app.html` | `/login`, `/register`, `/profile`, `/admin`, `/pages/:slug`, … | React SPA (auth, profile, admin, CMS pages) |
Vite middleware rewrites SPA paths to `app.html` on dev/preview (`vite.main-static.ts`).
| Path | Page | Access |
| ------------------ | --------------------- | ----------------------- |
| `/` | Landing (`index.html`)| public |
| `/login` | Login | guest |
| `/register` | Registration | guest |
| `/verify` | Email verification | public |
| `/forgot-password` | Password reset request| guest |
| `/reset-password` | Set new password | public (with token) |
| `/pages/:slug` | CMS page | public (published only) |
| `/profile` | Profile CRUD + avatar | authenticated |
| `/admin` | Admin panel | role `admin` |
**Auth model:** one login flow for everyone. The `is_superuser` flag on admin accounts controls access to critical panel sections (Security, Diagnostics, Install Secrets reveal, runtime settings). Regular admins see Users, Content, and Activity only.
- **Access JWT** — in memory only (Zustand), not in `localStorage` / `sessionStorage`.
- **Refresh token** — HttpOnly cookie (`Path=/api/v1/auth`, `SameSite=Lax`), rotated on each refresh.
- **Session restore on reload** — `AuthBootstrap` calls a deduplicated `bootstrapSessionRefresh()` when a session hint exists in `sessionStorage` **or** the current path is protected (`/admin`, `/profile`). Guards wait for `bootstrapped` before redirecting.
- **After login** — admins go to `/admin`, regular users to `/profile`.
Admin users can return to the public landing via the **«На сайт»** topbar link (full navigation to `/`, not client-side React routing).
## API Overview
Base URL: `http://localhost:8000/api/v1`
| Area | Endpoints |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Health** | `GET /health` |
| **Auth** | `POST /auth/register`, `/login`, `/logout`, `/refresh`, `/verify-email`, `/forgot-password`, `/reset-password` |
| **Profile** | `GET/PATCH /users/me`, `POST /users/me/password`, `POST /users/me/avatar` |
| **Content** | `GET /content/pages`, `GET /content/pages/{slug}`, admin: `POST/PATCH/DELETE /content/pages`, `GET /content/pages/manage/all` |
| **Admin** | `GET/PATCH /admin/users`, `POST /admin/users`, `PATCH /admin/users/{id}/password`, `GET /admin/stats`, `GET/PATCH /admin/settings`, `GET /admin/diagnostics/report`, `GET /admin/activity-feed`, `POST /admin/ui-activity`, `GET /admin/server-log`, `GET /admin/secrets`, `POST /admin/secrets/reveal` |
| **Media** | `GET /media/files/{path}` |
OpenAPI docs (when `ENABLE_DOCS=true`): [http://localhost:8000/api/v1/docs](http://localhost:8000/api/v1/docs)
## Database Migrations
```bash
cd apps/api
alembic upgrade head # apply schema
alembic downgrade -1 # rollback one revision
```
Tables: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`.
**Security constraints** (PostgreSQL, migration `20260714_0004`):
- `CHECK` on `users.role`, `users.status`, `content_pages.status`
- Superuser rule: `is_superuser=true` only when `role=admin`
- Indexes on `expires_at` for token tables
Expired tokens are cleaned up on API startup (`cleanup_expired_tokens`, 30-day retention).
## Auth Email (SMTP)
- Verify/reset use one-time opaque tokens (SHA-256 hash + pepper in DB, TTL 1 hour); raw token omitted from production email bodies
- Env: `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`, `FRONTEND_URL`, `EMAIL_DELIVERY_MODE`
- Docker dev: `EMAIL_DELIVERY_MODE=memory` (no real SMTP required)
- Local SMTP: Mailpit/Mailhog on `localhost:1025` (`SMTP_HOST=localhost`, `SMTP_PORT=1025`)
- Tests: `EMAIL_DELIVERY_MODE=memory` (in-memory outbox)
## Profile & Avatar (MinIO)
- `GET/PATCH /api/v1/users/me`, `POST /api/v1/users/me/password`, `POST /api/v1/users/me/avatar`
- Avatar: jpeg/png/webp, max 2 MB, re-encoded via Pillow (SVG rejected)
- Storage env: `STORAGE_MODE` (`s3` or `memory`), `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`
- Local dev: MinIO on `localhost:9000` (console `9001`), bucket `compton` (created on API startup)
- Tests: `STORAGE_MODE=memory` (in-memory file store, no MinIO)
## Admin Panel
- Route: `/admin` (`AdminGuard`, role `admin`)
- UI: WESP-style **Ant Design** shell with fixed left sidebar, content topbar, and a dedicated admin theme system
- Topbar actions:
- **Light/Dark** — toggles isolated admin theme (preference persisted in `localStorage`)
- **«На сайт»** — opens the static landing at `/` (`index.html`) via full navigation
- **Logout** — revokes refresh cookie and redirects to `/login`
- Reload UX: no blocking «Loading…» screens; admin routes preload the current admin background (`#f6f6f4` in Light, `#1f2229` in Dark) to avoid flash
- Sections:
- **Users** — list/create/patch role, status, password
- **Content** — CMS CRUD
- **Security** *(superuser only)* — runtime toggles + Install Secrets reveal
- **Diagnostics** *(superuser only)* — security health checks (JWT, CORS, DB credentials, SSL mode, token table size, install secrets lock)
- **Activity** — admin audit feed + server log tail
- Business rules: last admin protected, no self-demotion, no self-block; critical endpoints require `is_superuser`
### Auth UI (zootech)
Auth pages (`/login`, `/register`, `/forgot-password`, `/reset-password`, `/verify`) use a WESP-inspired zootech card layout with organic theme tokens (`#48816d`, centered card, icon inputs). `AppHeader` is hidden on auth routes and in `/admin`.
## Tests
```bash
# Frontend unit/component (coverage ≥ 85%)
pnpm --filter web test:ci
# Same, inside Docker web container (when pnpm is not installed on host)
docker compose --profile docker-web exec web sh -c "cd apps/web && pnpm test:ci"
# Backend unit/integration (coverage ≥ 90%)
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
# E2E regression (Playwright §15.7, 16 critical scenarios)
pnpm --filter web e2e
```
### E2E (Playwright)
- `pnpm --filter web e2e` starts a dedicated API (`:8001`) and Vite (`:5175`) — does not conflict with dev on `:5173` or Docker API on `:8000`
- Uses SQLite + `EMAIL_DELIVERY_MODE=memory` + `ENABLE_TEST_ROUTES=true`
- Test email tokens: `GET /api/v1/test/emails/latest-token` (only when test routes enabled)
- Reuse running API: `E2E_START_API=false E2E_API_URL=http://localhost:8000 pnpm --filter web e2e`
- Install browsers once: `pnpm --filter web exec playwright install chromium`
## Troubleshooting
| Problem | Solution |
| ------- | -------- |
| **`docker compose up` fails: install.env missing** | Run `python apps/api/scripts/bootstrap_install.py` before first start |
| **API exits: `password authentication failed for user "compton_app"`** | Postgres volume was initialized before bootstrap. Reset local dev data: `docker compose --profile docker-web down -v`, bootstrap again, then `docker compose --profile docker-web up -d --build`. See [docs/secrets-recovery.md](docs/secrets-recovery.md) |
| **Login failed** with correct credentials | Check API: `curl http://localhost:8000/api/v1/health`. Ensure `apps/web/.env` exists for local frontend. Restart: `docker compose restart web` or `pnpm --filter web dev` |
| **401 on `/auth/refresh` in browser console (guest)** | Expected for logged-out users on public pages — refresh is skipped unless a session hint or protected path (`/admin`, `/profile`) triggers bootstrap |
| **Logged out after F5 on `/admin`** | Usually a failed refresh (expired cookie) or stale Docker web build. Rebuild: `docker compose --profile docker-web up -d --build web`. Log in again if the refresh cookie expired |
| **«На сайт» in admin does nothing / goes to login** | Must use full navigation to `/` (static landing), not React Router. Ensure topbar action is `href="/"`, then rebuild web container if behavior persists |
| **White flash on admin reload** | `app.html` preloads admin background before React mount. Verify `localStorage.wespAdminTheme` and rebuild web if stale assets are served |
| **Sidebar should stay visible while scrolling** | Admin sidebar is fixed on desktop (`position: fixed`, `height: 100vh`) and switches back to normal flow on mobile (`<=768px`) |
| **Port 5173 already in use** | Stop Docker web: `docker compose --profile docker-web stop web`. Or stop local Vite |
| **Frontend in Docker: missing modules / esbuild errors** | Rebuild: `docker compose --profile docker-web up -d --build web`. Do not run `pnpm install` on the host for the Docker workflow |
| **Hot-reload not working in Docker (Windows/macOS)** | Enabled via `CHOKIDAR_USEPOLLING=true`. Restart: `docker compose --profile docker-web restart web` |
| **Admin panel missing Security/Diagnostics tabs** | Log in as `admin@compton.example` (superuser), not `ops@compton.example` |
| **Need DB password for DBeaver** | Admin → Security → Install Secrets → Reveal DB password (superuser), then `docker-compose.dev-ports.yml` |
| **Lost `install.env` / DB auth failed** | See [docs/secrets-recovery.md](docs/secrets-recovery.md). Do not regenerate secrets if Postgres volume already exists |
| **CORS errors in browser console** | Keep `VITE_USE_API_PROXY=true` (default in dev). Do not call `localhost:8000` directly from the browser |
## Security Highlights
All cryptographic primitives live in a single module (`apps/api/app/core/crypto.py`); `security.py` and `media_signing.py` re-export from it.
| Layer | Mechanism |
| ----- | --------- |
| **Passwords** | bcrypt cost 12 with per-user salt (embedded in hash); shared denylist |
| **Access JWT** | HS256, short TTL, in-memory on frontend only |
| **Refresh tokens** | HttpOnly cookie (`Path=/api/v1/auth`), rotation + family reuse detection; SHA-256 hash with server-side **pepper** |
| **Email/reset tokens** | Opaque tokens, SHA-256 + pepper in DB; raw token never stored |
| **Media URLs** | HMAC-SHA256 signed paths with TTL |
| **Install secrets** | `secrets.token_*` generation; generate-once + lock; superuser reveal (audited) |
**Auth & API**
- Refresh token rotation and reuse-detection (family revoke)
- Rate limiting and brute-force lockout (`ENABLE_RATE_LIMIT=true` in Docker compose)
- Pending/blocked users rejected on refresh; frontend clears session on `403` (`ACCOUNT_BLOCKED`, `EMAIL_NOT_VERIFIED`)
- Origin/Referer validation on cookie-based auth endpoints
- `TRUSTED_PROXY_IPS` — only listed proxies may influence client IP via `X-Forwarded-For`
- Email bodies omit raw tokens in production (`EMAIL_DELIVERY_MODE=memory` or `ENABLE_TEST_ROUTES=true` only)
**Data & infra**
- PostgreSQL/Redis/MinIO on internal Docker network by default (no host ports)
- DB CHECK constraints, connection pool tuning, startup token cleanup
- CMS HTML sanitization (bleach) with allowed URL protocols: `http`, `https`, `mailto`
- Signed avatar URLs; RBAC + superuser guards on admin routes
- Admin password policy on user create/reset
**Production guards** (`APP_ENV=production` — API refuses to start if):
- `ENABLE_TEST_ROUTES=true`
- `ENABLE_DOCS=true`
- `ENABLE_RATE_LIMIT=false`
- `COOKIE_SECURE=false`
- Default DB credentials (`user:pass`) or placeholder JWT secrets
- `DATABASE_URL` without `sslmode=require`
## Related Documentation
- [docs/secrets-recovery.md](docs/secrets-recovery.md) — recover from lost install secrets
- [docs/security-checklist.md](docs/security-checklist.md) — pre-production security checklist
- [docs/TZ.md](docs/TZ.md) — full technical specification
## MVP Status (Phase 1)
| # | Feature | Status |
| --- | ----------------------------- | ------ |
| 1.1 | PostgreSQL + Alembic + seed | done |
| 1.2 | Auth + SMTP (verify/reset) | done |
| 1.3 | Profile CRUD + avatar (MinIO) | done |
| 1.4 | Content pages | done |
| 1.5 | Admin panel (Ant Design + zootech auth UI) | done |
| 1.6 | E2E regression §15.7 | done |
+1510
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/compton
REDIS_URL=redis://localhost:6379/0
JWT_ACCESS_SECRET=replace-with-32-byte-secret
JWT_REFRESH_PEPPER=replace-with-32-byte-pepper
JWT_ACCESS_TTL_MIN=15
JWT_REFRESH_TTL_DAYS=30
ENABLE_DOCS=true
COOKIE_SECURE=false
ENABLE_RATE_LIMIT=true
APP_ENV=development
AUTH_LOCKOUT_ATTEMPTS=5
AUTH_LOCKOUT_MINUTES=15
CORS_ORIGINS=["http://localhost:5173"]
TRUSTED_PROXY_IPS=
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@compton.example
FRONTEND_URL=http://localhost:5173
PUBLIC_BASE_URL=http://localhost:5173
AUTH_TOKEN_TTL_HOURS=1
EMAIL_DELIVERY_MODE=memory
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minio
S3_SECRET_KEY=minio123
S3_BUCKET=compton
S3_REGION=us-east-1
STORAGE_MODE=s3
AVATAR_MAX_BYTES=2097152
MEDIA_URL_TTL_SECONDS=600
LOG_LEVEL=INFO
AUDIT_RETENTION_DAYS=90
COMPTON_SETTINGS_PATH=data/compton_settings.json
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
SERVER_LOG_PATH=data/logs/server.log
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
ADMIN_INITIAL_PASSWORD=Admin1234
DEMO_USER_PASSWORD=User1234
DEMO_OPS_PASSWORD=OpsAdmin1234
# E2E only, never enable in production.
ENABLE_TEST_ROUTES=false
+11
View File
@@ -0,0 +1,11 @@
DATABASE_URL=postgresql+psycopg://test:test@localhost:5433/compton_test
REDIS_URL=redis://localhost:6380/0
JWT_ACCESS_SECRET=test-access-secret-32-bytes-minimum
JWT_REFRESH_PEPPER=test-refresh-pepper-32-bytes-min
JWT_ACCESS_TTL_MIN=15
JWT_REFRESH_TTL_DAYS=30
ENABLE_DOCS=true
CORS_ORIGINS=["http://localhost:5175"]
EMAIL_DELIVERY_MODE=memory
STORAGE_MODE=memory
ENABLE_TEST_ROUTES=true
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONPATH=/app
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
EXPOSE 8000
CMD ["python", "scripts/docker_entrypoint.py"]
+42
View File
@@ -0,0 +1,42 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
"""Compton API application package."""
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from app.core.config import settings
SETTINGS_ENV_KEYS: dict[str, str] = {
"enable_rate_limit": "ENABLE_RATE_LIMIT",
"enable_docs": "ENABLE_DOCS",
"cookie_secure": "COOKIE_SECURE",
"jwt_access_ttl_min": "JWT_ACCESS_TTL_MIN",
"auth_lockout_attempts": "AUTH_LOCKOUT_ATTEMPTS",
"auth_lockout_minutes": "AUTH_LOCKOUT_MINUTES",
"cors_origins": "CORS_ORIGINS",
"frontend_url": "FRONTEND_URL",
"public_base_url": "PUBLIC_BASE_URL",
"smtp_host": "SMTP_HOST",
"smtp_port": "SMTP_PORT",
"smtp_from": "SMTP_FROM",
"avatar_max_bytes": "AVATAR_MAX_BYTES",
"media_url_ttl_seconds": "MEDIA_URL_TTL_SECONDS",
"log_level": "LOG_LEVEL",
"audit_retention_days": "AUDIT_RETENTION_DAYS",
"jwt_refresh_ttl_days": "JWT_REFRESH_TTL_DAYS",
}
MANAGED_KEYS = tuple(SETTINGS_ENV_KEYS.keys())
def _settings_file() -> Path:
return Path(settings.compton_settings_path)
def get_settings_values() -> dict[str, Any]:
return {key: getattr(settings, key) for key in MANAGED_KEYS}
def _coerce_value(key: str, value: Any) -> Any:
current = getattr(settings, key)
if isinstance(current, bool):
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in {"1", "true", "yes", "on"}
return bool(value)
if isinstance(current, int):
return int(value)
if isinstance(current, list):
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
raise ValueError(f"INVALID_LIST_{key}")
return value
def env_locks() -> dict[str, bool]:
return {key: os.getenv(env_key) is not None for key, env_key in SETTINGS_ENV_KEYS.items()}
def apply_settings_to_app(values: dict[str, Any]) -> None:
for key, value in values.items():
if key not in MANAGED_KEYS:
continue
setattr(settings, key, _coerce_value(key, value))
def read_settings() -> dict[str, Any]:
path = _settings_file()
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as file:
payload = json.load(file)
if not isinstance(payload, dict):
return {}
return {key: payload[key] for key in MANAGED_KEYS if key in payload}
def write_settings(partial: dict[str, Any]) -> dict[str, Any]:
locks = env_locks()
current = get_settings_values()
for key, value in partial.items():
if key not in MANAGED_KEYS:
continue
if locks[key]:
continue
current[key] = _coerce_value(key, value)
path = _settings_file()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
json.dump(current, file, ensure_ascii=False, indent=2)
return current
def bootstrap_settings() -> None:
apply_settings_to_app(read_settings())
def get_settings_payload() -> dict[str, Any]:
locks = env_locks()
values = get_settings_values()
secrets = {
"jwt_access_secret_configured": bool(settings.jwt_access_secret),
"jwt_refresh_pepper_configured": bool(settings.jwt_refresh_pepper),
"smtp_password_configured": bool(settings.smtp_password),
"s3_secret_key_configured": bool(settings.s3_secret_key),
}
return {
"values": values,
"locks": locks,
"settings_path": str(_settings_file()),
"secrets": secrets,
}
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from app.core.config import settings
def _audit_path() -> Path:
return Path(settings.admin_audit_log_path)
def write_audit_event(
action: str,
actor_user_id: str,
actor_email: str,
details: dict[str, Any] | None = None,
) -> None:
payload = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"actor_user_id": actor_user_id,
"actor_email": actor_email,
"details": details or {},
}
path = _audit_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as file:
file.write(json.dumps(payload, ensure_ascii=False))
file.write("\n")
def read_audit_events(limit: int = 200) -> list[dict[str, Any]]:
path = _audit_path()
if not path.exists():
return []
lines = path.read_text(encoding="utf-8").splitlines()
tail = lines[-limit:]
events: list[dict[str, Any]] = []
for line in tail:
if not line.strip():
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
return list(reversed(events))
+54
View File
@@ -0,0 +1,54 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.install_secrets import load_install_secrets_to_env
load_install_secrets_to_env()
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str = "postgresql+psycopg://user:pass@localhost:5432/compton"
redis_url: str = "redis://localhost:6379/0"
jwt_access_secret: str = "change-me-access-secret-with-at-least-32-bytes"
jwt_refresh_pepper: str = "change-me-refresh-pepper-with-at-least-32-bytes"
jwt_access_ttl_min: int = 15
jwt_refresh_ttl_days: int = 30
enable_docs: bool = True
cookie_secure: bool = False
cors_origins: list[str] = ["http://localhost:5173"]
enable_rate_limit: bool = True
auth_lockout_attempts: int = 5
auth_lockout_minutes: int = 15
admin_initial_password: str = "Admin1234"
demo_user_password: str = "User1234"
demo_ops_password: str = "OpsAdmin1234"
smtp_host: str = "localhost"
smtp_port: int = 1025
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "noreply@compton.example"
frontend_url: str = "http://localhost:5173"
public_base_url: str = "http://localhost:5173"
auth_token_ttl_hours: int = 1
email_delivery_mode: str = "smtp"
s3_endpoint: str = "http://localhost:9000"
s3_access_key: str = "minio"
s3_secret_key: str = "minio123"
s3_bucket: str = "compton"
s3_region: str = "us-east-1"
storage_mode: str = "s3"
avatar_max_bytes: int = 2 * 1024 * 1024
media_url_ttl_seconds: int = 600
log_level: str = "INFO"
audit_retention_days: int = 90
password_denylist_path: str = "data/security/password-denylist.txt"
compton_settings_path: str = "data/compton_settings.json"
admin_audit_log_path: str = "data/logs/admin-audit.jsonl"
server_log_path: str = "data/logs/server.log"
enable_test_routes: bool = False
app_env: str = "development"
trusted_proxy_ips: str = ""
settings = Settings()
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode
import bcrypt
from jose import jwt
def generate_secret_token_urlsafe(length: int = 32) -> str:
return secrets.token_urlsafe(length)
def generate_secret_token_hex(length: int = 32) -> str:
return secrets.token_hex(length)
def generate_install_bundle() -> dict[str, str]:
postgres_user = "compton_app"
postgres_password = generate_secret_token_urlsafe(32)
postgres_db = "compton"
minio_root_user = "minio"
minio_root_password = generate_secret_token_urlsafe(32)
return {
"POSTGRES_USER": postgres_user,
"POSTGRES_PASSWORD": postgres_password,
"POSTGRES_DB": postgres_db,
"DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}",
"JWT_ACCESS_SECRET": generate_secret_token_hex(32),
"JWT_REFRESH_PEPPER": generate_secret_token_hex(32),
"S3_ACCESS_KEY": minio_root_user,
"S3_SECRET_KEY": minio_root_password,
"MINIO_ROOT_USER": minio_root_user,
"MINIO_ROOT_PASSWORD": minio_root_password,
}
def hash_password(raw_password: str) -> str:
return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def verify_password(raw_password: str, password_hash: str) -> bool:
return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8"))
def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str:
from app.core.config import settings
now = datetime.now(UTC)
payload = {
"sub": user_id,
"role": role,
"is_superuser": is_superuser,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
"jti": generate_secret_token_hex(16),
}
return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256")
def decode_access_token(token: str) -> dict:
from app.core.config import settings
return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"])
def generate_refresh_token() -> str:
return generate_secret_token_urlsafe(48)
def generate_opaque_token() -> str:
return generate_secret_token_urlsafe(32)
def hash_opaque_token(token: str) -> str:
return hash_refresh_token(token)
def hash_refresh_token(token: str) -> str:
from app.core.config import settings
return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest()
def build_signed_media_url(stored_url: str | None) -> str | None:
from app.core.config import settings
if not stored_url:
return None
if not stored_url.startswith("/api/v1/media/files/"):
return stored_url
path = stored_url.removeprefix("/api/v1/media/files/")
expires = int(time.time()) + settings.media_url_ttl_seconds
signature = _sign_media_path(path, expires)
query = urlencode({"expires": expires, "sig": signature})
return f"/api/v1/media/files/{path}?{query}"
def verify_signed_media(path: str, expires: int, signature: str) -> bool:
if expires < int(time.time()):
return False
expected = _sign_media_path(path, expires)
return hmac.compare_digest(expected, signature)
def _sign_media_path(path: str, expires: int) -> str:
from app.core.config import settings
payload = f"{path}:{expires}"
return hmac.new(
settings.jwt_access_secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import settings
_connect_args: dict[str, object] = {}
_engine_kwargs: dict[str, object] = {
"pool_pre_ping": True,
"future": True,
"pool_size": 5,
"max_overflow": 10,
"pool_recycle": 1800,
}
if settings.database_url.startswith("sqlite"):
_connect_args["check_same_thread"] = False
_engine_kwargs["poolclass"] = StaticPool
_engine_kwargs.pop("pool_size", None)
_engine_kwargs.pop("max_overflow", None)
_engine_kwargs.pop("pool_recycle", None)
engine = create_engine(settings.database_url, connect_args=_connect_args, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
@contextmanager
def session_scope() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_db() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from datetime import UTC, datetime
def ensure_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def utc_now() -> datetime:
return datetime.now(UTC)
+38
View File
@@ -0,0 +1,38 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.security import decode_access_token
from app.modules.users.repository import get_user_by_id
bearer = HTTPBearer(auto_error=False)
def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)):
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
try:
payload = decode_access_token(credentials.credentials)
except Exception as exc: # pragma: no cover - defensive
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
user = get_user_by_id(payload["sub"])
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
if user.status == "pending":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
if user.status == "blocked":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
return user
def require_admin(user=Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
return user
def require_superuser(user=Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
if not user.is_superuser:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPERUSER_ONLY")
return user
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import smtplib
from dataclasses import dataclass
from email.message import EmailMessage
from app.core.config import settings
@dataclass
class SentEmail:
to: str
subject: str
body: str
template: str
class MemoryMailer:
def __init__(self) -> None:
self.sent: list[SentEmail] = []
def send(self, to: str, subject: str, body: str, template: str) -> None:
self.sent.append(SentEmail(to=to, subject=subject, body=body, template=template))
def clear(self) -> None:
self.sent.clear()
def latest_token(self, recipient: str, template: str) -> str | None:
for message in reversed(self.sent):
if message.to == recipient and message.template == template:
for line in message.body.splitlines():
if line.startswith("TOKEN:"):
return line.split(":", 1)[1].strip()
return None
class SmtpMailer:
def send(self, to: str, subject: str, body: str, template: str) -> None:
_ = template
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
if settings.smtp_user:
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(message)
memory_mailer = MemoryMailer()
def get_mailer():
if settings.email_delivery_mode == "memory":
return memory_mailer
return SmtpMailer()
def send_template_email(to: str, template: str, subject: str, body: str) -> None:
get_mailer().send(to=to, subject=subject, body=body, template=template)
+2
View File
@@ -0,0 +1,2 @@
class DomainError(Exception):
"""Base domain error."""
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import ParseResult, urlparse, urlunparse
from uuid import uuid4
from app.core.crypto import generate_install_bundle
INSTALL_SECRETS_DIR = Path("data/secrets")
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
REQUIRED_KEYS = (
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"POSTGRES_DB",
"DATABASE_URL",
"JWT_ACCESS_SECRET",
"JWT_REFRESH_PEPPER",
"S3_ACCESS_KEY",
"S3_SECRET_KEY",
"MINIO_ROOT_USER",
"MINIO_ROOT_PASSWORD",
)
@dataclass
class InstallSecretsStatus:
initialized: bool
locked: bool
path: str
created: bool
def _parse_env_text(raw: str) -> dict[str, str]:
values: dict[str, str] = {}
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def _render_env(values: dict[str, str]) -> str:
ordered = [f"{key}={values[key]}" for key in sorted(values.keys())]
return "\n".join(ordered) + "\n"
def read_install_secrets() -> dict[str, str]:
if not INSTALL_SECRETS_FILE.exists():
return {}
return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8"))
def _write_install_secrets(values: dict[str, str]) -> None:
INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True)
INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8")
def _write_meta() -> None:
payload = (
"{\n"
f' "install_id": "{uuid4()}",\n'
f' "locked_at": "{datetime.now(UTC).isoformat()}"\n'
"}\n"
)
INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8")
def _adopt_from_environment() -> dict[str, str]:
env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS}
db_url = os.getenv("DATABASE_URL", "")
if db_url:
parsed = urlparse(db_url)
if parsed.username:
env_values["POSTGRES_USER"] = parsed.username
if parsed.password:
env_values["POSTGRES_PASSWORD"] = parsed.password
if parsed.path and parsed.path != "/":
env_values["POSTGRES_DB"] = parsed.path.lstrip("/")
return {key: value for key, value in env_values.items() if value}
def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]:
"""MinIO root credentials are the S3 access key pair — keep them aligned."""
if (
values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER")
and values.get("MINIO_ROOT_PASSWORD")
and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"]
):
values = dict(values)
values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"]
return values
def load_install_secrets_to_env() -> None:
values = _sync_minio_s3_secrets(read_install_secrets())
for key, value in values.items():
os.environ[key] = value
def ensure_install_secrets() -> InstallSecretsStatus:
existing = read_install_secrets()
if existing:
synced = _sync_minio_s3_secrets(existing)
if synced != existing:
synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true")
_write_install_secrets(synced)
existing = synced
load_install_secrets_to_env()
return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False)
adopted = _adopt_from_environment()
generated = generate_install_bundle()
values = generated | adopted
values["SECRETS_LOCKED"] = "true"
_write_install_secrets(values)
_write_meta()
load_install_secrets_to_env()
return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True)
def masked_database_url(database_url: str) -> str:
parsed = urlparse(database_url)
if not parsed.username:
return database_url
password = "***" if parsed.password else ""
credentials = f"{parsed.username}:{password}" if password else parsed.username
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
netloc = f"{credentials}@{host}"
sanitized = ParseResult(
scheme=parsed.scheme,
netloc=netloc,
path=parsed.path,
params=parsed.params,
query=parsed.query,
fragment=parsed.fragment,
)
return urlunparse(sanitized)
def install_secrets_payload() -> dict:
values = read_install_secrets()
db = urlparse(values.get("DATABASE_URL", ""))
return {
"initialized": bool(values),
"locked": values.get("SECRETS_LOCKED") == "true",
"secrets_path": str(INSTALL_SECRETS_FILE),
"database": {
"host": db.hostname,
"port": db.port,
"database": db.path.lstrip("/") if db.path else "",
"user": db.username,
"password_configured": bool(values.get("POSTGRES_PASSWORD")),
},
"connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")),
"secrets_status": {
"jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing",
"jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing",
"postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing",
"s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing",
"password_bcrypt_salt": "per_user_in_db",
},
}
def reveal_install_secret(key: str) -> str:
mapping = {
"database_password": "POSTGRES_PASSWORD",
"jwt_access_secret": "JWT_ACCESS_SECRET",
"jwt_refresh_pepper": "JWT_REFRESH_PEPPER",
"s3_secret_key": "S3_SECRET_KEY",
}
env_key = mapping.get(key)
if not env_key:
raise ValueError("UNSUPPORTED_SECRET_KEY")
values = read_install_secrets()
if env_key in values:
return values[env_key]
if env_key == "POSTGRES_PASSWORD":
database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "")
parsed = urlparse(database_url)
return parsed.password or ""
return os.getenv(env_key, "")
+3
View File
@@ -0,0 +1,3 @@
from app.core.crypto import build_signed_media_url, verify_signed_media
__all__ = ["build_signed_media_url", "verify_signed_media"]
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
from app.core.config import settings
COMMON_PASSWORDS = frozenset(
{
"password",
"password1",
"password123",
"12345678",
"123456789",
"qwerty123",
"admin123",
"admin1234",
"letmein1",
"welcome1",
"iloveyou1",
"sunshine1",
"football1",
"baseball1",
"monkey123",
"dragon123",
"master123",
"trustno1",
"passw0rd",
"passw0rd1",
}
)
def load_denylist() -> set[str]:
denylist = set(COMMON_PASSWORDS)
path = Path(settings.password_denylist_path)
if not path.exists():
return denylist
for line in path.read_text(encoding="utf-8").splitlines():
candidate = line.strip().lower()
if candidate and not candidate.startswith("#"):
denylist.add(candidate)
return denylist
def is_denied_password(password: str) -> bool:
return password.lower() in load_denylist()
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import re
from app.core.password_denylist import is_denied_password
def validate_password_strength(password: str) -> str:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters long")
if is_denied_password(password):
raise ValueError("Password is too common")
if not re.search(r"[A-Z]", password):
raise ValueError("Password must include at least one uppercase letter")
if not re.search(r"[a-z]", password):
raise ValueError("Password must include at least one lowercase letter")
if not re.search(r"\d", password):
raise ValueError("Password must include at least one digit")
return password
+74
View File
@@ -0,0 +1,74 @@
"""Redis-first rate limiter with in-memory fallback."""
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from fastapi import HTTPException, Request, status
from redis import Redis
from redis.exceptions import RedisError
from app.core.config import settings
_buckets: dict[str, list[datetime]] = defaultdict(list)
_redis_client: Redis | None = None
def get_redis_client() -> Redis | None:
global _redis_client
if _redis_client is not None:
return _redis_client
try:
_redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
_redis_client.ping()
return _redis_client
except RedisError:
_redis_client = None
return None
def check_rate_limit(key: str, limit: int, window_seconds: int) -> None:
if not settings.enable_rate_limit:
return
redis_client = get_redis_client()
if redis_client is not None:
redis_key = f"rl:{key}"
try:
current = redis_client.incr(redis_key)
if current == 1:
redis_client.expire(redis_key, window_seconds)
if current > limit:
ttl = redis_client.ttl(redis_key)
retry_after = ttl if ttl and ttl > 0 else window_seconds
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="RATE_LIMIT_EXCEEDED",
headers={"Retry-After": str(retry_after)},
)
return
except RedisError:
pass
now = datetime.now(UTC)
cutoff = now - timedelta(seconds=window_seconds)
timestamps = [moment for moment in _buckets[key] if moment > cutoff]
if len(timestamps) >= limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="RATE_LIMIT_EXCEEDED",
headers={"Retry-After": str(window_seconds)},
)
timestamps.append(now)
_buckets[key] = timestamps
def client_ip(request: Request) -> str:
trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()}
forwarded = request.headers.get("X-Forwarded-For")
request_ip = request.client.host if request.client else ""
if forwarded and request_ip in trusted_proxy_ips:
return forwarded.split(",")[0].strip()
if request.client:
return request_ip
return "unknown"
+21
View File
@@ -0,0 +1,21 @@
from app.core.crypto import (
create_access_token,
decode_access_token,
generate_opaque_token,
generate_refresh_token,
hash_opaque_token,
hash_password,
hash_refresh_token,
verify_password,
)
__all__ = [
"create_access_token",
"decode_access_token",
"generate_opaque_token",
"generate_refresh_token",
"hash_opaque_token",
"hash_password",
"hash_refresh_token",
"verify_password",
]
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from app.core.config import settings
_s3_client = None
def get_s3_client():
global _s3_client
if _s3_client is None:
import boto3
_s3_client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
)
return _s3_client
def ensure_bucket() -> None:
if settings.storage_mode != "s3":
return
client = get_s3_client()
bucket = settings.s3_bucket
try:
client.head_bucket(Bucket=bucket)
except Exception:
client.create_bucket(Bucket=bucket)
def upload_object(key: str, body: bytes, content_type: str) -> None:
if settings.storage_mode == "memory":
memory_store[key] = (body, content_type)
return
client = get_s3_client()
ensure_bucket()
client.put_object(
Bucket=settings.s3_bucket,
Key=key,
Body=body,
ContentType=content_type,
)
def download_object(key: str) -> tuple[bytes, str] | None:
if settings.storage_mode == "memory":
return memory_store.get(key)
client = get_s3_client()
try:
response = client.get_object(Bucket=settings.s3_bucket, Key=key)
body = response["Body"].read()
content_type = response.get("ContentType", "application/octet-stream")
return body, content_type
except Exception:
return None
memory_store: dict[str, tuple[bytes, str]] = {}
+3
View File
@@ -0,0 +1,3 @@
from app.db.base import Base
__all__ = ["Base"]
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+12
View File
@@ -0,0 +1,12 @@
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
from app.modules.content.models import ContentPage
from app.modules.users.models import User, UserProfile
__all__ = [
"User",
"UserProfile",
"RefreshToken",
"PasswordResetToken",
"EmailVerificationToken",
"ContentPage",
]
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from datetime import UTC, datetime
from app.core.config import settings
from app.core.security import hash_password
from app.modules.content.repository import create_page, get_page_by_slug
from app.modules.users.repository import create_user, get_user_by_email
def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str):
from app.modules.users import repository
user = get_user_by_email(email)
if user:
changed = False
if user.role != role:
user.role = role
changed = True
if user.is_superuser != is_superuser:
user.is_superuser = is_superuser
changed = True
if user.status != status:
user.status = status
changed = True
if user.email_verified_at is None and status == "active":
user.email_verified_at = datetime.now(UTC)
changed = True
if changed:
repository.update_user(user)
return user
user = create_user(
email=email,
password_hash=hash_password(password),
role=role,
is_superuser=is_superuser,
status=status,
)
user.email_verified_at = datetime.now(UTC)
repository.update_user(user)
return user
def run_seed(include_demo_pages: bool = True) -> None:
admin = _ensure_user(
email="admin@compton.example",
password=settings.admin_initial_password,
role="admin",
is_superuser=True,
status="active",
)
_ensure_user(
email="user@compton.example",
password=settings.demo_user_password,
role="user",
is_superuser=False,
status="active",
)
_ensure_user(
email="ops@compton.example",
password=settings.demo_ops_password,
role="admin",
is_superuser=False,
status="active",
)
if not include_demo_pages:
return
demo_pages = [
{
"slug": "about",
"title": "О бренде",
"body": "<p>Compton — платформа Organic Tech.</p>",
},
{
"slug": "privacy",
"title": "Политика конфиденциальности",
"body": "<p>Мы обрабатываем персональные данные согласно политике.</p>",
},
{
"slug": "terms",
"title": "Условия использования",
"body": "<p>Используя сервис, вы принимаете условия.</p>",
},
]
for page in demo_pages:
if get_page_by_slug(page["slug"], include_draft=True):
continue
create_page(
slug=page["slug"],
title=page["title"],
body=page["body"],
status="published",
author_id=admin.id,
)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, or_
from app.core.database import session_scope
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
def cleanup_expired_tokens(retention_days: int = 30) -> dict[str, int]:
now = datetime.now(UTC)
revoked_cutoff = now - timedelta(days=retention_days)
with session_scope() as db:
refresh_deleted = db.execute(
delete(RefreshToken).where(
or_(
RefreshToken.expires_at < now,
RefreshToken.revoked_at.is_not(None) & (RefreshToken.revoked_at < revoked_cutoff),
)
)
).rowcount or 0
reset_deleted = db.execute(
delete(PasswordResetToken).where(
or_(
PasswordResetToken.expires_at < now,
PasswordResetToken.used_at.is_not(None) & (PasswordResetToken.used_at < now),
)
)
).rowcount or 0
verify_deleted = db.execute(
delete(EmailVerificationToken).where(
or_(
EmailVerificationToken.expires_at < now,
EmailVerificationToken.used_at.is_not(None) & (EmailVerificationToken.used_at < now),
)
)
).rowcount or 0
return {
"refresh_tokens_deleted": int(refresh_deleted),
"password_reset_tokens_deleted": int(reset_deleted),
"email_verification_tokens_deleted": int(verify_deleted),
}
+90
View File
@@ -0,0 +1,90 @@
from contextlib import asynccontextmanager
from urllib.parse import urlparse, parse_qs
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings
from app.core.app_settings import bootstrap_settings
from app.core.storage import ensure_bucket
from app.core import database as db_module
from app.db.token_cleanup import cleanup_expired_tokens
from app.db.base import Base
from app.db import models as _models # noqa: F401
from app.db.seed import run_seed
from app.modules.auth.router import router as auth_router
from app.modules.users.router import router as users_router
from app.modules.content.router import router as content_router
from app.modules.admin.router import router as admin_router
from app.modules.media.router import router as media_router
from app.modules.test.router import router as test_router
def _assert_production_guards() -> None:
if settings.app_env.lower() != "production":
return
if settings.enable_test_routes:
raise RuntimeError("ENABLE_TEST_ROUTES must be false in production")
if settings.enable_docs:
raise RuntimeError("ENABLE_DOCS must be false in production")
if not settings.enable_rate_limit:
raise RuntimeError("ENABLE_RATE_LIMIT must be true in production")
if not settings.cookie_secure:
raise RuntimeError("COOKIE_SECURE must be true in production")
if settings.jwt_access_secret.startswith("change-me-"):
raise RuntimeError("JWT_ACCESS_SECRET placeholder is not allowed in production")
if settings.jwt_refresh_pepper.startswith("change-me-"):
raise RuntimeError("JWT_REFRESH_PEPPER placeholder is not allowed in production")
parsed = urlparse(settings.database_url)
if parsed.username == "user" and parsed.password == "pass":
raise RuntimeError("Default database credentials are not allowed in production")
if parsed.scheme.startswith("postgresql"):
sslmode = parse_qs(parsed.query).get("sslmode", [""])[0]
if sslmode != "require":
raise RuntimeError("DATABASE_URL must contain sslmode=require in production")
def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_install_secrets()
bootstrap_settings()
_assert_production_guards()
if settings.enable_test_routes:
Base.metadata.create_all(db_module.engine)
run_seed()
cleanup_expired_tokens()
ensure_bucket()
yield
app = FastAPI(
title="Compton API",
version="1.0.0",
docs_url="/api/v1/docs" if settings.enable_docs else None,
openapi_url="/api/v1/openapi.json" if settings.enable_docs else None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
@app.get("/api/v1/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(users_router, prefix="/api/v1/users", tags=["users"])
app.include_router(content_router, prefix="/api/v1/content", tags=["content"])
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
if settings.enable_test_routes:
app.include_router(test_router, prefix="/api/v1/test", tags=["test"])
return app
app = create_app()
+1
View File
@@ -0,0 +1 @@
"""Application modules."""
+1
View File
@@ -0,0 +1 @@
"""Admin module."""
+142
View File
@@ -0,0 +1,142 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from app.core.dependencies import require_admin, require_superuser
from app.modules.admin.schemas import (
AdminSettingsPatchIn,
AdminRevealSecretIn,
AdminUiActivityIn,
AdminUserCreateIn,
AdminUserPasswordPatchIn,
AdminUserPatchIn,
)
from app.modules.admin.service import (
create_admin_user,
delete_admin_user,
get_admin_settings,
get_admin_summary,
get_install_secrets,
get_diagnostics_report,
get_server_log_tail,
list_activity_feed,
list_admin_users,
patch_admin_settings,
patch_user,
record_ui_activity,
reveal_secret,
reset_user_password,
)
from app.modules.analytics.service import get_admin_dashboard_metrics
router = APIRouter()
@router.get("/users")
async def list_users_route(
page: int = Query(default=1, ge=1),
limit: int = Query(default=20, ge=1, le=100),
_admin=Depends(require_admin),
):
return list_admin_users(page, limit)
@router.patch("/users/{user_id}")
async def patch_user_route(
user_id: str,
payload: AdminUserPatchIn,
admin=Depends(require_admin),
):
if payload.is_superuser is not None and not admin.is_superuser:
raise HTTPException(status_code=403, detail="SUPERUSER_ONLY")
try:
return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/users")
async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)):
try:
return create_admin_user(
admin,
email=payload.email,
password=payload.password,
role=payload.role,
is_superuser=payload.is_superuser,
status=payload.status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.patch("/users/{user_id}/password")
async def reset_user_password_route(
user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser)
):
try:
return reset_user_password(admin, user_id, payload.password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/users/{user_id}")
async def delete_user_route(user_id: str, admin=Depends(require_superuser)):
try:
return delete_admin_user(admin, user_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("/summary")
async def summary_route(_admin=Depends(require_admin)):
return get_admin_summary()
@router.get("/stats")
async def stats_route(_admin=Depends(require_admin)):
return get_admin_dashboard_metrics()
@router.get("/settings")
async def admin_settings_route(_admin=Depends(require_superuser)):
return get_admin_settings()
@router.patch("/settings")
async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)):
return patch_admin_settings(admin, payload.values)
@router.get("/diagnostics/report")
async def diagnostics_route(_admin=Depends(require_superuser)):
return get_diagnostics_report()
@router.get("/activity-feed")
async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)):
return list_activity_feed(limit=limit)
@router.post("/ui-activity")
async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)):
return record_ui_activity(admin, payload.event, payload.meta)
@router.get("/server-log")
async def server_log_route(
lines: int = Query(default=200, ge=1, le=1000),
_admin=Depends(require_superuser),
):
return get_server_log_tail(lines=lines)
@router.get("/secrets")
async def install_secrets_route(_admin=Depends(require_superuser)):
return get_install_secrets()
@router.post("/secrets/reveal")
async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)):
try:
return reveal_secret(admin, payload.key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+46
View File
@@ -0,0 +1,46 @@
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class AdminUserPatchIn(BaseModel):
role: Literal["user", "admin"] | None = None
status: Literal["pending", "active", "blocked"] | None = None
is_superuser: bool | None = None
class AdminUserCreateIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
role: Literal["user", "admin"] = "user"
is_superuser: bool = False
status: Literal["pending", "active", "blocked"] = "active"
@field_validator("password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class AdminUserPasswordPatchIn(BaseModel):
password: str = Field(min_length=8)
@field_validator("password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class AdminSettingsPatchIn(BaseModel):
values: dict
class AdminUiActivityIn(BaseModel):
event: str
meta: dict | None = None
class AdminRevealSecretIn(BaseModel):
key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"]
@@ -0,0 +1,94 @@
from __future__ import annotations
from urllib.parse import parse_qs, urlparse
from app.core.config import settings
from app.core.install_secrets import read_install_secrets
from app.core.password_denylist import load_denylist
from app.core.redis import get_redis_client
from app.core.database import session_scope
from app.modules.auth.models import RefreshToken
from app.modules.users import repository
from sqlalchemy import func, select
def build_security_diagnostics_report() -> dict:
redis_client = get_redis_client()
db = urlparse(settings.database_url)
sslmode = parse_qs(db.query).get("sslmode", [""])[0]
install_secrets = read_install_secrets()
with session_scope() as session:
refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0
checks = [
{
"id": "jwt_access_secret",
"status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail",
"message": "JWT access secret configured",
},
{
"id": "jwt_refresh_pepper",
"status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail",
"message": "JWT refresh pepper configured",
},
{
"id": "cookie_secure",
"status": "ok" if settings.cookie_secure else "warn",
"message": "Refresh cookie Secure flag is enabled",
},
{
"id": "rate_limit_backend",
"status": "ok" if redis_client is not None else "warn",
"message": "Rate limiter uses Redis backend",
},
{
"id": "password_denylist",
"status": "ok" if len(load_denylist()) >= 1000 else "warn",
"message": "Password denylist has strong coverage",
},
{
"id": "cors_wildcard",
"status": "ok" if "*" not in settings.cors_origins else "fail",
"message": "CORS does not include wildcard origin",
},
{
"id": "superuser_exists",
"status": "ok" if repository.count_superusers() > 0 else "fail",
"message": "At least one superuser exists",
},
{
"id": "docs_production",
"status": "warn" if settings.enable_docs else "ok",
"message": "API docs are disabled in production",
},
{
"id": "db_default_credentials",
"status": "fail" if db.username == "user" and db.password == "pass" else "ok",
"message": "Database does not use default credentials",
},
{
"id": "db_localhost_exposed",
"status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok",
"message": "Production database host is not localhost",
},
{
"id": "db_ssl_mode",
"status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn",
"message": "Production database URL uses sslmode=require",
},
{
"id": "db_refresh_token_table_size",
"status": "warn" if refresh_count > 10000 else "ok",
"message": "Refresh token table size is under threshold",
},
{
"id": "install_secrets_initialized",
"status": "ok" if bool(install_secrets) else "fail",
"message": "Install secrets file is initialized",
},
{
"id": "install_secrets_locked",
"status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn",
"message": "Install secrets are locked after bootstrap",
},
]
return {"checks": checks}
+206
View File
@@ -0,0 +1,206 @@
from pathlib import Path
from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings
from app.core.audit_log import read_audit_events, write_audit_event
from app.core.config import settings
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
from app.core.security import hash_password
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
from app.modules.auth.service import revoke_user_refresh_family
from app.modules.users import repository
def list_admin_users(page: int, limit: int) -> dict:
users, total = repository.list_users(page, limit)
return {
"data": [
{
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
}
for user in users
],
"meta": {"total": total, "page": page, "limit": limit},
}
def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None:
super_count = repository.count_superusers()
role_becomes_admin = target.role if role is None else role
super_becomes_true = target.is_superuser if is_superuser is None else is_superuser
is_losing_super = target.role == "admin" and target.is_superuser and (
role_becomes_admin != "admin" or not super_becomes_true
)
if is_losing_super and super_count <= 1:
raise ValueError("LAST_SUPERUSER_PROTECTED")
def patch_user(
admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None
) -> dict:
target = repository.get_user_by_id(target_user_id)
if not target:
raise ValueError("USER_NOT_FOUND")
if admin_user.id == target_user_id and role and role != "admin":
raise ValueError("SELF_DEMOTION_FORBIDDEN")
if admin_user.id == target_user_id and is_superuser is False:
raise ValueError("SELF_DEMOTION_FORBIDDEN")
if admin_user.id == target_user_id and status == "blocked":
raise ValueError("SELF_BLOCK_FORBIDDEN")
admin_count = repository.count_admins()
if target.role == "admin" and role and role != "admin" and admin_count <= 1:
raise ValueError("LAST_ADMIN_PROTECTED")
_ensure_last_superuser_protection(target, role, is_superuser)
if role:
target.role = role
if is_superuser is not None:
target.is_superuser = bool(is_superuser)
if status:
target.status = status
if status == "blocked":
revoke_user_refresh_family(target.id)
repository.update_user(target)
write_audit_event(
action="admin.user.patch",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": target.id},
)
return {
"id": target.id,
"email": target.email,
"role": target.role,
"is_superuser": target.is_superuser,
"status": target.status,
}
def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict:
if repository.get_user_by_email(email):
raise ValueError("USER_EXISTS")
user = repository.create_user(
email=email,
password_hash=hash_password(password),
role=role,
is_superuser=is_superuser,
status=status,
)
write_audit_event(
action="admin.user.create",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": user.id},
)
return {
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
}
def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
target = repository.get_user_by_id(target_user_id)
if not target:
raise ValueError("USER_NOT_FOUND")
target.password_hash = hash_password(password)
repository.update_user(target)
revoke_user_refresh_family(target.id)
write_audit_event(
action="admin.user.reset_password",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": target.id},
)
return {"status": "ok"}
def delete_admin_user(admin_user, target_user_id: str) -> dict:
target = repository.get_user_by_id(target_user_id)
if not target:
raise ValueError("USER_NOT_FOUND")
if admin_user.id == target.id:
raise ValueError("SELF_DELETE_FORBIDDEN")
_ensure_last_superuser_protection(target, "user", False)
if target.role == "admin" and repository.count_admins() <= 1:
raise ValueError("LAST_ADMIN_PROTECTED")
repository.delete_user(target.id)
revoke_user_refresh_family(target.id)
write_audit_event(
action="admin.user.delete",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"target_user_id": target.id},
)
return {"status": "deleted"}
def get_admin_summary() -> dict:
return {
"users_count": repository.count_users(),
"registrations_day": repository.count_users_registered_today(),
"admins_count": repository.count_admins(),
"superusers_count": repository.count_superusers(),
}
def get_admin_settings() -> dict:
return get_settings_payload()
def patch_admin_settings(admin_user, values: dict) -> dict:
merged = write_settings(values)
apply_settings_to_app(merged)
write_audit_event(
action="admin.settings.patch",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"updated_keys": sorted(values.keys())},
)
return get_settings_payload()
def get_diagnostics_report() -> dict:
return build_security_diagnostics_report()
def list_activity_feed(limit: int = 200) -> dict:
return {"events": read_audit_events(limit=limit)}
def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict:
write_audit_event(
action=f"ui.{event}",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details=meta or {},
)
return {"status": "ok"}
def get_server_log_tail(lines: int = 200) -> dict:
path = Path(settings.server_log_path)
if not path.exists():
return {"lines": []}
return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]}
def get_install_secrets() -> dict:
return install_secrets_payload()
def reveal_secret(admin_user, key: str) -> dict:
value = reveal_install_secret(key)
write_audit_event(
action="admin.secrets.reveal",
actor_user_id=admin_user.id,
actor_email=admin_user.email,
details={"key": key},
)
return {"key": key, "value": value}
@@ -0,0 +1,8 @@
from app.modules.users import repository
def get_admin_dashboard_metrics() -> dict:
return {
"users_count": repository.count_users(),
"registrations_day": repository.count_users_registered_today(),
}
+1
View File
@@ -0,0 +1 @@
"""Auth module."""
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class EmailVerificationToken(Base):
__tablename__ = "email_verification_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete, select
from app.core.database import session_scope
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
def _detach(db, instance):
db.refresh(instance)
db.expunge(instance)
return instance
def create_refresh_token(
user_id: str,
token_hash: str,
family_id: str,
expires_at: datetime,
) -> RefreshToken:
with session_scope() as db:
token = RefreshToken(
user_id=user_id,
token_hash=token_hash,
family_id=family_id,
expires_at=expires_at,
)
db.add(token)
db.flush()
return _detach(db, token)
def get_refresh_token(token_hash: str) -> RefreshToken | None:
with session_scope() as db:
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if not token:
return None
return _detach(db, token)
def revoke_refresh_token(token_hash: str) -> RefreshToken | None:
with session_scope() as db:
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if not token:
return None
token.revoked_at = datetime.now(UTC)
db.flush()
return _detach(db, token)
def revoke_user_families(user_id: str) -> None:
with session_scope() as db:
tokens = db.scalars(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked_at.is_(None),
)
).all()
now = datetime.now(UTC)
for token in tokens:
token.revoked_at = now
def revoke_family_tokens(family_id: str) -> None:
with session_scope() as db:
tokens = db.scalars(
select(RefreshToken).where(
RefreshToken.family_id == family_id,
RefreshToken.revoked_at.is_(None),
)
).all()
now = datetime.now(UTC)
for token in tokens:
token.revoked_at = now
def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
with session_scope() as db:
db.execute(
delete(EmailVerificationToken).where(
EmailVerificationToken.user_id == user_id,
EmailVerificationToken.used_at.is_(None),
)
)
db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None:
with session_scope() as db:
token = db.scalar(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
if not token:
return None
return _detach(db, token)
def mark_email_verification_token_used(token_hash: str) -> None:
with session_scope() as db:
token = db.scalar(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
if token:
token.used_at = datetime.now(UTC)
def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
with session_scope() as db:
db.execute(
delete(PasswordResetToken).where(
PasswordResetToken.user_id == user_id,
PasswordResetToken.used_at.is_(None),
)
)
db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
def get_password_reset_token(token_hash: str) -> PasswordResetToken | None:
with session_scope() as db:
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
if not token:
return None
return _detach(db, token)
def mark_password_reset_token_used(token_hash: str) -> None:
with session_scope() as db:
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
if token:
token.used_at = datetime.now(UTC)
+206
View File
@@ -0,0 +1,206 @@
from app.core.datetime_utils import ensure_utc, utc_now
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.core.config import settings
from app.core.redis import check_rate_limit, client_ip
from app.modules.auth.schemas import (
ForgotPasswordIn,
LoginIn,
LoginOut,
RegisterIn,
ResendVerificationIn,
ResetPasswordIn,
VerifyEmailIn,
)
from app.modules.auth.service import (
forgot_password,
login,
refresh,
register,
resend_verification,
reset_password,
revoke_refresh_token,
verify_email_token,
)
from app.modules.users import repository
router = APIRouter()
def _allowed_origins() -> set[str]:
origins: set[str] = set()
for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins):
if not raw:
continue
normalized = raw.rstrip("/")
origins.add(normalized)
if "://localhost" in normalized:
origins.add(normalized.replace("://localhost", "://127.0.0.1"))
if "://127.0.0.1" in normalized:
origins.add(normalized.replace("://127.0.0.1", "://localhost"))
return origins
def _enforce_origin(request: Request, *, require_header: bool = False) -> None:
allowed = _allowed_origins()
origin = (request.headers.get("Origin") or "").rstrip("/")
referer = request.headers.get("Referer") or ""
if require_header and not origin and not referer:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
if origin and origin not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
if not origin and referer:
if not any(referer.startswith(base) for base in allowed):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
path="/api/v1/auth",
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
)
@router.post("/register")
async def register_route(payload: RegisterIn, request: Request):
_enforce_origin(request)
check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600)
user = register(payload.email, payload.password)
_ = user
return {"message": "If email is valid, verification has been sent."}
@router.post("/verify-email")
async def verify_email_route(payload: VerifyEmailIn):
try:
user = verify_email_token(payload.token)
except ValueError as exc:
detail = str(exc)
if detail == "TOKEN_EXPIRED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
_ = user
return {"status": "active"}
@router.post("/login", response_model=LoginOut)
async def login_route(payload: LoginIn, request: Request, response: Response):
_enforce_origin(request)
user = repository.get_user_by_email(payload.email)
if user:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until > utc_now():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="ACCOUNT_TEMPORARILY_LOCKED",
)
check_rate_limit(
f"login:{client_ip(request)}:{payload.email.lower()}",
limit=5,
window_seconds=60,
)
try:
access_token, refresh_token, user = login(payload.email, payload.password)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS")
except PermissionError as exc:
detail = str(exc)
if detail == "EMAIL_NOT_VERIFIED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
if detail == "ACCOUNT_TEMPORARILY_LOCKED":
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
_set_refresh_cookie(response, refresh_token)
return {
"access_token": access_token,
"expires_in": settings.jwt_access_ttl_min * 60,
"user": {
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
},
}
@router.post("/refresh", response_model=LoginOut)
async def refresh_route(request: Request, response: Response):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING")
_enforce_origin(request, require_header=True)
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
try:
access_token, new_refresh, user = refresh(refresh_token)
except PermissionError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
_set_refresh_cookie(response, new_refresh)
return {
"access_token": access_token,
"expires_in": settings.jwt_access_ttl_min * 60,
"user": {
"id": user.id,
"email": user.email,
"role": user.role,
"is_superuser": user.is_superuser,
"status": user.status,
},
}
@router.post("/logout")
async def logout_route(request: Request, response: Response):
_enforce_origin(request, require_header=True)
refresh_token = request.cookies.get("refresh_token")
if refresh_token:
revoke_refresh_token(refresh_token)
response.delete_cookie(
"refresh_token",
path="/api/v1/auth",
secure=settings.cookie_secure,
samesite="lax",
)
return {"message": "logged_out"}
@router.post("/forgot-password")
async def forgot_password_route(payload: ForgotPasswordIn, request: Request):
_enforce_origin(request)
check_rate_limit(
f"forgot:{client_ip(request)}:{payload.email.lower()}",
limit=3,
window_seconds=3600,
)
forgot_password(payload.email)
return {"message": "If email is registered, reset instructions have been sent."}
@router.post("/reset-password")
async def reset_password_route(payload: ResetPasswordIn):
try:
reset_password(payload.token, payload.new_password)
except ValueError as exc:
detail = str(exc)
if detail == "TOKEN_EXPIRED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
return {"message": "password_updated"}
@router.post("/resend-verification")
async def resend_verification_route(payload: ResendVerificationIn, request: Request):
_enforce_origin(request)
check_rate_limit(
f"resend:{client_ip(request)}:{payload.email.lower()}",
limit=3,
window_seconds=3600,
)
resend_verification(payload.email)
return {"message": "If email is registered, verification has been sent."}
+69
View File
@@ -0,0 +1,69 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
@field_validator("email", "password", mode="before")
@classmethod
def strip_whitespace(cls, value: str) -> str:
if isinstance(value, str):
return value.strip()
return value
@field_validator("password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
@field_validator("email", "password", mode="before")
@classmethod
def strip_whitespace(cls, value: str) -> str:
if isinstance(value, str):
return value.strip()
return value
class VerifyEmailIn(BaseModel):
token: str
class ForgotPasswordIn(BaseModel):
email: EmailStr
class ResendVerificationIn(BaseModel):
email: EmailStr
class ResetPasswordIn(BaseModel):
token: str
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
class AuthUserOut(BaseModel):
id: str
email: EmailStr
role: str
is_superuser: bool
status: str
class LoginOut(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 900
user: AuthUserOut
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
from app.core.config import settings
from app.core.datetime_utils import ensure_utc, utc_now
from app.core.email import send_template_email
from app.core.security import (
create_access_token,
generate_opaque_token,
generate_refresh_token,
hash_opaque_token,
hash_password,
hash_refresh_token,
verify_password,
)
from app.modules.auth import repository as auth_repository
from app.modules.users import repository
from app.modules.users.models import User
def _token_expires_at():
return utc_now() + timedelta(hours=settings.auth_token_ttl_hours)
def _send_verification_email(user: User, token: str) -> None:
verify_url = f"{settings.frontend_url}/verify?token={token}"
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
token_line = f"TOKEN:{token}\n" if show_token else ""
body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line
send_template_email(
to=user.email,
template="verify_email",
subject="Confirm your Compton account",
body=body,
)
def _send_password_reset_email(user: User, token: str) -> None:
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
token_line = f"TOKEN:{token}\n" if show_token else ""
body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line
send_template_email(
to=user.email,
template="reset_password",
subject="Reset your Compton password",
body=body,
)
def _issue_verification_token(user_id: str) -> str:
token = generate_opaque_token()
auth_repository.create_email_verification_token(
user_id=user_id,
token_hash=hash_opaque_token(token),
expires_at=_token_expires_at(),
)
return token
def _issue_password_reset_token(user_id: str) -> str:
token = generate_opaque_token()
auth_repository.create_password_reset_token(
user_id=user_id,
token_hash=hash_opaque_token(token),
expires_at=_token_expires_at(),
)
return token
def register(email: str, password: str) -> User:
existing = repository.get_user_by_email(email)
if existing:
if existing.status == "pending":
token = _issue_verification_token(existing.id)
_send_verification_email(existing, token)
return existing
user = repository.create_user(email=email, password_hash=hash_password(password), status="pending")
token = _issue_verification_token(user.id)
_send_verification_email(user, token)
return user
def verify_email_token(token: str) -> User:
token_hash = hash_opaque_token(token)
token_row = auth_repository.get_email_verification_token(token_hash)
if not token_row or token_row.used_at is not None:
raise ValueError("INVALID_TOKEN")
if ensure_utc(token_row.expires_at) < utc_now():
raise ValueError("TOKEN_EXPIRED")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise ValueError("INVALID_TOKEN")
user.status = "active"
user.email_verified_at = utc_now()
repository.update_user(user)
auth_repository.mark_email_verification_token_used(token_hash)
return user
def resend_verification(email: str) -> None:
user = repository.get_user_by_email(email)
if not user or user.status != "pending":
return
token = _issue_verification_token(user.id)
_send_verification_email(user, token)
def forgot_password(email: str) -> None:
user = repository.get_user_by_email(email)
if not user:
return
token = _issue_password_reset_token(user.id)
_send_password_reset_email(user, token)
def reset_password(token: str, new_password: str) -> None:
token_hash = hash_opaque_token(token)
token_row = auth_repository.get_password_reset_token(token_hash)
if not token_row or token_row.used_at is not None:
raise ValueError("INVALID_TOKEN")
if ensure_utc(token_row.expires_at) < utc_now():
raise ValueError("TOKEN_EXPIRED")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise ValueError("INVALID_TOKEN")
user.password_hash = hash_password(new_password)
repository.update_user(user)
auth_repository.mark_password_reset_token_used(token_hash)
revoke_user_refresh_family(user.id)
def _is_locked(user: User) -> bool:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until > utc_now():
return True
if locked_until and locked_until <= utc_now():
user.failed_login_attempts = 0
user.locked_until = None
repository.update_user(user)
return False
def _record_failed_login(user: User) -> None:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until <= utc_now():
user.failed_login_attempts = 0
user.locked_until = None
user.failed_login_attempts += 1
if user.failed_login_attempts >= settings.auth_lockout_attempts:
user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes)
repository.update_user(user)
def _reset_login_attempts(user: User) -> None:
user.failed_login_attempts = 0
user.locked_until = None
repository.update_user(user)
def login(email: str, password: str) -> tuple[str, str, User]:
user = repository.get_user_by_email(email)
if user and _is_locked(user):
raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED")
if not user or not verify_password(password, user.password_hash):
if user:
_record_failed_login(user)
raise ValueError("INVALID_CREDENTIALS")
if user.status == "pending":
raise PermissionError("EMAIL_NOT_VERIFIED")
if user.status == "blocked":
raise PermissionError("ACCOUNT_BLOCKED")
_reset_login_attempts(user)
access_token = create_access_token(user.id, user.role, user.is_superuser)
refresh_token = issue_refresh_token(user.id)
return access_token, refresh_token, user
def issue_refresh_token(user_id: str, family_id: str | None = None) -> str:
token = generate_refresh_token()
token_hash = hash_refresh_token(token)
family = family_id or str(uuid4())
auth_repository.create_refresh_token(
user_id=user_id,
token_hash=token_hash,
family_id=family,
expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days),
)
return token
def refresh(refresh_token: str) -> tuple[str, str, User]:
token_hash = hash_refresh_token(refresh_token)
token_row = auth_repository.get_refresh_token(token_hash)
if not token_row:
raise PermissionError("INVALID_REFRESH")
if token_row.revoked_at is not None:
auth_repository.revoke_family_tokens(token_row.family_id)
raise PermissionError("INVALID_REFRESH")
if ensure_utc(token_row.expires_at) < utc_now():
raise PermissionError("EXPIRED_REFRESH")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise PermissionError("INVALID_REFRESH")
if user.status in {"pending", "blocked"}:
raise PermissionError("INVALID_REFRESH")
auth_repository.revoke_refresh_token(token_hash)
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
access = create_access_token(user.id, user.role, user.is_superuser)
return access, new_refresh, user
def revoke_refresh_token(refresh_token: str) -> None:
token_hash = hash_refresh_token(refresh_token)
token_row = auth_repository.revoke_refresh_token(token_hash)
if token_row:
auth_repository.revoke_family_tokens(token_row.family_id)
def revoke_user_refresh_family(user_id: str) -> None:
auth_repository.revoke_user_families(user_id)
+1
View File
@@ -0,0 +1 @@
"""Content module."""
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ContentPage(Base):
__tablename__ = "content_pages"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True)
author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import select
from app.core.database import session_scope
from app.modules.content.models import ContentPage
ALLOWED_PAGE_STATUSES = {"draft", "published"}
def _detach(db, instance):
db.refresh(instance)
db.expunge(instance)
return instance
def list_published_pages() -> list[ContentPage]:
with session_scope() as db:
pages = list(
db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug))
)
return [_detach(db, page) for page in pages]
def list_all_pages() -> list[ContentPage]:
with session_scope() as db:
pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug)))
return [_detach(db, page) for page in pages]
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
with session_scope() as db:
page = db.scalar(select(ContentPage).where(ContentPage.slug == slug))
if not page:
return None
if include_draft or page.status == "published":
return _detach(db, page)
return None
def get_page_by_id(page_id: str) -> ContentPage | None:
with session_scope() as db:
page = db.get(ContentPage, page_id)
if not page:
return None
return _detach(db, page)
def create_page(
slug: str,
title: str,
body: str,
status: str,
author_id: str,
) -> ContentPage:
if status not in ALLOWED_PAGE_STATUSES:
raise ValueError("INVALID_STATUS")
with session_scope() as db:
page = ContentPage(
id=str(uuid4()),
slug=slug,
title=title,
body=body,
status=status,
author_id=author_id,
published_at=datetime.now(UTC) if status == "published" else None,
)
db.add(page)
db.flush()
return _detach(db, page)
def update_page(
page_id: str,
title: str | None,
body: str | None,
status: str | None,
) -> ContentPage:
with session_scope() as db:
page = db.get(ContentPage, page_id)
if not page:
raise KeyError(page_id)
if title:
page.title = title
if body:
page.body = body
if status:
if status not in ALLOWED_PAGE_STATUSES:
raise ValueError("INVALID_STATUS")
page.status = status
if status == "published" and page.published_at is None:
page.published_at = datetime.now(UTC)
page.updated_at = datetime.now(UTC)
db.flush()
return _detach(db, page)
def delete_page(page_id: str) -> None:
with session_scope() as db:
page = db.get(ContentPage, page_id)
if page:
db.delete(page)
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import require_admin
from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn
from app.modules.content.service import (
create_page,
delete_page,
get_page_by_slug,
list_all_pages,
list_published_pages,
page_exists,
page_to_dict,
update_page,
)
router = APIRouter()
@router.get("/pages")
async def list_pages_route():
pages = list_published_pages()
return {"data": [page_to_dict(page) for page in pages]}
@router.get("/pages/manage/all")
async def list_all_pages_route(_admin=Depends(require_admin)):
pages = list_all_pages()
return {"data": [page_to_dict(page) for page in pages]}
@router.get("/pages/{slug}")
async def get_page_route(slug: str):
page = get_page_by_slug(slug)
if not page:
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
return page_to_dict(page)
@router.post("/pages")
async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)):
page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id)
return page_to_dict(page)
@router.patch("/pages/{page_id}")
async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)):
if not page_exists(page_id):
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
page = update_page(page_id, payload.title, payload.body, payload.status)
return page_to_dict(page)
@router.delete("/pages/{page_id}")
async def delete_page_route(page_id: str, _admin=Depends(require_admin)):
delete_page(page_id)
return {"message": "deleted"}
+24
View File
@@ -0,0 +1,24 @@
from typing import Literal
from pydantic import BaseModel, Field
class ContentPageIn(BaseModel):
slug: str = Field(min_length=2, max_length=120)
title: str = Field(min_length=2, max_length=200)
body: str
status: Literal["draft", "published"] = "draft"
class ContentPagePatchIn(BaseModel):
title: str | None = Field(default=None, min_length=2, max_length=200)
body: str | None = None
status: Literal["draft", "published"] | None = None
class ContentPageOut(BaseModel):
id: str
slug: str
title: str
body: str
status: str
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import bleach
from app.modules.content import repository
from app.modules.content.models import ContentPage
ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]
def sanitize_html(raw_html: str) -> str:
return bleach.clean(
raw_html,
tags=ALLOWED_TAGS,
attributes={"a": ["href"], "img": ["src", "alt"]},
protocols=["http", "https", "mailto"],
strip=True,
)
def page_to_dict(page: ContentPage) -> dict:
return {
"id": page.id,
"slug": page.slug,
"title": page.title,
"body": page.body,
"status": page.status,
"author_id": page.author_id,
"published_at": page.published_at.isoformat() if page.published_at else None,
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
}
def list_published_pages() -> list[ContentPage]:
return repository.list_published_pages()
def list_all_pages() -> list[ContentPage]:
return repository.list_all_pages()
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
return repository.get_page_by_slug(slug, include_draft=include_draft)
def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage:
return repository.create_page(
slug=slug,
title=title,
body=sanitize_html(body),
status=status,
author_id=author_id,
)
def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage:
return repository.update_page(
page_id,
title,
sanitize_html(body) if body else None,
status,
)
def delete_page(page_id: str) -> None:
repository.delete_page(page_id)
def page_exists(page_id: str) -> bool:
return repository.get_page_by_id(page_id) is not None
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from app.core.media_signing import verify_signed_media
from app.core.storage import download_object
router = APIRouter()
@router.get("/files/{file_path:path}")
async def get_media_file(
file_path: str,
expires: int = Query(...),
sig: str = Query(...),
):
if not file_path.startswith("avatars/"):
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
if not verify_signed_media(file_path, expires, sig):
raise HTTPException(status_code=403, detail="INVALID_SIGNATURE")
stored = download_object(file_path)
if not stored:
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
body, content_type = stored
return Response(content=body, media_type=content_type)
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class AvatarUploadOut(BaseModel):
message: str
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from io import BytesIO
from uuid import uuid4
from PIL import Image
from app.core.config import settings
from app.core.storage import upload_object
ALLOWED_MIME = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
FORMAT_TO_MIME = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"WEBP": "image/webp",
}
class AvatarValidationError(ValueError):
pass
def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]:
if len(content) > settings.avatar_max_bytes:
raise AvatarValidationError("FILE_TOO_LARGE")
if not content:
raise AvatarValidationError("INVALID_IMAGE")
try:
with Image.open(BytesIO(content)) as image:
image.verify()
with Image.open(BytesIO(content)) as image:
mime = FORMAT_TO_MIME.get(image.format or "")
if mime not in ALLOWED_MIME:
raise AvatarValidationError("INVALID_MIME")
buffer = BytesIO()
if mime == "image/jpeg":
rgb = image.convert("RGB")
rgb.save(buffer, format="JPEG", quality=85, optimize=True)
elif mime == "image/png":
image.save(buffer, format="PNG", optimize=True)
else:
image.save(buffer, format="WEBP", quality=85, method=6)
return mime, buffer.getvalue()
except AvatarValidationError:
raise
except Exception as exc:
raise AvatarValidationError("INVALID_IMAGE") from exc
def upload_user_avatar(user_id: str, content: bytes) -> str:
mime, processed = validate_and_process_avatar(content)
extension = ALLOWED_MIME[mime]
key = f"avatars/{user_id}/{uuid4()}{extension}"
upload_object(key, processed, mime)
return f"/api/v1/media/files/{key}"
@@ -0,0 +1,8 @@
"""Celery-ready notifications service placeholder.
MVP keeps SMTP sync path in auth; v1.0 should move to async queue.
"""
def enqueue_email(template: str, recipient: str, context: dict) -> dict:
return {"queued": True, "template": template, "recipient": recipient, "context": context}
+5
View File
@@ -0,0 +1,5 @@
"""Orders module placeholder (request/booking mode without payment)."""
def create_order_request(user_id: str, items: list[dict]) -> dict:
return {"id": "order-placeholder", "user_id": user_id, "items": items, "status": "submitted"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter, HTTPException, Query
from app.core.config import settings
from app.core.email import memory_mailer
router = APIRouter()
@router.get("/emails/latest-token")
async def latest_email_token(to: str = Query(...), template: str = Query(...)):
if not settings.enable_test_routes or settings.email_delivery_mode != "memory":
raise HTTPException(status_code=404, detail="NOT_FOUND")
token = memory_mailer.latest_token(to, template)
if not token:
raise HTTPException(status_code=404, detail="TOKEN_NOT_FOUND")
return {"token": token}
+1
View File
@@ -0,0 +1 @@
"""Users module."""
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
profile: Mapped["UserProfile"] = relationship(
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
passive_deletes=True,
)
class UserProfile(Base):
__tablename__ = "user_profiles"
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
user: Mapped[User] = relationship(back_populates="profile")
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import func, select
from app.core.database import session_scope
from app.modules.users.models import User, UserProfile
ALLOWED_ROLES = {"user", "admin"}
ALLOWED_STATUSES = {"pending", "active", "blocked"}
def _detach(db, instance):
db.refresh(instance)
db.expunge(instance)
return instance
def create_user(
email: str,
password_hash: str,
role: str = "user",
is_superuser: bool = False,
status: str = "pending",
) -> User:
if role not in ALLOWED_ROLES:
raise ValueError("INVALID_ROLE")
if status not in ALLOWED_STATUSES:
raise ValueError("INVALID_STATUS")
if is_superuser and role != "admin":
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
with session_scope() as db:
user = User(
id=str(uuid4()),
email=email.lower(),
password_hash=password_hash,
role=role,
is_superuser=is_superuser,
status=status,
)
db.add(user)
db.flush()
profile = UserProfile(user_id=user.id, display_name=email.split("@")[0])
db.add(profile)
db.flush()
return _detach(db, user)
def get_user_by_email(email: str) -> User | None:
with session_scope() as db:
user = db.scalar(select(User).where(User.email == email.lower()))
if not user:
return None
return _detach(db, user)
def get_user_by_id(user_id: str) -> User | None:
with session_scope() as db:
user = db.get(User, user_id)
if not user:
return None
return _detach(db, user)
def update_user(user: User) -> None:
if user.role not in ALLOWED_ROLES:
raise ValueError("INVALID_ROLE")
if user.status not in ALLOWED_STATUSES:
raise ValueError("INVALID_STATUS")
if user.is_superuser and user.role != "admin":
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
with session_scope() as db:
db_user = db.get(User, user.id)
if not db_user:
return
db_user.email = user.email
db_user.password_hash = user.password_hash
db_user.role = user.role
db_user.is_superuser = user.is_superuser
db_user.status = user.status
db_user.failed_login_attempts = user.failed_login_attempts
db_user.locked_until = user.locked_until
db_user.email_verified_at = user.email_verified_at
db_user.updated_at = datetime.now(UTC)
def list_users(page: int, limit: int) -> tuple[list[User], int]:
with session_scope() as db:
total = db.scalar(select(func.count()).select_from(User)) or 0
users = db.scalars(
select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit)
).all()
return [_detach(db, user) for user in users], total
def count_users() -> int:
with session_scope() as db:
return db.scalar(select(func.count()).select_from(User)) or 0
def count_users_registered_today() -> int:
today = datetime.now(UTC).date()
with session_scope() as db:
return (
db.scalar(
select(func.count())
.select_from(User)
.where(func.date(User.created_at) == today)
)
or 0
)
def count_admins() -> int:
with session_scope() as db:
return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
def count_superusers() -> int:
with session_scope() as db:
return (
db.scalar(
select(func.count())
.select_from(User)
.where(User.role == "admin", User.is_superuser.is_(True))
)
or 0
)
def delete_user(user_id: str) -> bool:
with session_scope() as db:
user = db.get(User, user_id)
if not user:
return False
db.delete(user)
return True
def get_profile(user_id: str) -> UserProfile:
with session_scope() as db:
profile = db.get(UserProfile, user_id)
if not profile:
raise KeyError(user_id)
return _detach(db, profile)
def update_profile(
user_id: str,
display_name: str | None = None,
avatar_url: str | None = None,
) -> UserProfile:
with session_scope() as db:
profile = db.get(UserProfile, user_id)
if not profile:
raise KeyError(user_id)
if display_name is not None:
profile.display_name = display_name
if avatar_url is not None:
profile.avatar_url = avatar_url
db.flush()
return _detach(db, profile)
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from app.core.dependencies import get_current_user
from app.core.media_signing import build_signed_media_url
from app.core.redis import check_rate_limit
from app.modules.media.service import AvatarValidationError
from app.modules.users.schemas import PasswordChangeIn, UserPatchIn
from app.modules.users.service import change_password, get_me, update_me, upload_avatar
router = APIRouter()
def _profile_response(data: dict) -> dict:
return {
"user": {
"id": data["user"].id,
"email": data["user"].email,
"role": data["user"].role,
"status": data["user"].status,
},
"profile": {
"display_name": data["profile"].display_name,
"avatar_url": build_signed_media_url(data["profile"].avatar_url),
},
}
@router.get("/me")
async def me_route(current_user=Depends(get_current_user)):
return _profile_response(get_me(current_user))
@router.patch("/me")
async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)):
return _profile_response(update_me(current_user, payload.display_name))
@router.post("/me/password")
async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)):
try:
change_password(current_user, payload.current_password, payload.new_password)
except ValueError:
raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD")
return {"message": "password_changed"}
@router.post("/me/avatar")
async def upload_avatar_route(
request: Request,
file: UploadFile = File(...),
current_user=Depends(get_current_user),
):
check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600)
content = await file.read()
try:
data = upload_avatar(current_user, content)
except AvatarValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _profile_response(data)
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class UserOut(BaseModel):
id: str
email: EmailStr
role: str
status: str
class UserProfileOut(BaseModel):
display_name: str
avatar_url: str | None = None
class UserMeOut(BaseModel):
user: UserOut
profile: UserProfileOut
class UserPatchIn(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def password_policy(cls, value: str) -> str:
return validate_password_strength(value)
+29
View File
@@ -0,0 +1,29 @@
from app.core.security import hash_password, verify_password
from app.modules.auth.service import revoke_user_refresh_family
from app.modules.media.service import upload_user_avatar
from app.modules.users import repository
from app.modules.users.models import User
def get_me(user: User) -> dict:
profile = repository.get_profile(user.id)
return {"user": user, "profile": profile}
def update_me(user: User, display_name: str | None) -> dict:
profile = repository.update_profile(user.id, display_name=display_name)
return {"user": user, "profile": profile}
def upload_avatar(user: User, content: bytes) -> dict:
avatar_url = upload_user_avatar(user.id, content)
profile = repository.update_profile(user.id, avatar_url=avatar_url)
return {"user": user, "profile": profile}
def change_password(user: User, current_password: str, new_password: str) -> None:
if not verify_password(current_password, user.password_hash):
raise ValueError("INVALID_CURRENT_PASSWORD")
user.password_hash = hash_password(new_password)
repository.update_user(user)
revoke_user_refresh_family(user.id)
+1
View File
@@ -0,0 +1 @@
"""Celery worker entrypoint placeholder for v1.0 notifications."""
+21
View File
@@ -0,0 +1,21 @@
{
"enable_rate_limit": false,
"enable_docs": false,
"cookie_secure": false,
"jwt_access_ttl_min": 15,
"auth_lockout_attempts": 5,
"auth_lockout_minutes": 15,
"cors_origins": [
"http://localhost:5173"
],
"frontend_url": "http://localhost:5173",
"public_base_url": "http://localhost:5173",
"smtp_host": "localhost",
"smtp_port": 1025,
"smtp_from": "noreply@compton.example",
"avatar_max_bytes": 2097152,
"media_url_ttl_seconds": 600,
"log_level": "INFO",
"audit_retention_days": 90,
"jwt_refresh_ttl_days": 30
}
+52
View File
@@ -0,0 +1,52 @@
{"timestamp": "2026-07-14T09:45:09.679160+00:00", "action": "admin.settings.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:45:10.079262+00:00", "action": "admin.user.create", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "2effcf29-377b-417d-a804-d19cb4c9e2a1"}}
{"timestamp": "2026-07-14T09:45:11.267271+00:00", "action": "admin.user.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "a508d851-1f17-4397-a272-c67ac65d0b12"}}
{"timestamp": "2026-07-14T09:45:24.615324+00:00", "action": "admin.settings.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:45:25.017710+00:00", "action": "admin.user.create", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}}
{"timestamp": "2026-07-14T09:45:25.217777+00:00", "action": "admin.user.delete", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}}
{"timestamp": "2026-07-14T09:45:25.431192+00:00", "action": "admin.user.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "55c1800f-e445-4eae-b84a-836c27ec88de"}}
{"timestamp": "2026-07-14T09:45:43.063910+00:00", "action": "admin.settings.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:45:43.471557+00:00", "action": "admin.user.create", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}}
{"timestamp": "2026-07-14T09:45:43.677436+00:00", "action": "admin.user.delete", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}}
{"timestamp": "2026-07-14T09:45:43.890842+00:00", "action": "admin.user.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "5094baf7-5c73-4e9d-a1bb-8baa98516303"}}
{"timestamp": "2026-07-14T09:46:32.753967+00:00", "action": "admin.settings.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T09:46:33.160994+00:00", "action": "admin.user.create", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}}
{"timestamp": "2026-07-14T09:46:33.371828+00:00", "action": "admin.user.delete", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}}
{"timestamp": "2026-07-14T09:46:33.574496+00:00", "action": "admin.user.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "9c93dc2b-b171-486e-ac8b-855a657dd4db"}}
{"timestamp": "2026-07-14T10:17:13.813043+00:00", "action": "admin.settings.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:17:15.032768+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "a7f9dad1-652a-4a78-8eb4-559d73633cbb"}}
{"timestamp": "2026-07-14T10:17:17.964910+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "14c90906-7e65-4c2f-bc10-3e2c37771f77"}}
{"timestamp": "2026-07-14T10:17:21.299378+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "4fb379b6-d751-4b74-8441-dacfc765a1b6"}}
{"timestamp": "2026-07-14T10:18:21.413246+00:00", "action": "admin.settings.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:18:21.831392+00:00", "action": "admin.user.create", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}}
{"timestamp": "2026-07-14T10:18:22.043824+00:00", "action": "admin.user.delete", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}}
{"timestamp": "2026-07-14T10:18:22.667006+00:00", "action": "admin.secrets.reveal", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:18:23.044151+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "19bd08e1-1fd2-45d6-9d2e-910b8021060d"}}
{"timestamp": "2026-07-14T10:18:25.817132+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3e2fb3bf-a9c3-47e0-9c7d-5b9a4fff37d5"}}
{"timestamp": "2026-07-14T10:18:29.042541+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "25c9b2af-b46b-4c38-96e8-ca504e3d05ef"}}
{"timestamp": "2026-07-14T10:18:59.773024+00:00", "action": "admin.settings.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:19:00.184666+00:00", "action": "admin.user.create", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}}
{"timestamp": "2026-07-14T10:19:00.387212+00:00", "action": "admin.user.delete", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}}
{"timestamp": "2026-07-14T10:19:01.013462+00:00", "action": "admin.secrets.reveal", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:19:01.232252+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "2a167305-8874-4b4b-9485-2f01dbc239e7"}}
{"timestamp": "2026-07-14T10:19:03.987813+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "022de58f-d06e-42a7-bd93-76e1df17848c"}}
{"timestamp": "2026-07-14T10:19:07.219981+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "589bffa1-74dd-401a-a21e-93721d7d9c4e"}}
{"timestamp": "2026-07-14T10:19:24.488155+00:00", "action": "admin.settings.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:19:24.903805+00:00", "action": "admin.user.create", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}}
{"timestamp": "2026-07-14T10:19:25.101700+00:00", "action": "admin.user.delete", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}}
{"timestamp": "2026-07-14T10:19:25.741900+00:00", "action": "admin.secrets.reveal", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:19:25.941995+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "3c69f5f3-af00-4d5c-85d6-80a65cb7cf08"}}
{"timestamp": "2026-07-14T10:19:28.720348+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a4c56a38-9f0a-48dd-80e2-96ee38f1bde1"}}
{"timestamp": "2026-07-14T10:19:31.957425+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d91c9691-8277-44f5-8f02-534f6a1b75e4"}}
{"timestamp": "2026-07-14T10:20:50.255505+00:00", "action": "admin.settings.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:20:50.666682+00:00", "action": "admin.user.create", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}}
{"timestamp": "2026-07-14T10:20:50.885241+00:00", "action": "admin.user.delete", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}}
{"timestamp": "2026-07-14T10:20:51.518235+00:00", "action": "admin.secrets.reveal", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:20:51.717954+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "81911a53-d750-480b-aca2-a6c79488cf84"}}
{"timestamp": "2026-07-14T10:20:54.465598+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "2c535720-06d4-4c30-8d00-36c43419c9bf"}}
{"timestamp": "2026-07-14T10:20:57.747476+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "6de83480-a867-4166-97e4-6c5a053ae771"}}
{"timestamp": "2026-07-14T10:50:15.053615+00:00", "action": "admin.settings.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
{"timestamp": "2026-07-14T10:50:15.469330+00:00", "action": "admin.user.create", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}}
{"timestamp": "2026-07-14T10:50:15.684274+00:00", "action": "admin.user.delete", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}}
{"timestamp": "2026-07-14T10:50:16.307486+00:00", "action": "admin.secrets.reveal", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
{"timestamp": "2026-07-14T10:50:16.524434+00:00", "action": "admin.user.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "5afc1722-6ef5-4c0e-817a-fd1f4af99188"}}
+1
View File
@@ -0,0 +1 @@
+52
View File
@@ -0,0 +1,52 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
# Import models so Alembic can discover metadata.
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401
from app.modules.content.models import ContentPage # noqa: F401
from app.modules.users.models import User, UserProfile # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
transaction_per_migration=True,
)
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,120 @@
"""initial schema
Revision ID: 20260711_0001
Revises:
Create Date: 2026-07-11 14:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260711_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("failed_login_attempts", sa.Integer(), nullable=False),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("idx_users_email", "users", ["email"], unique=True)
op.create_index("idx_users_status", "users", ["status"], unique=False)
op.create_table(
"user_profiles",
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("display_name", sa.String(length=120), nullable=False),
sa.Column("avatar_url", sa.String(length=512), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("family_id", sa.String(length=36), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False)
op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False)
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False)
op.create_table(
"email_verification_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_table(
"content_pages",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("author_id", sa.String(length=36), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["author_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True)
op.create_index("idx_content_status", "content_pages", ["status"], unique=False)
def downgrade() -> None:
op.drop_index("idx_content_status", table_name="content_pages")
op.drop_index("idx_content_slug", table_name="content_pages")
op.drop_table("content_pages")
op.drop_table("email_verification_tokens")
op.drop_index("idx_password_reset_user", table_name="password_reset_tokens")
op.drop_table("password_reset_tokens")
op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens")
op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_table("user_profiles")
op.drop_index("idx_users_status", table_name="users")
op.drop_index("idx_users_email", table_name="users")
op.drop_table("users")
@@ -0,0 +1,33 @@
"""seed admin and demo content
Revision ID: 20260711_0002
Revises: 20260711_0001
Create Date: 2026-07-11 14:05:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260711_0002"
down_revision: Union[str, None] = "20260711_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed)
# after all schema migrations, including is_superuser (20260714_0003).
pass
def downgrade() -> None:
from sqlalchemy import delete
from app.core.database import session_scope
from app.modules.content.models import ContentPage
from app.modules.users.models import User
with session_scope() as db:
db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"])))
db.execute(delete(User).where(User.email == "admin@compton.example"))
@@ -0,0 +1,36 @@
"""add is_superuser to users
Revision ID: 20260714_0003
Revises: 20260711_0002
Create Date: 2026-07-14 12:45:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0003"
down_revision: Union[str, None] = "20260711_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()),
)
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.alter_column("users", "is_superuser", server_default=None)
op.execute(
sa.text(
"UPDATE users SET is_superuser = true "
"WHERE email = 'admin@compton.example' AND role = 'admin'"
)
)
def downgrade() -> None:
op.drop_column("users", "is_superuser")
@@ -0,0 +1,55 @@
"""db security constraints and token indexes
Revision ID: 20260714_0004
Revises: 20260714_0003
Create Date: 2026-07-14 13:30:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260714_0004"
down_revision: Union[str, None] = "20260714_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.create_check_constraint(
"ck_users_role_allowed",
"users",
"role IN ('user', 'admin')",
)
op.create_check_constraint(
"ck_users_status_allowed",
"users",
"status IN ('pending', 'active', 'blocked')",
)
op.create_check_constraint(
"ck_users_superuser_requires_admin",
"users",
"(is_superuser = false) OR (role = 'admin')",
)
op.create_check_constraint(
"ck_content_pages_status_allowed",
"content_pages",
"status IN ('draft', 'published')",
)
op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False)
op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False)
op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False)
def downgrade() -> None:
op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens")
op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens")
op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens")
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check")
op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check")
op.drop_constraint("ck_users_status_allowed", "users", type_="check")
op.drop_constraint("ck_users_role_allowed", "users", type_="check")
@@ -0,0 +1,35 @@
"""backfill superuser flag for seeded admin
Revision ID: 20260714_0005
Revises: 20260714_0004
Create Date: 2026-07-14 14:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0005"
down_revision: Union[str, None] = "20260714_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
sa.text(
"UPDATE users SET is_superuser = true "
"WHERE email = 'admin@compton.example' AND role = 'admin'"
)
)
op.execute(
sa.text(
"UPDATE users SET is_superuser = false "
"WHERE email IN ('ops@compton.example', 'user@compton.example')"
)
)
def downgrade() -> None:
pass
+13
View File
@@ -0,0 +1,13 @@
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
check_untyped_defs = true
ignore_missing_imports = true
+24
View File
@@ -0,0 +1,24 @@
fastapi>=0.115.0
uvicorn>=0.32.0
sqlalchemy>=2.0.36
alembic>=1.13.3
psycopg[binary]>=3.2.0
asyncpg>=0.30.0
redis>=5.1.1
python-jose>=3.3.0
bcrypt>=4.0.0,<5.0.0
pydantic>=2.9.2
email-validator>=2.2.0
pydantic-settings>=2.6.0
httpx>=0.27.2
pytest>=8.3.3
pytest-asyncio>=0.24.0
pytest-cov>=5.0.0
mypy>=1.11.2
ruff>=0.7.1
pip-audit>=2.7.3
bandit>=1.7.9
bleach>=6.1.0
python-multipart>=0.0.12
boto3>=1.35.0
Pillow>=10.4.0
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.install_secrets import ensure_install_secrets
def main() -> None:
status = ensure_install_secrets()
if status.created:
print(f"Created install secrets at {status.path}")
print(
"\nIf docker compose was already started without install.env, reset the Postgres volume "
"before the next start (local dev only — deletes DB data):\n"
" docker compose --profile docker-web down -v\n"
" docker compose --profile docker-web up -d --build"
)
else:
print(f"Install secrets already exist at {status.path}")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API."""
from __future__ import annotations
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings
INITIAL_REVISION = "20260711_0001"
SCHEMA_TABLES = (
"content_pages",
"email_verification_tokens",
"password_reset_tokens",
"refresh_tokens",
"user_profiles",
"users",
)
def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
engine = create_engine(settings.database_url)
last_error: Exception | None = None
for _ in range(max_attempts):
try:
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
return engine
except Exception as exc:
last_error = exc
time.sleep(delay_seconds)
detail = str(last_error or "unknown error")
hint = ""
if "password authentication failed" in detail or "does not exist" in detail:
hint = (
"\n\nPostgres credentials in install.env do not match the existing database volume "
"(common if docker compose ran before bootstrap_install.py).\n"
"Local dev fix — deletes Docker DB data:\n"
" docker compose --profile docker-web down -v\n"
" docker compose --profile docker-web up -d --build\n"
"See docs/secrets-recovery.md"
)
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
def current_revision(engine) -> str | None:
inspector = inspect(engine)
if "alembic_version" not in inspector.get_table_names():
return None
with engine.connect() as connection:
return connection.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _reset_schema(engine) -> None:
cascade = " CASCADE" if engine.dialect.name == "postgresql" else ""
with engine.begin() as connection:
for table in SCHEMA_TABLES:
connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}'))
connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}'))
def run_migrations(engine) -> None:
config = Config("alembic.ini")
tables = set(inspect(engine).get_table_names())
revision = current_revision(engine)
schema_tables = set(SCHEMA_TABLES)
existing_schema = tables & schema_tables
if existing_schema:
if schema_tables.issubset(tables):
if revision is None:
command.stamp(config, INITIAL_REVISION)
else:
_reset_schema(engine)
command.upgrade(config, "head")
def main() -> None:
ensure_install_secrets()
engine = wait_for_database()
run_migrations(engine)
subprocess.run(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"],
check=True,
)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
import json
from app.main import app
def main() -> None:
schema = app.openapi()
output = Path(__file__).resolve().parent.parent / "openapi.json"
output.write_text(json.dumps(schema, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
"""Apply Alembic migrations."""
from alembic import command
from alembic.config import Config
def main() -> None:
config = Config("alembic.ini")
command.upgrade(config, "head")
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
API_DIR = Path(__file__).resolve().parents[1]
os.chdir(API_DIR)
sys.path.insert(0, str(API_DIR))
db_file = API_DIR / ".e2e.sqlite"
if db_file.exists():
db_file.unlink()
os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_file.as_posix()}"
os.environ["EMAIL_DELIVERY_MODE"] = "memory"
os.environ["STORAGE_MODE"] = "memory"
os.environ["ENABLE_RATE_LIMIT"] = "false"
os.environ["ENABLE_TEST_ROUTES"] = "true"
web_port = os.environ.get("E2E_WEB_PORT", "5175")
os.environ["CORS_ORIGINS"] = f'["http://127.0.0.1:{web_port}","http://localhost:{web_port}"]'
port = os.environ.get("E2E_API_PORT", "8001")
subprocess.run(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", port],
cwd=API_DIR,
check=True,
)
+50
View File
@@ -0,0 +1,50 @@
import os
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("EMAIL_DELIVERY_MODE", "memory")
from fastapi.testclient import TestClient
import pytest
from app.core import database as db_module
from app.core.email import memory_mailer
from app.core.storage import memory_store
from app.db.base import Base
from app.db.seed import run_seed
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def setup_database():
Base.metadata.create_all(db_module.engine)
run_seed(include_demo_pages=False)
yield
Base.metadata.drop_all(db_module.engine)
@pytest.fixture(autouse=True)
def disable_rate_limits(monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "enable_rate_limit", False)
monkeypatch.setattr(settings, "email_delivery_mode", "memory")
monkeypatch.setattr(settings, "storage_mode", "memory")
@pytest.fixture(autouse=True)
def clear_email_outbox():
memory_mailer.clear()
yield
memory_mailer.clear()
@pytest.fixture(autouse=True)
def clear_storage():
memory_store.clear()
yield
memory_store.clear()
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
+38
View File
@@ -0,0 +1,38 @@
from app.core.media_signing import build_signed_media_url, verify_signed_media
from app.core.config import settings
import time
def test_build_and_verify_signed_media_url():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
assert "expires=" in signed
assert "sig=" in signed
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert int(params["expires"]) - int(time.time()) <= settings.media_url_ttl_seconds
assert verify_signed_media(path, int(params["expires"]), params["sig"])
def test_build_signed_media_url_none():
assert build_signed_media_url(None) is None
def test_verify_signed_media_rejects_expired_signature():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert not verify_signed_media(path, int(params["expires"]) - 10_000, params["sig"])
def test_verify_signed_media_rejects_tampered_signature():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert not verify_signed_media(path, int(params["expires"]), "invalid")
@@ -0,0 +1,6 @@
from app.core.media_signing import build_signed_media_url
def test_build_signed_media_url_passthrough():
external = "https://cdn.example.com/avatar.png"
assert build_signed_media_url(external) == external
@@ -0,0 +1,5 @@
from app.core.password_denylist import is_denied_password
def test_password_denylist_blocks_common_password():
assert is_denied_password("password123")
+5
View File
@@ -0,0 +1,5 @@
from app.core.storage import ensure_bucket
def test_ensure_bucket_noop_in_memory():
ensure_bucket()
+32
View File
@@ -0,0 +1,32 @@
from app.db.seed import run_seed
from app.modules.users.repository import get_user_by_email
def test_seed_ensures_admin_is_superuser():
admin = get_user_by_email("admin@compton.example")
assert admin is not None
admin.is_superuser = False
from app.modules.users import repository
repository.update_user(admin)
run_seed(include_demo_pages=False)
refreshed = get_user_by_email("admin@compton.example")
assert refreshed is not None
assert refreshed.is_superuser is True
def test_seed_ensures_ops_is_not_superuser():
ops = get_user_by_email("ops@compton.example")
assert ops is not None
ops.is_superuser = True
from app.modules.users import repository
repository.update_user(ops)
run_seed(include_demo_pages=False)
refreshed = get_user_by_email("ops@compton.example")
assert refreshed is not None
assert refreshed.is_superuser is False
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.core.database import session_scope
from app.db.token_cleanup import cleanup_expired_tokens
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
from app.modules.users.repository import get_user_by_email
def test_cleanup_expired_tokens_removes_stale_rows():
user = get_user_by_email("admin@compton.example")
now = datetime.now(UTC)
with session_scope() as db:
db.add(
RefreshToken(
user_id=user.id,
token_hash="f" * 64,
family_id="fam-cleanup-1",
expires_at=now - timedelta(days=2),
revoked_at=now - timedelta(days=2),
)
)
db.add(
PasswordResetToken(
user_id=user.id,
token_hash="e" * 64,
expires_at=now - timedelta(days=1),
used_at=None,
)
)
db.add(
EmailVerificationToken(
user_id=user.id,
token_hash="d" * 64,
expires_at=now - timedelta(days=1),
used_at=None,
)
)
result = cleanup_expired_tokens(retention_days=1)
assert result["refresh_tokens_deleted"] >= 1
assert result["password_reset_tokens_deleted"] >= 1
assert result["email_verification_tokens_deleted"] >= 1
+3
View File
@@ -0,0 +1,3 @@
def test_health_smoke(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from io import BytesIO
from PIL import Image
def make_test_png() -> bytes:
image = Image.new("RGB", (8, 8), color=(70, 129, 109))
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core.email import memory_mailer
def clear_sent_emails() -> None:
memory_mailer.clear()
def latest_token(recipient: str, template: str) -> str:
token = memory_mailer.latest_token(recipient, template)
if not token:
raise AssertionError(f"No {template} email sent to {recipient}")
return token
def register_and_verify(client: TestClient, email: str, password: str = "Valid123") -> None:
client.post("/api/v1/auth/register", json={"email": email, "password": password})
token = latest_token(email, "verify_email")
response = client.post("/api/v1/auth/verify-email", json={"token": token})
assert response.status_code == 200
def register_verify_login(client: TestClient, email: str, password: str = "Valid123") -> dict[str, str]:
register_and_verify(client, email, password)
login = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert login.status_code == 200
return {"Authorization": f"Bearer {login.json()['access_token']}"}
@@ -0,0 +1,26 @@
from __future__ import annotations
def _super_headers(client) -> dict[str, str]:
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
assert login.status_code == 200
return {"Authorization": f"Bearer {login.json()['access_token']}"}
def test_admin_create_user_rejects_weak_password(client):
headers = _super_headers(client)
response = client.post(
"/api/v1/admin/users",
headers=headers,
json={
"email": "weak-pass@example.com",
"password": "password",
"role": "user",
"is_superuser": False,
"status": "active",
},
)
assert response.status_code == 422
@@ -0,0 +1,73 @@
from app.core.security import hash_password
from app.modules.users import repository
def _admin_headers(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def _plain_admin_headers(client):
email = "ops-admin@compton.example"
if not repository.get_user_by_email(email):
repository.create_user(
email=email,
password_hash=hash_password("Admin1234"),
role="admin",
is_superuser=False,
status="active",
)
login = client.post("/api/v1/auth/login", json={"email": email, "password": "Admin1234"})
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_admin_users_list(client):
response = client.get("/api/v1/admin/users", headers=_admin_headers(client))
assert response.status_code == 200
assert "data" in response.json()
def test_non_superuser_cannot_patch_settings(client):
response = client.patch(
"/api/v1/admin/settings",
json={"values": {"enable_docs": False}},
headers=_plain_admin_headers(client),
)
assert response.status_code == 403
assert response.json()["detail"] == "SUPERUSER_ONLY"
def test_superuser_can_patch_settings(client):
response = client.patch(
"/api/v1/admin/settings",
json={"values": {"enable_docs": False}},
headers=_admin_headers(client),
)
assert response.status_code == 200
payload = response.json()
assert "values" in payload
def test_superuser_can_create_and_delete_user(client):
created = client.post(
"/api/v1/admin/users",
json={
"email": "created-by-admin@compton.example",
"password": "StrongPass123A",
"role": "user",
"is_superuser": False,
"status": "active",
},
headers=_admin_headers(client),
)
assert created.status_code == 200
user_id = created.json()["id"]
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
assert deleted.status_code == 200
assert deleted.json()["status"] == "deleted"
@@ -0,0 +1,35 @@
from __future__ import annotations
def _login(client, email: str, password: str) -> dict[str, str]:
response = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
def test_superuser_can_read_install_secrets(client):
headers = _login(client, "admin@compton.example", "Admin1234")
response = client.get("/api/v1/admin/secrets", headers=headers)
assert response.status_code == 200
payload = response.json()
assert "secrets_status" in payload
assert payload["secrets_status"]["postgres_password"] in {"configured", "missing"}
assert "POSTGRES_PASSWORD" not in str(payload)
def test_non_superuser_cannot_read_install_secrets(client):
headers = _login(client, "ops@compton.example", "OpsAdmin1234")
response = client.get("/api/v1/admin/secrets", headers=headers)
assert response.status_code == 403
def test_superuser_can_reveal_db_password(client):
headers = _login(client, "admin@compton.example", "Admin1234")
response = client.post(
"/api/v1/admin/secrets/reveal",
headers=headers,
json={"key": "database_password"},
)
assert response.status_code == 200
assert response.json()["key"] == "database_password"
assert "value" in response.json()
@@ -0,0 +1,33 @@
from unittest.mock import patch
from app.modules.admin.service import patch_user
from app.modules.users import repository
from app.modules.users.repository import create_user, get_user_by_email
from app.core.security import hash_password
def test_admin_cannot_self_demote():
admin = get_user_by_email("admin@compton.example")
try:
patch_user(admin, admin.id, "user", None)
assert False, "Expected self-demotion error"
except ValueError as exc:
assert str(exc) == "SELF_DEMOTION_FORBIDDEN"
def test_admin_can_promote_user():
admin = get_user_by_email("admin@compton.example")
regular = create_user("sample@example.com", hash_password("Valid123"), role="user", status="active")
result = patch_user(admin, regular.id, "admin", None)
assert result["role"] == "admin"
def test_last_admin_protected():
admin = get_user_by_email("admin@compton.example")
target = create_user("target@example.com", hash_password("Valid123"), role="admin", status="active")
with patch.object(repository, "count_admins", return_value=1):
try:
patch_user(admin, target.id, "user", None)
assert False, "Expected last-admin protection"
except ValueError as exc:
assert str(exc) == "LAST_ADMIN_PROTECTED"
@@ -0,0 +1,42 @@
from tests.helpers import latest_token, register_and_verify
def test_health(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_register_and_verify_and_login(client):
register_response = client.post(
"/api/v1/auth/register",
json={"email": "user@example.com", "password": "Valid123"},
)
assert register_response.status_code == 200
assert "user_id" not in register_response.json()
duplicate = client.post(
"/api/v1/auth/register",
json={"email": "user@example.com", "password": "Valid123"},
)
assert duplicate.status_code == 200
assert "user_id" not in duplicate.json()
verify_response = client.post(
"/api/v1/auth/verify-email",
json={"token": latest_token("user@example.com", "verify_email")},
)
assert verify_response.status_code == 200
login_response = client.post(
"/api/v1/auth/login",
json={"email": "user@example.com", "password": "Valid123"},
)
assert login_response.status_code == 200
assert "access_token" in login_response.json()
def test_forgot_password_anti_enumeration(client):
response = client.post("/api/v1/auth/forgot-password", json={"email": "missing@example.com"})
assert response.status_code == 200
assert "If email is registered" in response.json()["message"]
@@ -0,0 +1,20 @@
from tests.helpers import latest_token
def test_verify_email_rejects_invalid_token(client):
client.post("/api/v1/auth/register", json={"email": "bad@example.com", "password": "Valid123"})
response = client.post("/api/v1/auth/verify-email", json={"token": "invalid-token"})
assert response.status_code == 400
assert response.json()["detail"] == "INVALID_TOKEN"
def test_resend_verification_sends_new_token(client):
client.post("/api/v1/auth/register", json={"email": "resend@example.com", "password": "Valid123"})
first_token = latest_token("resend@example.com", "verify_email")
client.post("/api/v1/auth/resend-verification", json={"email": "resend@example.com"})
second_token = latest_token("resend@example.com", "verify_email")
assert first_token != second_token
verify = client.post("/api/v1/auth/verify-email", json={"token": second_token})
assert verify.status_code == 200
@@ -0,0 +1,19 @@
from tests.helpers import latest_token, register_and_verify
def test_auth_brute_force_lockout(client):
register_and_verify(client, "locked@example.com")
for _ in range(5):
response = client.post(
"/api/v1/auth/login",
json={"email": "locked@example.com", "password": "WrongPass1"},
)
assert response.status_code == 401
locked = client.post(
"/api/v1/auth/login",
json={"email": "locked@example.com", "password": "Valid123"},
)
assert locked.status_code == 429
assert locked.json()["detail"] == "ACCOUNT_TEMPORARILY_LOCKED"
@@ -0,0 +1,7 @@
from app.core.password_policy import validate_password_strength
import pytest
def test_password_policy_rejects_denied_password():
with pytest.raises(ValueError, match="too common"):
validate_password_strength("Password123")
@@ -0,0 +1,12 @@
import pytest
from app.core.password_policy import validate_password_strength
def test_password_policy_accepts_valid_password():
assert validate_password_strength("Valid123") == "Valid123"
def test_password_policy_rejects_weak_password():
with pytest.raises(ValueError, match="uppercase letter"):
validate_password_strength("valid123")

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