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:
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Compton API application package."""
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
class DomainError(Exception):
|
||||||
|
"""Base domain error."""
|
||||||
@@ -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, "")
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.crypto import build_signed_media_url, verify_signed_media
|
||||||
|
|
||||||
|
__all__ = ["build_signed_media_url", "verify_signed_media"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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]] = {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
__all__ = ["Base"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application modules."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Admin module."""
|
||||||
@@ -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))
|
||||||
@@ -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}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Auth module."""
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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."}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Content module."""
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarUploadOut(BaseModel):
|
||||||
|
message: str
|
||||||
@@ -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}
|
||||||
@@ -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"}
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Users module."""
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Celery worker entrypoint placeholder for v1.0 notifications."""
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.core.storage import ensure_bucket
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_bucket_noop_in_memory():
|
||||||
|
ensure_bucket()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def test_health_smoke(client):
|
||||||
|
response = client.get("/api/v1/health")
|
||||||
|
assert response.status_code == 200
|
||||||
@@ -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()
|
||||||
@@ -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
Reference in New Issue
Block a user