22 KiB
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_KEYas Django (Authorization: Beareroraccess_tokencookie). 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:
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 to match Django; point DATABASE_URL at the shared db
cargo run # listens on BIND_ADDR (default 0.0.0.0:8001)
SECRET_KEY must equal Django's, or JWTs minted by Django won't verify here.
Strangler proxy (nginx)
The live routing is in frontend/nginx.conf (copied into the holy-frontend
image; shared proxy headers live in frontend/proxy_params.conf). Two upstreams
are declared — django_backend (holy-backend:8000) and rust_backend
(holy-rust:8001) — and each /api/<group>/ location has its own proxy_pass.
Cutting a group over is a one-line edit (django_backend → rust_backend)
followed by a reload; rollback is the reverse edit. Because both backends share
one SQLite DB, one media dir, and the same SECRET_KEY, either can serve any
request and tokens stay valid across a flip.
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, matching Django's REMOTE_ADDR).
Production cutover runbook (seamless, reversible)
Goal: migrate a running Django deployment to Rust with no downtime and a
per-step rollback. Everything hinges on the shared substrate already in place —
same db.sqlite3 (WAL on), same media dir, same SECRET_KEY — so a request can
be served by either backend interchangeably.
Invariant (do not violate): exactly one scheduler may own the api_job
table at any time. Django's beat owns it until Phase 3; Rust's in-process
scheduler (ENABLE_SCHEDULER) is off until then. Never both.
Phase 0 — Deploy Rust in shadow (no traffic)
- Set
SECRET_KEYin.envto the exact value Django already uses (else existing JWTs break on cutover). Optionally setFLOWER_BASIC_AUTH. docker compose up -d --build backend-rs— brings upholy-rustbeside Django.ENABLE_SCHEDULERdefaults to false; it only reads the DB.- Smoke-test directly:
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/and spot-check a few endpoints against it. nginx still sends all prod traffic to Django. Rollback:docker compose stop backend-rs.
Phase 1 — Reads
- In
frontend/nginx.conf, flipcategories,flavors,bannerstorust_backend. Rebuild/reload the frontend (docker compose up -d --build frontend, ordocker exec holy-frontend nginx -s reloadif you bind-mount the conf). Watchdocker logs holy-rust+ error rates. Rollback: flip back, reload.
Phase 2 — Auth + writes
- Flip
auth,token(legacy alias),ratings,replies,users,notifications,tickets. JWTs and cookies are cross-valid, so in-flight sessions survive. Reload. Rollback: flip back, reload.
Phase 3 — Admin + jobs (the careful one)
- Flip
admin-customtorust_backend, reload. - Stop Django's scheduler/workers:
docker compose stop beat worker flower. Now nobody schedules jobs. - Hand scheduling to Rust: set
RUST_ENABLE_SCHEDULER=truein.env, thendocker compose up -d backend-rs. Confirm the log linejob scheduler ENABLED. Single owner throughout → no double-run. Rollback: setRUST_ENABLE_SCHEDULER=false+ recreatebackend-rs, thendocker compose start beat worker.
Phase 4 — Retire Django
- Point the catch-all
location /api/atrust_backend(covers/api/schematoo — Rust serves OpenAPI + Swagger). Reload. Django now receives only/admin/+/static/(its admin site)./media/is already served straight from nginx (the:romount), independent of either backend. - Decide the Django admin site's fate. The app's own admin UI uses
/api/admin-custom/, so the Django/admin/site is usually unneeded:- Keeping it: leave the
backendcontainer up for/admin/+/static/. - Dropping it:
docker compose stop backend, delete the/admin/+/static/locations from nginx.
- Keeping it: leave the
- Once Django is fully off,
redis(only a Celery broker + Django cache — Rust uses neither) can be stopped too:docker compose stop redis. Eventually removebackend,worker,beat,flower,redisfrom compose.
Notes / caveats
- Rate-limit counters reset at cutover — Django's
django_ratelimitlives in Redis; Rust's throttle is in-process. Acceptable (windows are minutes/hours). - Token blacklist is shared (both use
token_blacklist_*tables), so logout / refresh-rotation stays consistent across a flip. - Reloads: the frontend image bakes the conf, so a conf change needs
docker compose up -d --build frontend. For instant reloads during cutover, bind-mount./frontend/nginx.conf:/etc/nginx/conf.d/default.conftemporarily and usenginx -s reload. - Keep
backend/db.sqlite3+backend/media/(host bind mounts) as the canonical data across all of this — neither is recreated by rebuilds.
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). When Rust owns jobs in prod, disable the Celerybeat/workercontainers to avoid double-execution.
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 it covers the Django side too) so readers don't block behind the
writer under the shared-DB strangler; 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).