mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
323 lines
18 KiB
Markdown
323 lines
18 KiB
Markdown
# holy_backend_rs — Rust backend (strangler migration)
|
|
|
|
Incremental Rust rewrite of the Django backend. Both backends run side by side
|
|
and **share the same SQLite file**; an nginx proxy routes already-ported
|
|
endpoint groups to Rust and everything else to Django. Endpoints move group by
|
|
group until Django can be retired.
|
|
|
|
- **Web**: Axum 0.8 + utoipa (OpenAPI) + Swagger UI
|
|
- **DB**: SeaORM over the existing SQLite (`backend/db.sqlite3`), schema unchanged
|
|
- **Auth**: SimpleJWT-compatible — verifies *and issues* HS256 tokens signed
|
|
with the same `SECRET_KEY` as Django (`Authorization: Bearer` or `access_token`
|
|
cookie). Login/refresh/verify/logout are ported; tokens are interchangeable
|
|
with Django (verified both directions).
|
|
- **Jobs**: tokio-cron-scheduler in-process (not yet wired; replaces Celery+beat)
|
|
|
|
## Ported so far (read slice)
|
|
|
|
| Method + path | Notes |
|
|
|---|---|
|
|
| `GET /health/` | liveness |
|
|
| `GET /api/categories/` `…/{id}/` | DRF paginated |
|
|
| `GET /api/flavors/` `…/{id}/` | paginated, `?category=`, `?category__slug=`, `?search=`, ordered by -average_rating |
|
|
| `GET /api/flavors/top/` `?category=` | top 10 rated |
|
|
| `GET /api/flavors/newest/` | 10 newest |
|
|
| `GET /api/flavors/search/?q=` | ranked hits (≤15) |
|
|
| `GET /api/flavors/followed_top/` | auth required |
|
|
| `GET /api/banners/` `…/{id}/` `…/active/` | enabled-only for non-superusers |
|
|
| `GET /api/schema/` + `/api/schema/swagger-ui` | OpenAPI |
|
|
|
|
### Auth slice (issuance)
|
|
|
|
| Method + path | Notes |
|
|
|---|---|
|
|
| `POST /api/auth/token/` | login; validates Django PBKDF2 password, sets httpOnly cookies, body `{"status":"ok"}` |
|
|
| `POST /api/auth/token/refresh/` | refresh from body or `refresh_token` cookie; rotates + blacklists old (BLACKLIST_AFTER_ROTATION) |
|
|
| `POST /api/auth/token/verify/` | validate any token; rejects blacklisted jti |
|
|
| `POST /api/auth/logout/` | blacklists the refresh cookie, clears both cookies |
|
|
| `POST /api/token/` | legacy alias of login |
|
|
|
|
### Ratings + replies slice (writes)
|
|
|
|
| Method + path | Notes |
|
|
|---|---|
|
|
| `GET /api/ratings/` | paginated (size 50), `-created_at`, nested replies (read-only OK anon) |
|
|
| `POST /api/ratings/` | auth; one rating per (user, flavor); parses `@mentions` in comment |
|
|
| `GET /api/ratings/{id}/` | retrieve |
|
|
| `PUT/PATCH /api/ratings/{id}/` | owner or superuser; no mention parsing on edit (matches Django) |
|
|
| `DELETE /api/ratings/{id}/` | owner or superuser; cascades replies + notifications |
|
|
| `GET /api/ratings/feed/` | auth; ratings by followed users, FeedPagination (size 10) |
|
|
| `GET /api/ratings/recent/` | AllowAny; 10 newest with a non-empty comment (plain array) |
|
|
| `POST /api/ratings/{id}/reply/` | auth; notifies the rating owner, parses `@mentions` |
|
|
| `GET /api/replies/` `…/{id}/` | auth |
|
|
| `PUT/PATCH /api/replies/{id}/` | owner or superuser; re-parses `@mentions` |
|
|
| `DELETE /api/replies/{id}/` | owner or superuser; cascades notifications |
|
|
|
|
Mirrors `RatingViewSet`/`ReplyViewSet` + `services/mentions.py`. Validation
|
|
errors reproduce DRF's field-keyed / top-level-list bodies. Django's
|
|
`on_delete=CASCADE` is emulated in the handlers (collect + delete dependent
|
|
notifications/replies in a transaction) because SQLite enforces the FKs but has
|
|
no `ON DELETE CASCADE`. Datetimes are written truncated to microseconds so the
|
|
on-disk format matches Django's; verified Django reads Rust-written rows back.
|
|
|
|
### Users / social slice
|
|
|
|
| Method + path | Notes |
|
|
|---|---|
|
|
| `POST /api/users/signup/` | AllowAny; creates inactive user, sends verification email; `{"error":...}` on dup/missing |
|
|
| `POST /api/users/resend_verification/` | AllowAny; new code + email |
|
|
| `POST /api/users/verify_signup/` | AllowAny; activates on matching code |
|
|
| `GET /api/users/me/` | auth; also logs client IP (XFF first hop, else peer addr) |
|
|
| `GET /api/users/profile/{username}/` | AllowAny; public profile aggregate (ratings, comments, followers, following) |
|
|
| `GET /api/users/following_list/` | auth; users the caller follows |
|
|
| `GET /api/users/dashboard/` | auth; rated/missing flavors + my ratings, with followed-average |
|
|
| `PATCH /api/users/update_preferences/` | auth; theme/language/drawer_anchor/selected_banner (validated) |
|
|
| `POST /api/users/update_avatar/` | auth; multipart; resizes >256px or non-JPEG to 256² JPEG q90 (PIL parity) |
|
|
| `POST /api/users/change_password/` | auth; checks old password |
|
|
| `PATCH /api/users/update_profile/` | auth; username (unique) + email change (confirmation code mailed) |
|
|
| `POST /api/users/confirm_email/` | auth; applies pending email on matching code |
|
|
| `POST /api/users/request_password_reset/` | AllowAny; always returns generic message |
|
|
| `POST /api/users/complete_password_reset/` | AllowAny; email+code → new password |
|
|
| `POST /api/users/request_account_deletion/` | auth; mails deletion code |
|
|
| `POST /api/users/confirm_account_deletion/` | auth; deletes account + all dependents (cascade) |
|
|
| `GET /api/users/{id}/` | auth; retrieve |
|
|
| `POST /api/users/{id}/follow/` `…/unfollow/` | auth; follow notification; self-follow rejected |
|
|
| `POST /api/users/{id}/add_comment/` | auth; profile comment + notification |
|
|
| `DELETE /api/users/{id}/delete_comment/{comment_id}/` | author or profile owner |
|
|
| `GET /api/notifications/` | auth; own, `-created_at`, plain array |
|
|
| `GET /api/notifications/{id}/` | auth; own only (404 otherwise) |
|
|
| `POST /api/notifications/{id}/mark_read/` | auth |
|
|
| `POST /api/notifications/mark_all_read/` | auth |
|
|
|
|
### Tickets / support slice
|
|
|
|
| Method + path | Notes |
|
|
|---|---|
|
|
| `GET /api/tickets/` | auth; superuser sees all, else own; `-updated_at`; paginated |
|
|
| `POST /api/tickets/` | auth; status defaults `open`; notifies other admins |
|
|
| `GET/PUT/PATCH /api/tickets/{id}/` | auth; own or superuser |
|
|
| `DELETE /api/tickets/{id}/` | superuser only; cascades messages + notifications |
|
|
| `POST /api/tickets/{id}/add_message/` | auth; notifies counterparty; `{"error":"Text is required"}` if empty |
|
|
| `POST /api/tickets/{id}/update_status/` | staff only (`IsAdminUser`); validates status enum |
|
|
|
|
Ports `UserViewSet`/`NotificationViewSet`/`TicketViewSet` + serializers
|
|
field-for-field. Generic `POST/PUT/PATCH/DELETE /api/users/` and `/api/users/{id}/`
|
|
are intentionally **not** exposed (Django's `ModelViewSet` lets any authenticated
|
|
user edit any user via `UserSerializer` — a footgun the frontend never uses; the
|
|
dedicated actions cover every real flow). They 405.
|
|
|
|
**Email is implemented** (lettre): when `EMAIL_HOST` is set, mail is sent over
|
|
SMTP — STARTTLS (`EMAIL_USE_TLS`) or implicit TLS (`EMAIL_USE_SSL`), with
|
|
optional cert/hostname skip (`EMAIL_SKIP_CERT_VERIFICATION`, mirrors Django's
|
|
`InsecureEmailBackend`). With no `EMAIL_HOST` it logs to the console (dev
|
|
parity). Sends are fire-and-forget (`tokio::spawn`), matching Django's Celery
|
|
`.delay()`.
|
|
|
|
Account/ticket/comment deletion emulates Django's app-level CASCADE in a
|
|
transaction (SQLite has no `ON DELETE CASCADE`): account deletion removes the
|
|
user's notifications, replies (theirs + on their ratings), ratings, ticket
|
|
messages, tickets, profile comments (authored + owned), IP logs, follow rows,
|
|
and blacklisted tokens before the user row.
|
|
|
|
Mirrors `api/views/auth.py`. Tokens are HS256 with the SimpleJWT claim set
|
|
(`token_type, exp, iat, jti, user_id`), lifetimes 60 min / 14 days. The
|
|
refresh blacklist shares Django's `token_blacklist_*` tables, so rotation works
|
|
across both backends. Only the `pbkdf2_sha256` hasher is supported (the
|
|
project's default); change `password.rs` if the hasher ever changes.
|
|
|
|
Responses mirror the DRF serializers field-for-field (names, order, pagination
|
|
envelope) so the existing frontend is unchanged.
|
|
|
|
### Admin + jobs slice
|
|
|
|
Ports `AdminViewSet` (registered under the `admin-custom` prefix) and every
|
|
management command / Celery task. All endpoints require `IsAdminUser` (`is_staff`):
|
|
401 if anonymous, 403 if authenticated non-staff.
|
|
|
|
| Method + path | Notes |
|
|
|---|---|
|
|
| `GET /api/admin-custom/stats/` | counts + email/server config snapshot |
|
|
| `GET /api/admin-custom/config/` | `SystemConfig` pk=1 singleton (auto-created) |
|
|
| `PATCH /api/admin-custom/config/` | partial update; bumps `updated_at` |
|
|
| `GET /api/admin-custom/jobs/` | all job rows; `name_display` via registry, `next_run` derived (`last_run + interval_hours`) |
|
|
| `POST /api/admin-custom/{pk}/trigger_job/` | sets status `pending`, spawns the job; `{"status":"Job queued","task":"api.<name>"}` or 400 if unknown |
|
|
| `PATCH /api/admin-custom/{pk}/update_job_schedule/` | sets `interval_hours` |
|
|
| `POST /api/admin-custom/merge_flavors/` | `{keep_id, remove_id}`; 400 missing/diff-category, 404 not found, else merges |
|
|
| `POST /api/admin-custom/send_test_email/` | synchronous send; 200 or 500 with SMTP error |
|
|
| `GET /api/admin-custom/users/` | admin user list w/ IPs |
|
|
| `GET/PATCH/DELETE /api/admin-custom/{pk}/user_detail/` | full detail (IPs + ratings); DELETE cascades via shared `delete_user_cascade` |
|
|
|
|
**Job framework** (`src/jobs/`) replaces the Django management-command + Celery
|
|
layer with a single abstraction:
|
|
|
|
- `BackgroundJob` async trait (`name`, `display_name`, `run`) + a `registry()` of
|
|
5 jobs: `sync_flavors`, `cleanup_duplicates`, `backup_db`, `seed_legacy`,
|
|
`seed_banners`. `run_job()` is the lifecycle wrapper — it writes status /
|
|
`last_run` / `last_output` / `error_message` into the `api_job` row (parity
|
|
with Django's `_run_command_job`) and a per-process concurrency guard
|
|
(`Arc<Mutex<HashSet>>`) prevents the same job running twice.
|
|
- **Scheduler** (`scheduler.rs`): an in-process 60 s poller replacing Celery
|
|
beat. Each tick, any registry job whose row has `interval_hours > 0` and is due
|
|
(`last_run` null, or `now - last_run >= interval_hours`) and not already running
|
|
is dispatched. Restart-safe via the persisted `last_run`. Chosen over a cron
|
|
crate so intervals stay editable at runtime through `update_job_schedule`.
|
|
|
|
> **Operational note:** once Rust owns jobs in production, the Python Celery
|
|
> `beat`/`worker` containers **must** be disabled, or jobs run twice (both
|
|
> backends share `api_job` and the same SQLite file).
|
|
|
|
Django-data parity for the shared DB + media tree:
|
|
|
|
- `slugify` reimplemented (NFKD + ASCII-fold) to match Django exactly, so image
|
|
paths dedupe across both backends.
|
|
- Image filenames keyed by `sha1(url_path)[:10]`, matching Django's `hashlib.sha1`.
|
|
- Microsecond (6-frac-digit) timestamps; app-level CASCADE emulation.
|
|
- `merge_flavors` re-homes ratings (conflict resolution by comment length then
|
|
`created_at`), re-points replies/notifications, inherits `external_id`, in a txn.
|
|
- `sync_flavors` is a full port including the **Syrup** special-case (the single
|
|
bundle product's variants expand into individual `Flavor` rows keyed by variant id).
|
|
|
|
Verified live against a copy of the production SQLite: scheduler dispatched the
|
|
due `sync_flavors` job which fetched `weareholy.com` and reported
|
|
`Created: 24, Updated: 137, Marked Unavailable: 22`; the full endpoint matrix
|
|
(incl. 401/403 gates, merge, user_detail GET/PATCH/DELETE) passed.
|
|
|
|
## Run
|
|
|
|
```bash
|
|
cp .env.example .env # set SECRET_KEY; point DATABASE_URL at the shared db
|
|
cargo run # listens on BIND_ADDR (default 0.0.0.0:8001)
|
|
```
|
|
|
|
`SECRET_KEY` signs the SimpleJWT-compatible tokens — **keep it constant** across
|
|
deploys/restarts, or every issued access/refresh token is invalidated. (It must
|
|
match the value the retired Django app used, so tokens already in the wild stay
|
|
valid.)
|
|
|
|
## Deployment
|
|
|
|
The Rust backend is the production API; Django/Celery/Redis were retired after
|
|
the strangler migration completed. `docker compose` (`docker-compose.yml` +
|
|
`docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`)
|
|
and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend,
|
|
and serves `/media` from the shared `./backend/media` bind mount; see
|
|
`frontend/nginx.conf`.
|
|
|
|
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
|
`next`/`previous`) are rebuilt from them, matching DRF's `build_absolute_uri`.
|
|
`X-Forwarded-For` lets `/api/users/me/` log the real client IP (it falls back to
|
|
the peer address otherwise).
|
|
|
|
**Scheduler invariant:** exactly **one** process may own the `api_job` table.
|
|
Run a single `backend-rs` instance with `ENABLE_SCHEDULER=true`
|
|
(`RUST_ENABLE_SCHEDULER` in `.env`); any additional instances must leave it off.
|
|
|
|
The canonical data lives in host bind mounts — `backend/db.sqlite3` (WAL on) and
|
|
`backend/media/` — and survives image rebuilds. Keep them.
|
|
|
|
## Caveats / parity TODO
|
|
|
|
- `created_at` etc. are emitted as ISO-8601 with 6 fractional digits + `Z`. DRF
|
|
(Python `isoformat`) omits the fraction when microseconds are zero — verify
|
|
against live responses if a client parses strictly.
|
|
- The flavor `search` action approximates DRF's `SearchFilter` with word-level
|
|
`LIKE` matching + the category-keyword extraction from `services/search.py`.
|
|
- Average-rating ordering is validated by construction; the current DB has zero
|
|
ratings, so confirm ordering once rating data exists.
|
|
- `POST /api/replies/` is intentionally not exposed (Django marks `rating`
|
|
read-only there; replies are only created via `/ratings/{id}/reply/`). It
|
|
returns 405 rather than Django's 500.
|
|
- Generic `POST/PUT/PATCH/DELETE /api/users/` and `/api/users/{id}/` return 405
|
|
by design (see Users/social slice) — Django allowed them but the frontend
|
|
never used them.
|
|
- Admin endpoints + background jobs are ported (see the Admin + jobs slice).
|
|
The job-trigger and admin write endpoints are **not** rate-limited (those are
|
|
staff-only; `django_ratelimit` only covered the public flows below).
|
|
|
|
## Security & hardening
|
|
|
|
Rate limiting is ported in `throttle.rs` — an in-memory sliding-window limiter
|
|
(per-process; **resets on restart and fails open**, so keep nginx-level limits
|
|
in front for defence in depth, and run a single Rust instance). Limits mirror
|
|
`django_ratelimit`, plus a few the Django app lacked:
|
|
|
|
| Flow | Limit | Key | Parity |
|
|
|---|---|---|---|
|
|
| signup | 5 / h | IP | matches Django |
|
|
| password-reset request | 3 / h | IP | matches Django |
|
|
| complete password reset | 5 / h | IP | matches Django |
|
|
| account-deletion request | 3 / h | user | matches Django |
|
|
| rating `create` | 10 / min | user | matches Django (`reply` was never throttled, still isn't) |
|
|
| login | 10 / min | IP | **added** (Django had none) |
|
|
| resend verification | 5 / h | IP | **added** |
|
|
| confirmation-code attempts | 10 / h | account | **added** (brute-force lockout) |
|
|
|
|
Over-limit returns **429 + `Retry-After`** (DRF returned 403; the 429 body
|
|
includes both `detail` and `error` so existing frontend error handling still
|
|
reads a message).
|
|
|
|
Confirmation codes (signup / password reset / email change / account deletion)
|
|
now **expire after 15 minutes** and are cleared on use. Expiry is tracked
|
|
in-memory because the shared SQLite schema has no expiry column (adding one would
|
|
need a coordinated Django migration); the check **fails open on an unknown code
|
|
and only fails closed on a known-expired one**, so a process restart can't lock
|
|
anyone out — it only forgoes expiry enforcement for codes minted before restart.
|
|
|
|
Login runs a dummy PBKDF2 verify on the user-not-found branch so response time
|
|
can't be used to enumerate usernames. The signup/resend SMTP-failure path logs
|
|
the detail server-side and returns a generic message (no info disclosure to
|
|
anonymous callers).
|
|
|
|
Transport/process hardening (`main.rs`): static security headers
|
|
(`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`,
|
|
each set only if absent so nginx can override / own HSTS), an 8 MB request-body
|
|
cap, a 30 s whole-request timeout, a panic-catch layer (handler panic → 500
|
|
instead of a dropped connection), and graceful shutdown on SIGTERM/Ctrl+C so
|
|
in-flight requests drain. `/health/` now runs `SELECT 1` so the Docker
|
|
healthcheck fails when SQLite is unreachable.
|
|
|
|
SQLite runs in **WAL** journal mode (enabled once at startup, persistent in the
|
|
file header) so readers don't block behind the writer; `busy_timeout` is sqlx's
|
|
default 5 s, matching Django's SQLite timeout. The default `DATABASE_URL` uses `mode=rw` (not
|
|
`rwc`) so a wrong path fails loudly instead of silently creating an empty DB.
|
|
|
|
Confirmation codes are compared in **constant time** (`subtle::ConstantTimeEq`)
|
|
on signup verify / email confirm / account-deletion confirm.
|
|
|
|
CORS is **locked to an allow-list** (`config.cors_allowed_origins`, mirroring
|
|
Django's `CORS_ALLOWED_ORIGINS` derived from `DOMAIN`; override via the
|
|
`CORS_ALLOWED_ORIGINS` env var) rather than reflecting any origin — credentialed
|
|
CORS can't use a wildcard. Request *headers* are still mirrored; the origin is
|
|
the security boundary.
|
|
|
|
Password strength is enforced by `password_policy.rs`, a faithful port of
|
|
Django's four default `AUTH_PASSWORD_VALIDATORS` (minimum length 8, the shipped
|
|
20k common-password list embedded via `include_bytes!`, all-numeric rejection,
|
|
and user-attribute similarity using a `difflib.SequenceMatcher.quick_ratio`
|
|
reimplementation) applied on signup / change-password / complete-reset.
|
|
**Deliberate divergence:** the live Django app configures these validators but
|
|
never actually calls `validate_password`, so a weak password Django would accept
|
|
is rejected here. Verdicts and message ordering were cross-checked against
|
|
Django's `validate_password` on representative inputs.
|
|
|
|
Tested (`cargo test`): PBKDF2 verify against a known Django-produced hash + a
|
|
hash→verify round-trip; SimpleJWT mint/verify round-trips incl. wrong-secret and
|
|
tampered-token rejection; the rate limiter and code-TTL logic; and the password
|
|
validators. **Still open:** an integration test for cascade-delete completeness
|
|
(needs a seeded test DB harness, a different class of test from these unit
|
|
tests).
|
|
|
|
## Next slices (suggested order)
|
|
|
|
1. ~~Auth issuance (login/refresh)~~ — **done** (PBKDF2 verify + SimpleJWT minting
|
|
+ cookie views + refresh blacklist).
|
|
2. ~~Ratings/replies write + notifications (mentions parsing)~~ — **done**
|
|
(CRUD + feed/recent/reply, mention notifications, CASCADE emulation).
|
|
3. ~~Users/social, tickets/support~~ — **done** (account lifecycle, follow
|
|
graph, profile comments, notifications, tickets; SMTP email; avatar resize;
|
|
account/ticket cascade).
|
|
4. ~~Admin + jobs~~ — **done** (`AdminViewSet` + a `BackgroundJob` trait/registry
|
|
/runner, an in-process interval scheduler replacing Celery beat, all 5
|
|
management commands incl. full `sync_flavors` Shopify port).
|
|
```
|