18 KiB
holy_backend_rs — Rust backend (strangler migration)
Rust backend for the Holy Flavor Rater. The original Django backend has been fully retired and deleted.
- Web: Axum 0.8 + utoipa (OpenAPI) + Swagger UI
- DB: SeaORM over the SQLite database (
data/db.sqlite3), schema unchanged - Auth: SimpleJWT-compatible — verifies and issues HS256 tokens signed
with the
SECRET_KEY(Authorization: Beareroraccess_tokencookie). Login/refresh/verify/logout are ported. - Jobs: tokio-cron-scheduler in-process (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:
BackgroundJobasync trait (name,display_name,run) + aregistry()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_messageinto theapi_jobrow (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 hasinterval_hours > 0and is due (last_runnull, ornow - last_run >= interval_hours) and not already running is dispatched. Restart-safe via the persistedlast_run. Chosen over a cron crate so intervals stay editable at runtime throughupdate_job_schedule.
Operational note: once Rust owns jobs in production, the Python Celery
beat/workercontainers must be disabled, or jobs run twice (both backends shareapi_joband the same SQLite file).
Django-data parity for the shared DB + media tree:
slugifyreimplemented (NFKD + ASCII-fold) to match Django exactly, so image paths dedupe across both backends.- Image filenames keyed by
sha1(url_path)[:10], matching Django'shashlib.sha1. - Microsecond (6-frac-digit) timestamps; app-level CASCADE emulation.
merge_flavorsre-homes ratings (conflict resolution by comment length thencreated_at), re-points replies/notifications, inheritsexternal_id, in a txn.sync_flavorsis a full port including the Syrup special-case (the single bundle product's variants expand into individualFlavorrows 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
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 ./data/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 — data/db.sqlite3 (WAL on) and
data/media/ — and survives image rebuilds. Keep them.
Caveats / parity TODO
created_atetc. are emitted as ISO-8601 with 6 fractional digits +Z. DRF (Pythonisoformat) omits the fraction when microseconds are zero — verify against live responses if a client parses strictly.- The flavor
searchaction approximates DRF'sSearchFilterwith word-levelLIKEmatching + the category-keyword extraction fromservices/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 marksratingread-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_ratelimitonly 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)
Auth issuance (login/refresh)— done (PBKDF2 verify + SimpleJWT minting- cookie views + refresh blacklist).
Ratings/replies write + notifications (mentions parsing)— done (CRUD + feed/recent/reply, mention notifications, CASCADE emulation).Users/social, tickets/support— done (account lifecycle, follow graph, profile comments, notifications, tickets; SMTP email; avatar resize; account/ticket cascade).Admin + jobs— done (AdminViewSet+ aBackgroundJobtrait/registry /runner, an in-process interval scheduler replacing Celery beat, all 5 management commands incl. fullsync_flavorsShopify port).