20 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 6 jobs:sync_flavors,sync_reviews,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.- CLI escape hatch:
holy_backend_rs --run-job <name>runs one job to completion and exits without starting the server, for one-off operational runs (a first backfill, a re-sync after a data fix). It bypasses the scheduler, so it's safe on an instance that doesn't ownapi_job— just don't run a job that is already running elsewhere. - 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). It also records each flavor's Shopifyvariant_ids, whichsync_reviewsneeds.
Shop reviews (sync_reviews) — no Django equivalent
Pulls the public reviews.io timeline for the Holy store and keeps the product
reviews whose sku (a Shopify variant id) resolves to a flavor we track,
excluding the packs/merch category. About a quarter of product reviews map; the
rest are mix boxes, shakers and regional bundles that say nothing about a single
flavor. Newest-first crawl: deep on the first run, then stops after three
consecutive pages with nothing new (typically 2–3 requests), 250 ms apart.
Privacy: the upstream payload carries the reviewer's name, city and review
text; none of it is stored. The external_review row keeps only a salted hash of
the upstream reviewer id, the flavor, the 1–5 rating and the date. The hash exists
solely so the recommender can distinguish "one buyer liked both of these" from
"two unrelated buyers". This is a ratings signal, not a mirror of their reviews.
How the recommender uses it (src/recommend.rs) — deliberately not as extra
rows in the rating matrix, since the feed is 1–5 stars, heavily inflated (72% are
5★) and mostly single-review reviewers whose zero variance the correlation maths
discards anyway. Instead:
- Informed prior in the popularity ranking: a flavor's community score is
shrunk toward the shop consensus for that flavor (recentred onto the 1–10 scale)
rather than toward the flat catalog mean. Prior strength is capped, so a solid
community verdict always outweighs it. Flavors with no community ratings at all
become candidates for the first time, tagged
reason: "shop". - Co-occurrence layer in
/flavors/{id}/similar/: cosine over "which anonymous buyers liked both", blended with the community's adjusted cosine by confidence. Meaningful even when every rating involved was 5★. A negative community correlation vetoes the pair outright.
Aggregates are cached in-process for 10 minutes (src/reviews.rs) and invalidated
by the job; scanning the whole table per request would be far too expensive.
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).