Files
holy-flavor-rater/backend-rs
2026-06-16 13:39:41 +02:00
..
2026-06-16 13:39:41 +02:00
2026-06-09 15:07:04 +02:00
2026-06-15 15:48:34 +02:00
2026-06-09 15:07:04 +02:00
2026-06-09 15:07:04 +02:00
2026-06-15 15:48:34 +02:00
2026-06-15 17:08:46 +02:00

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

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).