rust backend + migration

This commit is contained in:
nvrl committed 2026-06-09 15:07:04 +02:00
1 parent 0bdd7cedb0
commit 61f0aadc70
70 files changed
+13707 -37

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
# Django → Rust migration runbook (seamless, reversible)
Migrate the running Django backend to the Rust backend (`backend-rs/`) with
**zero downtime** and a **per-step rollback**. Endpoint groups cut over one at a
time behind nginx; either backend can serve any request, so rollback is just the
reverse edit and data never forks.
There is a driver script that performs every step below:
`scripts/cutover.sh` (run `scripts/cutover.sh` with no args for the menu). Do the
steps manually or let the script do them — both edit the same files.
---
## Why it's safe
Both backends share one substrate, so a request is serviceable by either:
| Shared thing | How |
|---|---|
| Database | same `backend/db.sqlite3` (WAL on), bind-mounted into both containers |
| Media | same `backend/media/`; nginx serves `/media/` straight from the mount (`:ro`) |
| Auth | same `SECRET_KEY` → SimpleJWT HS256 tokens verify on both; cookies cross-valid |
| Token blacklist | shared `token_blacklist_*` tables → logout/refresh consistent across a flip |
**The one invariant — never violate:** exactly **one** scheduler may own the
`api_job` table at a time. Django's Celery `beat` owns it until Phase 3; the Rust
in-process scheduler (`ENABLE_SCHEDULER`) stays **false** until then. Never both,
or scheduled jobs fire twice.
---
## Prerequisites
1. **`SECRET_KEY` in `.env` must equal Django's exact value.** If it differs, all
existing JWTs break the instant you cut over auth. Verify before starting.
2. Back up first: `bash scripts/backup.sh` (snapshots SQLite + media).
3. Optionally enable **live nginx reloads** so flips don't rebuild the frontend
image. Add to the `frontend` service in `docker-compose.yml`:
```yaml
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf
- ./frontend/proxy_params.conf:/etc/nginx/proxy_params.conf
```
then `docker compose up -d frontend`. Without this, each phase rebuilds the
frontend image (`docker compose up -d --build frontend`) — slower, but still
no API downtime. (`scripts/cutover.sh dev-mount` prints these lines.)
---
## Phase 0 — Deploy Rust in shadow (no traffic)
Bring up `holy-rust` beside Django. It only reads; nginx still sends all traffic
to Django.
```bash
docker compose up -d --build backend-rs # or: scripts/cutover.sh phase0
```
Smoke-test it directly, bypassing nginx:
```bash
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/ # or: scripts/cutover.sh smoke
```
Spot-check a few endpoints against `holy-rust:8001` and compare to Django.
**Rollback:** `docker compose stop backend-rs`.
---
## Phase 1 — Reads
Lowest risk: read-only, no auth side effects.
Flip these locations in `frontend/nginx.conf` from `django_backend` →
`rust_backend`, then reload:
- `/api/categories/`
- `/api/flavors/`
- `/api/banners/`
```bash
scripts/cutover.sh phase1
```
Watch `docker logs holy-rust` and your error rate for a few minutes.
**Rollback:** `scripts/cutover.sh rollback 1`.
---
## Phase 2 — Auth + writes
JWTs and cookies are cross-valid, so logged-in sessions survive the flip.
Flip:
- `/api/auth/`
- `= /api/token/` (legacy JWT alias)
- `/api/ratings/`
- `/api/replies/`
- `/api/users/`
- `/api/notifications/`
- `/api/tickets/`
```bash
scripts/cutover.sh phase2
```
**Rollback:** `scripts/cutover.sh rollback 2`.
---
## Phase 3 — Admin + jobs (the careful one)
This is the only step that touches the scheduler invariant. Do it in order.
1. Flip `/api/admin-custom/` → `rust_backend`, reload.
2. **Stop Django's scheduler/workers** so nobody schedules:
```bash
docker compose stop beat worker flower
```
3. **Hand scheduling to Rust:** set `RUST_ENABLE_SCHEDULER=true` in `.env`, then
recreate the Rust container:
```bash
docker compose up -d backend-rs
```
4. Confirm single ownership:
```bash
docker logs holy-rust | grep "job scheduler ENABLED"
```
```bash
scripts/cutover.sh phase3 # does 1-4, prompts before the handover
```
At no point are both schedulers on → no double-run.
**Rollback:** `scripts/cutover.sh rollback 3` — sets `RUST_ENABLE_SCHEDULER=false`,
recreates `backend-rs`, restarts `beat worker flower`, flips admin back to Django.
---
## Phase 4 — Retire Django
Point the catch-all at Rust. Rust also serves OpenAPI + Swagger, so `/api/schema`
goes with it.
1. Flip `location /api/` and `/api/schema` → `rust_backend`, reload:
```bash
scripts/cutover.sh phase4
```
Django now receives only `/admin/` + `/static/` (its own admin site). `/media/`
is already served straight from nginx, independent of either backend.
2. Decide the Django admin site's fate. The app's admin UI uses
`/api/admin-custom/` (now on Rust), so Django `/admin/` is usually unneeded:
- **Keep it:** leave `backend` up for `/admin/` + `/static/`.
- **Drop it:** `docker compose stop backend`, then delete the `/admin/` and
`/static/` location blocks from `nginx.conf` and reload.
3. Once Django is fully off, stop Redis (only a Celery broker + Django cache —
Rust uses neither):
```bash
docker compose stop redis
```
4. Eventually remove `backend`, `worker`, `beat`, `flower`, `redis` from
`docker-compose.yml`.
**Rollback:** `scripts/cutover.sh rollback 4` (before removing services).
---
## Verify anytime
```bash
scripts/cutover.sh status # current routing per group + scheduler ownership
```
---
## Notes / caveats
- **Rate-limit counters reset at cutover.** Django's `django_ratelimit` lives in
Redis; Rust's throttle is in-process. Acceptable — windows are minutes/hours.
- **Reloads:** if the conf is baked into the image (default), a flip rebuilds the
frontend image. Bind-mount the conf (see Prerequisites) for instant
`nginx -s reload`. The script auto-detects which mode you're in.
- **Canonical data:** `backend/db.sqlite3` + `backend/media/` are host bind
mounts — never recreated by rebuilds. Keep them; they're the source of truth
the whole time.
- The same content lives in `backend-rs/README.md` ("Production cutover runbook")
as the in-tree reference.
+4
View File
@@ -0,0 +1,4 @@
/target
.env
.git
*.md
+45
View File
@@ -0,0 +1,45 @@
# Path to the SAME sqlite file Django uses (strangler: both backends share one DB).
# Relative paths resolve from the process CWD; absolute is safest.
DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc
# MUST equal Django's SECRET_KEY so SimpleJWT tokens verify across both backends.
SECRET_KEY=change-me-to-match-django
# Where this service binds.
BIND_ADDR=0.0.0.0:8001
# Media URL prefix Django serves under (matches MEDIA_URL = "media/").
MEDIA_URL=media/
# Filesystem root for uploaded media (avatars). Must point at the SAME directory
# Django uses (MEDIA_ROOT) so both backends read/write the same files.
MEDIA_ROOT=../backend/media
# Base URL of the frontend, used to build email links (verify-email, reset).
# Matches Django's FRONTEND_URL.
FRONTEND_URL=http://localhost:5173
# --- Email (lettre) -------------------------------------------------------
# Leave EMAIL_HOST unset to log emails to the console (dev). Set it to enable
# real SMTP. These mirror Django's EMAIL_* settings.
#EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
#EMAIL_HOST_USER=noreply@example.com
#EMAIL_HOST_PASSWORD=change-me
# Accept self-signed SMTP certs (mirrors api.email_backend.InsecureEmailBackend).
EMAIL_SKIP_CERT_VERIFICATION=false
# From address; falls back to EMAIL_HOST_USER then noreply@localhost.
#DEFAULT_FROM_EMAIL=noreply@example.com
# Set `Secure` on auth cookies (true in prod, like Django's JWT_AUTH_COOKIE_SECURE).
JWT_AUTH_COOKIE_SECURE=false
# In-process job scheduler (replaces Celery beat). MUST stay false while
# Django's beat container is still running — otherwise scheduled jobs fire twice
# against the shared api_job table. Set true ONLY after stopping Django beat.
ENABLE_SCHEDULER=false
# tracing filter
RUST_LOG=info,holy_backend_rs=debug,sea_orm=warn
+2
View File
@@ -0,0 +1,2 @@
/target
.env
+4123
View File
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
[package]
name = "holy_backend_rs"
version = "0.1.0"
edition = "2021"
description = "Rust port of the Holy Flavor Rater backend (strangler migration from Django)."
[dependencies]
axum = { version = "0.8", features = ["macros", "multipart"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = [
"cors",
"trace",
"fs",
"catch-panic",
"set-header",
"timeout",
] }
sea-orm = { version = "1.1", features = [
"sqlx-sqlite",
"runtime-tokio-rustls",
"macros",
"with-chrono",
"with-json",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = "9"
# Django-compatible password verification (pbkdf2_sha256$...) + token ids.
pbkdf2 = "0.12"
sha2 = "0.10"
base64 = "0.22"
subtle = "2"
uuid = { version = "1", features = ["v4"] }
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
tokio-cron-scheduler = "0.13"
# Dynamic dispatch for the Job trait (registry of `Arc<dyn Job>`).
async-trait = "0.1"
# sync_flavors: fetch the Shopify catalog + download product images.
reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls",
"json",
] }
# Bounded-concurrency image downloads in sync_flavors.
futures = "0.3"
# backup_db: gzip tarball of the sqlite snapshot + media tree.
tar = "0.4"
flate2 = "1"
# Django-accurate slugify (NFKD normalize + ASCII fold) for category/image slugs.
unicode-normalization = "0.1"
# Image filenames are keyed by sha1 of the URL path (parity with Django's
# hashlib.sha1) so downloads dedupe against the shared media directory.
sha1 = "0.10"
# SMTP email (parity with Django's send_mail / InsecureEmailBackend).
lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls-tls",
"smtp-transport",
"builder",
] }
# Avatar resize (parity with the PIL thumbnail-to-256-JPEG step in User.save).
image = { version = "0.25", default-features = false, features = [
"jpeg",
"png",
"gif",
"webp",
] }
[profile.release]
opt-level = 3
lto = "thin"
+51
View File
@@ -0,0 +1,51 @@
# --- Build stage ------------------------------------------------------------
# Pin the toolchain image; the crate is edition 2021 with no nightly features.
FROM rust:1-slim-bookworm AS build
WORKDIR /build
# reqwest uses rustls (no OpenSSL), but pkg-config/ca-certs are still handy for
# the build; sqlite is bundled via libsqlite3-sys, no system lib needed.
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Cache dependencies separately from source: copy manifests, build a stub, then
# copy real sources. A source-only change then reuses the dependency layer.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release \
&& rm -rf src
# Real sources (assets/ carries the embedded common-passwords list).
COPY src ./src
COPY assets ./assets
# Touch so cargo sees main.rs as newer than the stub build above.
RUN touch src/main.rs && cargo build --release \
&& strip target/release/holy_backend_rs
# --- Runtime stage ----------------------------------------------------------
FROM debian:bookworm-slim
# curl for the container healthcheck; ca-certificates for outbound HTTPS
# (Shopify sync, SMTP over TLS).
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Match the Django image's app user (UID/GID 1000) so the shared bind-mounted
# db.sqlite3 + media/ stay writable by both backends.
ARG APP_UID=1000
ARG APP_GID=1000
RUN groupadd -g ${APP_GID} app \
&& useradd -u ${APP_UID} -g app -m -s /bin/bash app
WORKDIR /app
COPY --from=build /build/target/release/holy_backend_rs /usr/local/bin/holy_backend_rs
USER app
EXPOSE 8001
# Paths point at the shared bind mounts wired in docker-compose. SECRET_KEY,
# DATABASE_URL, ENABLE_SCHEDULER, EMAIL_* etc. come from the environment.
ENTRYPOINT ["/usr/local/bin/holy_backend_rs"]
+384
View File
@@ -0,0 +1,384 @@
# 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 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)
1. Set `SECRET_KEY` in `.env` to the **exact** value Django already uses (else
existing JWTs break on cutover). Optionally set `FLOWER_BASIC_AUTH`.
2. `docker compose up -d --build backend-rs` — brings up `holy-rust` beside
Django. `ENABLE_SCHEDULER` defaults to false; it only reads the DB.
3. 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
4. In `frontend/nginx.conf`, flip `categories`, `flavors`, `banners` to
`rust_backend`. Rebuild/reload the frontend (`docker compose up -d --build
frontend`, or `docker exec holy-frontend nginx -s reload` if you bind-mount the
conf). Watch `docker logs holy-rust` + error rates. **Rollback:** flip back, reload.
### Phase 2 — Auth + writes
5. 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)
6. Flip `admin-custom` to `rust_backend`, reload.
7. **Stop Django's scheduler/workers:** `docker compose stop beat worker flower`.
Now nobody schedules jobs.
8. Hand scheduling to Rust: set `RUST_ENABLE_SCHEDULER=true` in `.env`, then
`docker compose up -d backend-rs`. Confirm the log line
`job scheduler ENABLED`. Single owner throughout → no double-run.
**Rollback:** set `RUST_ENABLE_SCHEDULER=false` + recreate `backend-rs`, then
`docker compose start beat worker`.
### Phase 4 — Retire Django
9. Point the catch-all `location /api/` at `rust_backend` (covers `/api/schema`
too — Rust serves OpenAPI + Swagger). Reload. Django now receives only
`/admin/` + `/static/` (its admin site). `/media/` is already served straight
from nginx (the `:ro` mount), independent of either backend.
10. 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 `backend` container up for `/admin/` + `/static/`.
- Dropping it: `docker compose stop backend`, delete the `/admin/` + `/static/`
locations from nginx.
11. Once Django is fully off, `redis` (only a Celery broker + Django cache — Rust
uses neither) can be stopped too: `docker compose stop redis`. Eventually
remove `backend`, `worker`, `beat`, `flower`, `redis` from compose.
### Notes / caveats
- **Rate-limit counters reset at cutover** — Django's `django_ratelimit` lives 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.conf` temporarily and
use `nginx -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_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). When Rust
owns jobs in prod, disable the Celery `beat`/`worker` containers 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)
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).
```
+128
View File
@@ -0,0 +1,128 @@
//! SimpleJWT-compatible token verification.
//!
//! Django's SimpleJWT signs access tokens with HS256 keyed on `SECRET_KEY`.
//! The claim set is `{ token_type, exp, iat, jti, user_id }`. We verify the
//! signature + `exp` and require `token_type == "access"`. No issuance happens
//! here yet — login/refresh stay on Django until that slice is ported.
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use sea_orm::EntityTrait;
use serde::Deserialize;
use crate::entities::prelude::User;
use crate::entities::user;
use crate::error::ApiError;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct Claims {
pub user_id: i32,
pub token_type: String,
pub exp: usize,
}
/// The authenticated user id, or `None` for anonymous requests. Used by
/// `IsAuthenticatedOrReadOnly`-style endpoints (e.g. `user_rating`).
#[derive(Debug, Clone, Copy)]
pub struct OptionalUser(pub Option<i32>);
/// A required authenticated user id; rejects with 401 when absent/invalid.
#[derive(Debug, Clone, Copy)]
pub struct AuthUser(pub i32);
fn token_from_parts(parts: &Parts) -> Option<String> {
if let Some(auth) = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
if let Some(rest) = auth.strip_prefix("Bearer ") {
return Some(rest.trim().to_string());
}
}
// httpOnly cookie fallback (JWT_AUTH_COOKIE_ACCESS = "access_token").
let cookie = parts
.headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())?;
for kv in cookie.split(';') {
let kv = kv.trim();
if let Some(val) = kv.strip_prefix("access_token=") {
return Some(val.to_string());
}
}
None
}
fn verify(token: &str, secret: &str) -> Option<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_aud = false;
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.ok()?;
if data.claims.token_type != "access" {
return None;
}
Some(data.claims)
}
impl FromRequestParts<AppState> for OptionalUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let uid = token_from_parts(parts)
.and_then(|t| verify(&t, &state.config.secret_key))
.map(|c| c.user_id);
Ok(OptionalUser(uid))
}
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
token_from_parts(parts)
.and_then(|t| verify(&t, &state.config.secret_key))
.map(|c| AuthUser(c.user_id))
.ok_or(ApiError::Unauthorized)
}
}
/// A staff user (DRF `IsAdminUser` = `is_staff`). 401 if unauthenticated, 403 if
/// authenticated but not staff. Carries the loaded user so admin handlers reuse it.
pub struct AdminUser(pub user::Model);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let uid = token_from_parts(parts)
.and_then(|t| verify(&t, &state.config.secret_key))
.map(|c| c.user_id)
.ok_or(ApiError::Unauthorized)?;
let user = User::find_by_id(uid)
.one(&state.db)
.await?
.ok_or(ApiError::Unauthorized)?;
if !user.is_staff {
return Err(ApiError::Forbidden(
"You do not have permission to perform this action.".into(),
));
}
Ok(AdminUser(user))
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Avatar upload handling, mirroring the PIL step in Django's `User.save`:
//! images larger than 256px or not already JPEG are thumbnailed to fit 256×256
//! and re-encoded as JPEG (quality 90). Smaller JPEGs are stored as-is.
//!
//! The processed file is written under `<MEDIA_ROOT>/avatars/` and the returned
//! value is the DB-stored relative path (e.g. `avatars/<uuid>.jpg`), matching
//! Django's `ImageField(upload_to="avatars/")`.
use std::path::Path;
use image::codecs::jpeg::JpegEncoder;
use image::{DynamicImage, ImageFormat};
const MAX_DIM: u32 = 256;
/// Process raw upload bytes and persist the avatar. Returns the relative media
/// path to store in `User.avatar`.
pub fn process_and_save(
media_root: &str,
original_name: &str,
data: &[u8],
) -> Result<String, String> {
let format = image::guess_format(data).ok();
let img = image::load_from_memory(data).map_err(|e| format!("invalid image: {e}"))?;
let oversized = img.width() > MAX_DIM || img.height() > MAX_DIM;
let is_jpeg = format == Some(ImageFormat::Jpeg);
let (bytes, ext): (Vec<u8>, &str) = if oversized || !is_jpeg {
// thumbnail() preserves aspect ratio, fitting within MAX_DIM×MAX_DIM.
let resized = if oversized {
img.thumbnail(MAX_DIM, MAX_DIM)
} else {
img
};
let rgb = DynamicImage::ImageRgb8(resized.to_rgb8());
let mut buf = Vec::new();
rgb.write_with_encoder(JpegEncoder::new_with_quality(&mut buf, 90))
.map_err(|e| format!("encode jpeg: {e}"))?;
(buf, "jpg")
} else {
let ext = Path::new(original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("jpg");
(data.to_vec(), if ext.is_empty() { "jpg" } else { ext })
};
let dir = Path::new(media_root).join("avatars");
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir avatars: {e}"))?;
let filename = format!("{}.{}", uuid::Uuid::new_v4().simple(), ext);
let full = dir.join(&filename);
std::fs::write(&full, &bytes).map_err(|e| format!("write avatar: {e}"))?;
Ok(format!("avatars/{filename}"))
}
+89
View File
@@ -0,0 +1,89 @@
//! Refresh-token blacklist, sharing SimpleJWT's tables
//! (`token_blacklist_outstandingtoken` / `token_blacklist_blacklistedtoken`).
//!
//! Replicates `rest_framework_simplejwt`'s blacklist app so rotation works
//! across both backends:
//! - login records an outstanding token (`RefreshToken.for_user`),
//! - refresh blacklists the incoming token before issuing a rotated pair
//! (`BLACKLIST_AFTER_ROTATION`),
//! - logout blacklists the cookie's refresh token,
//! - verification rejects blacklisted jtis.
use chrono::Utc;
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, Value};
use crate::datetime::db_datetime;
use crate::error::ApiResult;
/// Has this jti been blacklisted?
pub async fn is_blacklisted(db: &DatabaseConnection, jti: &str) -> ApiResult<bool> {
let stmt = Statement::from_sql_and_values(
db.get_database_backend(),
"SELECT 1 FROM token_blacklist_blacklistedtoken b \
JOIN token_blacklist_outstandingtoken o ON b.token_id = o.id \
WHERE o.jti = ? LIMIT 1",
[jti.into()],
);
Ok(db.query_one(stmt).await?.is_some())
}
/// Record an outstanding refresh token (mirrors `RefreshToken.for_user`).
/// `jti` is unique, so a re-record is a no-op.
pub async fn record_outstanding(
db: &DatabaseConnection,
user_id: i32,
jti: &str,
token: &str,
created_at: chrono::NaiveDateTime,
expires_at: chrono::NaiveDateTime,
) -> ApiResult<()> {
let stmt = Statement::from_sql_and_values(
db.get_database_backend(),
"INSERT OR IGNORE INTO token_blacklist_outstandingtoken \
(token, created_at, expires_at, user_id, jti) VALUES (?, ?, ?, ?, ?)",
[
token.into(),
db_datetime(&created_at).into(),
db_datetime(&expires_at).into(),
user_id.into(),
jti.into(),
],
);
db.execute(stmt).await?;
Ok(())
}
/// Blacklist a refresh token (mirrors `BlacklistMixin.blacklist`): ensure an
/// outstanding row exists for the jti, then a blacklist row referencing it.
pub async fn blacklist(
db: &DatabaseConnection,
user_id: i32,
jti: &str,
token: &str,
expires_at: chrono::NaiveDateTime,
) -> ApiResult<()> {
// `created_at` is unknown for a token we didn't mint here; Django's
// get_or_create leaves it NULL in that case, so omit it.
let upsert = Statement::from_sql_and_values(
db.get_database_backend(),
"INSERT OR IGNORE INTO token_blacklist_outstandingtoken \
(token, created_at, expires_at, user_id, jti) VALUES (?, NULL, ?, ?, ?)",
[
token.into(),
db_datetime(&expires_at).into(),
user_id.into(),
jti.into(),
],
);
db.execute(upsert).await?;
let now = db_datetime(&Utc::now().naive_utc());
let bl = Statement::from_sql_and_values(
db.get_database_backend(),
"INSERT OR IGNORE INTO token_blacklist_blacklistedtoken (blacklisted_at, token_id) \
SELECT ?, id FROM token_blacklist_outstandingtoken WHERE jti = ?",
[Value::from(now), jti.into()],
);
db.execute(bl).await?;
Ok(())
}
+129
View File
@@ -0,0 +1,129 @@
use std::env;
/// Runtime configuration sourced from the environment.
///
/// `secret_key` MUST match Django's `SECRET_KEY` — SimpleJWT signs access
/// tokens with HS256 keyed on it, and this service verifies the same tokens
/// during the strangler rollout.
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub secret_key: String,
pub bind_addr: String,
pub media_url: String,
/// Filesystem root where uploaded media (avatars) are written. Mirrors
/// Django's MEDIA_ROOT; both backends must point at the same directory.
pub media_root: String,
/// `Secure` flag on auth cookies (JWT_AUTH_COOKIE_SECURE; true in prod).
pub jwt_cookie_secure: bool,
/// Public site URL used to build verification/reset links (Django FRONTEND_URL).
pub frontend_url: String,
/// Exact origins allowed to send credentialed CORS requests. Mirrors Django's
/// `CORS_ALLOWED_ORIGINS` (which derives from `DOMAIN`); credentialed CORS
/// can't use a wildcard, so this is an explicit allow-list.
pub cors_allowed_origins: Vec<String>,
pub email: EmailConfig,
/// Directory of banner JSON configs (Django's `backend/banners/`).
pub banners_dir: String,
/// Directory of legacy flavor JSON dumps (repo-root `legacy/`).
pub legacy_dir: String,
/// Output directory for database/media backups (Django's `backend/backups/`).
pub backup_dir: String,
/// Whether the in-process job scheduler runs. MUST be false while Django's
/// Celery `beat` still owns scheduling — otherwise jobs fire twice against
/// the shared `api_job` table. Flip to true only after disabling Django beat.
pub enable_scheduler: bool,
}
impl Config {
/// Filesystem path of the SQLite database file, parsed out of `database_url`
/// (`sqlite://<path>?<params>`). Used by the backup job.
pub fn sqlite_path(&self) -> String {
self.database_url
.strip_prefix("sqlite://")
.unwrap_or(&self.database_url)
.split('?')
.next()
.unwrap_or("")
.to_string()
}
}
/// SMTP settings mirroring Django's `EMAIL_*` env vars. When `host` is `None`,
/// email is logged to the console instead of sent (Django's console backend).
#[derive(Clone, Debug)]
pub struct EmailConfig {
pub host: Option<String>,
pub port: u16,
pub use_tls: bool,
pub use_ssl: bool,
pub host_user: Option<String>,
pub host_password: Option<String>,
pub skip_cert_verification: bool,
pub default_from: String,
}
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(default)
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
database_url: env::var("DATABASE_URL")
// `mode=rw` (not `rwc`): the shared DB is created/owned by Django
// and must already exist. `rwc` would silently create an empty DB
// if the path were wrong, masking a misconfiguration.
.unwrap_or_else(|_| "sqlite://../backend/db.sqlite3?mode=rw".to_string()),
secret_key: env::var("SECRET_KEY")
.map_err(|_| anyhow::anyhow!("SECRET_KEY must be set (match Django's)"))?,
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8001".to_string()),
media_url: env::var("MEDIA_URL").unwrap_or_else(|_| "media/".to_string()),
media_root: env::var("MEDIA_ROOT").unwrap_or_else(|_| "../backend/media".to_string()),
jwt_cookie_secure: env_bool("JWT_AUTH_COOKIE_SECURE", false),
frontend_url: env::var("FRONTEND_URL")
.unwrap_or_else(|_| "http://localhost:5173".to_string())
.trim_end_matches('/')
.to_string(),
cors_allowed_origins: {
let domain = env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string());
match env::var("CORS_ALLOWED_ORIGINS") {
Ok(v) if !v.trim().is_empty() => v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
_ => vec![
format!("https://{domain}"),
"http://localhost:5173".to_string(),
"http://127.0.0.1:5173".to_string(),
],
}
},
email: EmailConfig {
host: env::var("EMAIL_HOST").ok().filter(|s| !s.is_empty()),
port: env::var("EMAIL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(587),
use_tls: env_bool("EMAIL_USE_TLS", false),
use_ssl: env_bool("EMAIL_USE_SSL", false),
host_user: env::var("EMAIL_HOST_USER").ok().filter(|s| !s.is_empty()),
host_password: env::var("EMAIL_HOST_PASSWORD").ok().filter(|s| !s.is_empty()),
skip_cert_verification: env_bool("EMAIL_SKIP_CERT_VERIFICATION", false),
default_from: env::var("DEFAULT_FROM_EMAIL")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| env::var("EMAIL_HOST_USER").ok().filter(|s| !s.is_empty()))
.unwrap_or_else(|| "noreply@localhost".to_string()),
},
banners_dir: env::var("BANNERS_DIR")
.unwrap_or_else(|_| "../backend/banners".to_string()),
legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".to_string()),
backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../backend/backups".to_string()),
// Default OFF: a fresh deploy runs beside Django, which still owns the
// scheduler. Operator flips ENABLE_SCHEDULER=true only after stopping
// Django beat (see README cutover steps).
enable_scheduler: env_bool("ENABLE_SCHEDULER", false),
})
}
}
+40
View File
@@ -0,0 +1,40 @@
//! httpOnly JWT cookie strings, matching Django's `set_jwt_cookies` /
//! `clear_jwt_cookies`. Names and attributes mirror `settings.JWT_AUTH_COOKIE_*`
//! so the same cookies are interchangeable between the Django and Rust backends.
pub const COOKIE_ACCESS: &str = "access_token";
pub const COOKIE_REFRESH: &str = "refresh_token";
const COOKIE_PATH: &str = "/";
const SAMESITE: &str = "Lax"; // JWT_AUTH_COOKIE_SAMESITE
/// Build a `Set-Cookie` value for an auth cookie with the given max-age (secs).
pub fn set_cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String {
let mut s = format!(
"{name}={value}; Max-Age={max_age}; Path={COOKIE_PATH}; HttpOnly; SameSite={SAMESITE}"
);
if secure {
s.push_str("; Secure");
}
s
}
/// Build a `Set-Cookie` value that clears the cookie (empty value, expired).
pub fn clear_cookie(name: &str, secure: bool) -> String {
let mut s = format!(
"{name}=; Max-Age=0; Path={COOKIE_PATH}; HttpOnly; SameSite={SAMESITE}; \
expires=Thu, 01 Jan 1970 00:00:00 GMT"
);
if secure {
s.push_str("; Secure");
}
s
}
/// Read a cookie value out of a `Cookie:` header string.
pub fn cookie_value(cookie_header: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
cookie_header
.split(';')
.map(str::trim)
.find_map(|kv| kv.strip_prefix(&prefix).map(str::to_string))
}
+27
View File
@@ -0,0 +1,27 @@
use chrono::{NaiveDateTime, Timelike, Utc};
/// Current UTC time truncated to microsecond precision. Django stores datetimes
/// with 6 fractional digits; SeaORM/sqlx would otherwise persist nanoseconds,
/// so truncate before inserting to keep the on-disk format identical.
pub fn now_micros() -> NaiveDateTime {
let now = Utc::now().naive_utc();
now.with_nanosecond(now.nanosecond() / 1000 * 1000).unwrap_or(now)
}
/// Format a naive-UTC datetime exactly as DRF serializes an aware UTC
/// `DateTimeField`: ISO-8601 with microseconds and a `Z` suffix, e.g.
/// `2026-04-11T00:37:59.496766Z`.
///
/// Caveat: DRF (Python `isoformat`) omits the fractional part when microseconds
/// are zero. We always emit 6 digits; verify against live responses if any
/// client parses strictly.
pub fn drf_iso(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
}
/// Format a naive-UTC datetime the way Django writes datetimes into SQLite:
/// `2026-04-11 00:37:59.496766` (space separator, 6 fractional digits, no `Z`).
/// Used when inserting token-blacklist rows so they match Django's ORM writes.
pub fn db_datetime(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
}
+40
View File
@@ -0,0 +1,40 @@
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement,
};
use std::time::Duration;
pub async fn connect(database_url: &str) -> anyhow::Result<DatabaseConnection> {
let mut opts = ConnectOptions::new(database_url.to_owned());
opts.max_connections(10)
.min_connections(1)
.connect_timeout(Duration::from_secs(8))
.sqlx_logging(false);
// sqlx already applies `busy_timeout=5s` and `foreign_keys=ON` per connection
// by default (matching Django's 5s SQLite timeout), so SQLITE_BUSY waits up to
// 5s before erroring rather than failing instantly.
let conn = Database::connect(opts).await?;
// Enable WAL so the Rust and Django processes that share this SQLite file
// during the strangler don't serialize readers behind the writer. In the
// default rollback-journal mode a writer takes an exclusive lock that blocks
// all readers, which surfaces as SQLITE_BUSY → 500s under concurrent load.
// WAL lets readers proceed during a write. It is a persistent, database-level
// setting stored in the file header, so this single statement covers every
// pooled connection and the Django side too. Best-effort: switching journal
// mode needs a brief exclusive lock that can't wait on busy_timeout, so log
// and continue (the 5s busy_timeout still applies) if a writer holds it now.
match conn
.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"PRAGMA journal_mode=WAL;",
))
.await
{
Ok(_) => tracing::info!("sqlite journal_mode=WAL enabled"),
Err(e) => {
tracing::warn!("could not enable WAL (continuing in default journal mode): {e}")
}
}
Ok(conn)
}
+184
View File
@@ -0,0 +1,184 @@
//! Output DTOs mirroring the DRF serializers field-for-field (names + order).
//! `image` is write-only in DRF, so it is intentionally absent here.
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct CategoryOut {
pub id: i32,
pub name: String,
pub slug: String,
}
#[derive(Serialize, ToSchema)]
pub struct ReplyOut {
pub id: i32,
/// StringRelatedField → username.
pub user: String,
pub rating: i32,
pub text: String,
pub created_at: String,
}
#[derive(Serialize, ToSchema)]
pub struct RatingOut {
pub id: i32,
pub user: String,
pub user_id: i32,
pub user_avatar: Option<String>,
pub flavor: i32,
pub flavor_name: String,
pub flavor_image: Option<String>,
pub category_name: String,
pub category_slug: String,
pub is_available: bool,
pub is_legacy: bool,
pub score: i32,
pub comment: Option<String>,
pub created_at: String,
pub replies: Vec<ReplyOut>,
}
#[derive(Serialize, ToSchema)]
pub struct FlavorOut {
pub id: i32,
pub name: String,
pub category: i32,
pub category_name: String,
pub category_slug: String,
pub description: String,
pub average_rating: Option<f64>,
pub followed_average_rating: Option<f64>,
pub user_rating: Option<i32>,
pub ratings: Vec<RatingOut>,
pub image_url: Option<String>,
pub image_urls: Vec<String>,
pub is_available: bool,
pub is_legacy: bool,
pub shop_url: Option<String>,
/// Keys "1".."10" → count. BTreeMap keeps stable ordering.
pub rating_distribution: BTreeMap<String, i64>,
}
/// Search-action result item (FlavorViewSet.search).
#[derive(Serialize, ToSchema)]
pub struct SearchHit {
pub id: i32,
pub name: String,
#[serde(rename = "type")]
pub kind: String,
pub subtitle: String,
pub image_url: Option<String>,
pub slug: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct UserOut {
pub id: i32,
pub username: String,
pub email: String,
pub theme: String,
pub language: String,
pub drawer_anchor: String,
pub avatar: Option<String>,
pub following_count: i64,
pub followers_count: i64,
pub is_following: bool,
pub unread_notifications_count: i64,
pub is_superuser: bool,
pub selected_banner: Option<i32>,
pub selected_banner_slug: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ProfileCommentOut {
pub id: i32,
pub author_username: String,
pub author_avatar: Option<String>,
pub text: String,
pub created_at: String,
}
#[derive(Serialize, ToSchema)]
pub struct NotificationOut {
pub id: i32,
pub actor_username: String,
pub actor_avatar: Option<String>,
pub notification_type: String,
pub rating: Option<i32>,
pub reply: Option<i32>,
pub ticket: Option<i32>,
pub profile_comment: Option<i32>,
pub profile_owner_username: Option<String>,
pub is_read: bool,
pub created_at: String,
pub flavor_name: Option<String>,
pub flavor_id: Option<i32>,
}
#[derive(Serialize, ToSchema)]
pub struct TicketMessageOut {
pub id: i32,
pub username: String,
pub text: String,
pub created_at: String,
pub is_admin: bool,
}
#[derive(Serialize, ToSchema)]
pub struct TicketOut {
pub id: i32,
pub user: i32,
pub username: String,
pub user_email: String,
pub user_avatar: Option<String>,
pub subject: String,
pub description: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub messages: Vec<TicketMessageOut>,
}
/// Custom payload of the `users/profile/<username>` action (not a serializer).
#[derive(Serialize, ToSchema)]
pub struct PublicProfileOut {
pub id: i32,
pub username: String,
pub theme: String,
pub avatar: Option<String>,
pub following_count: i64,
pub followers_count: i64,
pub is_following: bool,
pub ratings: Vec<RatingOut>,
pub comments: Vec<ProfileCommentOut>,
pub followers: Vec<UserOut>,
pub following: Vec<UserOut>,
}
/// Custom payload of the `users/dashboard` action.
#[derive(Serialize, ToSchema)]
pub struct DashboardOut {
pub user: UserOut,
pub rated_count: i64,
pub missing_count: i64,
pub missing_flavors: Vec<FlavorOut>,
pub my_ratings: Vec<RatingOut>,
}
#[derive(Serialize, ToSchema)]
pub struct BannerOut {
pub id: i32,
pub name: String,
pub slug: String,
pub description: String,
pub is_active: bool,
pub is_enabled: bool,
pub settings: Value,
pub schema: Value,
pub created_at: String,
pub updated_at: String,
}
+87
View File
@@ -0,0 +1,87 @@
//! SMTP email, mirroring Django's `send_mail` + `InsecureEmailBackend`.
//!
//! Config maps the `EMAIL_*` env vars (see `config::EmailConfig`):
//! - `EMAIL_USE_SSL` → implicit TLS (SMTPS, usually port 465)
//! - `EMAIL_USE_TLS` → STARTTLS
//! - neither → plaintext
//! - `EMAIL_SKIP_CERT_VERIFICATION` → accept self-signed certs (the whole point
//! of Django's `InsecureEmailBackend`).
//!
//! When `EMAIL_HOST` is unset the message is logged instead of sent — the
//! equivalent of Django's console email backend in dev.
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::config::EmailConfig;
/// Send a plain-text email to one or more recipients. Returns an error if the
/// message can't be built or the transport fails (parity with `fail_silently=False`).
pub async fn send_mail(
cfg: &EmailConfig,
subject: &str,
body: &str,
recipients: &[String],
) -> Result<(), String> {
let Some(host) = cfg.host.as_deref() else {
// Console backend equivalent.
tracing::info!(
to = ?recipients,
subject = %subject,
"email (console backend, not sent):\n{body}"
);
return Ok(());
};
let from: Mailbox = cfg
.default_from
.parse()
.map_err(|e| format!("invalid DEFAULT_FROM_EMAIL: {e}"))?;
let mut builder = Message::builder().from(from).subject(subject);
for r in recipients {
let mbox: Mailbox = r.parse().map_err(|e| format!("invalid recipient {r}: {e}"))?;
builder = builder.to(mbox);
}
let message = builder
.body(body.to_string())
.map_err(|e| format!("build email: {e}"))?;
let mut transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(cfg.port);
if cfg.use_ssl || cfg.use_tls {
let tls = TlsParameters::builder(host.to_string())
.dangerous_accept_invalid_certs(cfg.skip_cert_verification)
.dangerous_accept_invalid_hostnames(cfg.skip_cert_verification)
.build()
.map_err(|e| format!("tls config: {e}"))?;
transport = transport.tls(if cfg.use_ssl {
Tls::Wrapper(tls)
} else {
Tls::Required(tls)
});
}
if let (Some(u), Some(p)) = (cfg.host_user.as_ref(), cfg.host_password.as_ref()) {
transport = transport.credentials(Credentials::new(u.clone(), p.clone()));
}
transport
.build()
.send(message)
.await
.map_err(|e| format!("smtp send: {e}"))?;
Ok(())
}
/// Fire-and-forget send, mirroring Django's `send_email_task.delay(...)` where
/// the view does not await delivery. Logs on failure.
pub fn spawn_mail(cfg: EmailConfig, subject: String, body: String, recipients: Vec<String>) {
tokio::spawn(async move {
if let Err(e) = send_mail(&cfg, &subject, &body, &recipients).await {
tracing::error!(error = %e, subject = %subject, "background email send failed");
}
});
}
+25
View File
@@ -0,0 +1,25 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "api_banner")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub slug: String,
#[sea_orm(column_type = "Text")]
pub description: String,
pub is_active: bool,
pub is_enabled: bool,
#[sea_orm(column_type = "Json")]
pub settings: Json,
#[sea_orm(column_type = "Json")]
pub schema: Json,
pub created_at: ChronoDateTime,
pub updated_at: ChronoDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+24
View File
@@ -0,0 +1,24 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_category")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub slug: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::flavor::Entity")]
Flavor,
}
impl Related<super::flavor::Entity> for Entity {
fn to() -> RelationDef {
Relation::Flavor.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+54
View File
@@ -0,0 +1,54 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "api_flavor")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub category_id: i32,
pub name: String,
#[sea_orm(column_type = "Text")]
pub description: String,
pub image_url: Option<String>,
/// Django JSONField (default list), stored as TEXT.
#[sea_orm(column_type = "Json")]
pub image_urls: Option<Json>,
pub image: Option<String>,
/// Django JSONField (default list), stored as TEXT.
#[sea_orm(column_type = "Json")]
pub local_image_paths: Option<Json>,
pub main_image_path: Option<String>,
pub shop_url: Option<String>,
pub is_available: bool,
pub is_legacy: bool,
pub external_id: Option<i64>,
/// Django stores naive UTC text ("YYYY-MM-DD HH:MM:SS.ffffff"); we treat it
/// as UTC when formatting to DRF's ISO-8601 `...Z` output.
pub created_at: ChronoDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::category::Entity",
from = "Column::CategoryId",
to = "super::category::Column::Id"
)]
Category,
#[sea_orm(has_many = "super::rating::Entity")]
Rating,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl Related<super::rating::Entity> for Entity {
fn to() -> RelationDef {
Relation::Rating.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+26
View File
@@ -0,0 +1,26 @@
use sea_orm::entity::prelude::*;
/// `api_job` — an execution log + schedule for the background jobs (formerly
/// Celery tasks driven by management commands). `next_run` is a stored column
/// in Django but derived in the serializer; we leave it mapped yet unused and
/// compute the served value from `last_run + interval_hours` (see routes/admin).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "api_job")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub status: String,
pub last_run: Option<ChronoDateTime>,
pub next_run: Option<ChronoDateTime>,
pub interval_hours: i32,
#[sea_orm(column_type = "Text")]
pub last_output: String,
#[sea_orm(column_type = "Text")]
pub error_message: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+36
View File
@@ -0,0 +1,36 @@
//! SeaORM entities mapped 1:1 onto Django's existing `api_*` tables.
//!
//! Column names and types mirror Django's schema exactly — both backends read
//! and write the same SQLite file during the strangler migration, so drift here
//! corrupts data. When adding a field, confirm it against the live table with
//! `PRAGMA table_info(api_<name>)`.
pub mod banner;
pub mod category;
pub mod flavor;
pub mod job;
pub mod notification;
pub mod profile_comment;
pub mod rating;
pub mod reply;
pub mod system_config;
pub mod ticket;
pub mod ticket_message;
pub mod user;
pub mod user_ip;
pub mod prelude {
pub use super::banner::Entity as Banner;
pub use super::category::Entity as Category;
pub use super::flavor::Entity as Flavor;
pub use super::job::Entity as Job;
pub use super::notification::Entity as Notification;
pub use super::profile_comment::Entity as ProfileComment;
pub use super::rating::Entity as Rating;
pub use super::reply::Entity as Reply;
pub use super::system_config::Entity as SystemConfig;
pub use super::ticket::Entity as Ticket;
pub use super::ticket_message::Entity as TicketMessage;
pub use super::user::Entity as User;
pub use super::user_ip::Entity as UserIp;
}
+26
View File
@@ -0,0 +1,26 @@
use sea_orm::entity::prelude::*;
/// Maps Django's `api_notification` (see `api/models/social.py`). Only `mention`
/// and `reply` rows are written by the ported endpoints; `recipient`/`actor`
/// are required, the per-object foreign keys are all nullable.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_notification")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub is_read: bool,
pub created_at: ChronoDateTime,
pub actor_id: i32,
pub rating_id: Option<i32>,
pub recipient_id: i32,
pub reply_id: Option<i32>,
pub ticket_id: Option<i32>,
#[sea_orm(column_type = "String(StringLen::N(20))")]
pub notification_type: String,
pub profile_comment_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;
/// Maps Django's `api_profilecomment` (comments left on a user's profile).
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_profilecomment")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub text: String,
pub created_at: ChronoDateTime,
pub author_id: i32,
pub profile_owner_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+52
View File
@@ -0,0 +1,52 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "api_rating")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub flavor_id: i32,
pub score: i32,
#[sea_orm(column_type = "Text", nullable)]
pub comment: Option<String>,
pub created_at: ChronoDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::flavor::Entity",
from = "Column::FlavorId",
to = "super::flavor::Column::Id"
)]
Flavor,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
#[sea_orm(has_many = "super::reply::Entity")]
Reply,
}
impl Related<super::flavor::Entity> for Entity {
fn to() -> RelationDef {
Relation::Flavor.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::reply::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reply.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+43
View File
@@ -0,0 +1,43 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_reply")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub rating_id: i32,
#[sea_orm(column_type = "Text")]
pub text: String,
pub created_at: ChronoDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::rating::Entity",
from = "Column::RatingId",
to = "super::rating::Column::Id"
)]
Rating,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::rating::Entity> for Entity {
fn to() -> RelationDef {
Relation::Rating.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+19
View File
@@ -0,0 +1,19 @@
use sea_orm::entity::prelude::*;
/// `api_systemconfig` — singleton (pk always 1) of UI-editable app settings.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "api_systemconfig")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub site_name: String,
pub maintenance_mode: bool,
pub allow_new_signups: bool,
pub require_email_verification: bool,
pub updated_at: ChronoDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+22
View File
@@ -0,0 +1,22 @@
use sea_orm::entity::prelude::*;
/// Maps Django's `api_ticket` (support tickets).
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_ticket")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(column_type = "String(StringLen::N(200))")]
pub subject: String,
pub description: String,
#[sea_orm(column_type = "String(StringLen::N(20))")]
pub status: String,
pub created_at: ChronoDateTime,
pub updated_at: ChronoDateTime,
pub user_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+18
View File
@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;
/// Maps Django's `api_ticketmessage` (messages within a support ticket).
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_ticketmessage")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub text: String,
pub created_at: ChronoDateTime,
pub ticket_id: i32,
pub user_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+43
View File
@@ -0,0 +1,43 @@
use sea_orm::entity::prelude::*;
/// Subset of Django's `api_user` (AbstractUser + custom fields). Only the
/// columns the Rust endpoints currently read are mapped; add more as endpoints
/// are ported. `password` is intentionally included so auth can be ported later
/// (Django PBKDF2 verification), but is never serialized.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "api_user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub password: String,
pub last_login: Option<ChronoDateTime>,
pub is_superuser: bool,
pub username: String,
pub first_name: String,
pub last_name: String,
pub email: String,
pub is_staff: bool,
pub is_active: bool,
pub date_joined: ChronoDateTime,
pub theme: String,
pub email_confirmation_code: Option<String>,
pub pending_email: Option<String>,
pub avatar: Option<String>,
pub language: String,
pub selected_banner_id: Option<i32>,
pub drawer_anchor: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::rating::Entity")]
Rating,
}
impl Related<super::rating::Entity> for Entity {
fn to() -> RelationDef {
Relation::Rating.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+18
View File
@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;
/// Maps Django's `api_userip` — per-user login IP log (unique on user+ip).
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_userip")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(column_type = "String(StringLen::N(39))")]
pub ip_address: String,
pub last_login: ChronoDateTime,
pub user_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+86
View File
@@ -0,0 +1,86 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Unified API error. Serializes to a DRF-ish `{ "detail": "..." }` body so the
/// existing frontend's error handling keeps working unchanged.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("not found")]
NotFound,
#[error("authentication credentials were not provided")]
Unauthorized,
#[error("database error: {0}")]
Db(#[from] sea_orm::DbErr),
#[error("{0}")]
BadRequest(String),
/// 403 with a DRF `{ "detail": ... }` body (e.g. object-permission denial).
#[error("{0}")]
Forbidden(String),
/// 400 whose body is an arbitrary JSON value, used to mirror DRF serializer
/// validation errors verbatim (field-keyed maps or top-level lists).
#[error("validation error")]
Validation(serde_json::Value),
/// Arbitrary status + JSON body, for DRF actions that return custom
/// `{ "error": ... }` / `{ "status": ... }` payloads with a non-400 status
/// (e.g. 404 or 500 from the user account actions).
#[error("error")]
JsonStatus(StatusCode, serde_json::Value),
/// 429 Too Many Requests. Replaces django_ratelimit's block (which DRF
/// rendered as a 403); a real `429` + `Retry-After` is the professional
/// behaviour and the frontend's generic error fallbacks handle it.
#[error("too many requests")]
RateLimited { retry_after: u64 },
#[error("internal error")]
Internal,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
// Validation carries its own JSON body shape; handle before the
// `{ "detail": ... }` path below.
if let ApiError::Validation(body) = &self {
return (StatusCode::BAD_REQUEST, Json(body.clone())).into_response();
}
if let ApiError::JsonStatus(status, body) = &self {
return (*status, Json(body.clone())).into_response();
}
if let ApiError::RateLimited { retry_after } = &self {
let msg = "Too many requests. Please try again later.";
let mut resp = (
StatusCode::TOO_MANY_REQUESTS,
// `error` for the account flows' `data.error` handling, `detail`
// for DRF-style consumers.
Json(json!({ "detail": msg, "error": msg })),
)
.into_response();
if let Ok(hv) = axum::http::HeaderValue::from_str(&retry_after.to_string()) {
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, hv);
}
return resp;
}
let (status, detail) = match &self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "Not found.".to_string()),
ApiError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"Authentication credentials were not provided.".to_string(),
),
ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
ApiError::Validation(_) | ApiError::JsonStatus(..) | ApiError::RateLimited { .. } => {
unreachable!("handled above")
}
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
ApiError::Db(e) => {
tracing::error!(error = %e, "database error");
(StatusCode::INTERNAL_SERVER_ERROR, "Server error.".to_string())
}
ApiError::Internal => {
(StatusCode::INTERNAL_SERVER_ERROR, "Server error.".to_string())
}
};
(status, Json(json!({ "detail": detail }))).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiError>;
+111
View File
@@ -0,0 +1,111 @@
//! Port of `backup_db --full` — snapshot the SQLite database with `VACUUM INTO`
//! (a consistent online backup), bundle it with the media tree into a gzip
//! tarball, drop the raw snapshot, and prune backups older than the retention
//! window. The Celery task always ran `--full` with the default 7-day retention.
use std::fs::{self, File};
use std::path::Path;
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use flate2::write::GzEncoder;
use flate2::Compression;
use sea_orm::{ConnectionTrait, DbBackend, Statement};
use crate::state::AppState;
use super::BackgroundJob;
const RETENTION_DAYS: u64 = 7;
pub struct BackupDb;
#[async_trait]
impl BackgroundJob for BackupDb {
fn name(&self) -> &'static str {
"backup_db"
}
fn display_name(&self) -> &'static str {
"Database Backup"
}
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
let db_path = state.config.sqlite_path();
if db_path.is_empty() {
anyhow::bail!("could not determine sqlite path from DATABASE_URL");
}
let backup_dir = state.config.backup_dir.clone();
let media_root = state.config.media_root.clone();
fs::create_dir_all(&backup_dir)?;
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let db_backup_path = format!("{backup_dir}/db_backup_{timestamp}.sqlite3");
let mut log: Vec<String> = Vec::new();
log.push(format!("Cloning database {db_path}..."));
// VACUUM INTO produces a consistent snapshot even under concurrent
// access — the SQLite-native equivalent of Python's `sqlite3 .backup`.
let escaped = db_backup_path.replace('\'', "''");
state
.db
.execute(Statement::from_string(
DbBackend::Sqlite,
format!("VACUUM INTO '{escaped}'"),
))
.await?;
log.push(format!("DB backup created: {db_backup_path}"));
// Archive + retention is blocking IO; keep it off the async runtime.
let archive_path = format!("{backup_dir}/full_backup_{timestamp}.tar.gz");
log.push(format!("Creating full archive at {archive_path}..."));
let archive_log = tokio::task::spawn_blocking(move || {
build_archive(&db_backup_path, &media_root, &archive_path)?;
// Drop the raw snapshot now that it is bundled.
let _ = fs::remove_file(&db_backup_path);
prune_old(&backup_dir)
})
.await??;
log.push("Full archive created successfully.".to_string());
log.push(format!("Cleaning up backups older than {RETENTION_DAYS} days..."));
log.extend(archive_log);
Ok(log.join("\n"))
}
}
/// Build the gzip tarball: the snapshot as `db.sqlite3` + the media tree as `media`.
fn build_archive(db_backup_path: &str, media_root: &str, archive_path: &str) -> anyhow::Result<()> {
let tar_gz = File::create(archive_path)?;
let enc = GzEncoder::new(tar_gz, Compression::default());
let mut tar = tar::Builder::new(enc);
tar.append_path_with_name(db_backup_path, "db.sqlite3")?;
if Path::new(media_root).exists() {
tar.append_dir_all("media", media_root)?;
}
tar.into_inner()?.finish()?;
Ok(())
}
/// Delete files in `backup_dir` older than the retention window. Returns log lines.
fn prune_old(backup_dir: &str) -> anyhow::Result<Vec<String>> {
let mut log = Vec::new();
let cutoff = SystemTime::now() - Duration::from_secs(RETENTION_DAYS * 24 * 60 * 60);
for entry in fs::read_dir(backup_dir)?.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let modified = entry.metadata().and_then(|m| m.modified()).ok();
if let Some(mtime) = modified {
if mtime < cutoff {
let name = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
match fs::remove_file(&path) {
Ok(_) => log.push(format!(" -> Deleted old backup: {name}")),
Err(e) => log.push(format!(" -> Failed to delete {name}: {e}")),
}
}
}
}
Ok(log)
}
+106
View File
@@ -0,0 +1,106 @@
//! Port of `cleanup_duplicates` — merge flavors with the same normalized name
//! within a category. Always runs in "apply" mode with suffix normalization
//! (the Celery task passed no flags).
use async_trait::async_trait;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use crate::entities::flavor;
use crate::entities::prelude::{Category, Flavor};
use crate::merge::merge_flavors;
use crate::state::AppState;
use super::BackgroundJob;
/// Trailing form-factor phrases stripped during name normalization. Each is a
/// sequence of whitespace-separated tokens; the match requires a preceding
/// token (Django's leading `\s+`), so a name that is *only* the phrase is kept.
const SUFFIXES: &[&[&str]] = &[
&["energy"],
&["hydration"],
&["iced", "tea"],
&["(legacy)"],
];
/// Lowercase, trim, and strip the known trailing suffixes (Django's
/// `normalize_name`). The result is a grouping key, not a display value.
fn normalize_name(name: &str) -> String {
let mut tokens: Vec<String> = name.split_whitespace().map(|t| t.to_lowercase()).collect();
for suffix in SUFFIXES {
if tokens.len() > suffix.len() && tokens[tokens.len() - suffix.len()..] == suffix[..] {
tokens.truncate(tokens.len() - suffix.len());
}
}
tokens.join(" ")
}
pub struct CleanupDuplicates;
#[async_trait]
impl BackgroundJob for CleanupDuplicates {
fn name(&self) -> &'static str {
"cleanup_duplicates"
}
fn display_name(&self) -> &'static str {
"Cleanup Duplicate Flavors"
}
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
let db = &state.db;
let mut log: Vec<String> = Vec::new();
let mut total_merged = 0usize;
let categories = Category::find().all(db).await?;
for cat in categories {
let flavors = Flavor::find()
.filter(flavor::Column::CategoryId.eq(cat.id))
.all(db)
.await?;
// Group by normalized name (preserve insertion order is not needed;
// each group is sorted before merging).
let mut groups: std::collections::HashMap<String, Vec<flavor::Model>> =
std::collections::HashMap::new();
for f in flavors {
groups.entry(normalize_name(&f.name)).or_default().push(f);
}
for (_, mut group) in groups {
if group.len() <= 1 {
continue;
}
// Keep the catalog row (has external_id), oldest on ties.
group.sort_by(|a, b| {
(a.external_id.is_none(), a.created_at)
.cmp(&(b.external_id.is_none(), b.created_at))
});
let mut keep = group.remove(0);
let names: Vec<String> = group
.iter()
.map(|o| format!("'{}' (ID {})", o.name, o.id))
.collect();
log.push(format!(
"Merging {} into '{}' (Category: {}, ID: {})",
names.join(", "),
keep.name,
cat.name,
keep.id
));
for other in group {
merge_flavors(db, &keep, &other).await?;
// Re-fetch: merge may have inherited `external_id` onto keep,
// which the next iteration must see (external_id is unique).
keep = Flavor::find_by_id(keep.id)
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("kept flavor vanished mid-merge"))?;
total_merged += 1;
}
}
}
log.push(format!("Finished cleaning up {total_merged} duplicates."));
Ok(log.join("\n"))
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Shared image-download helpers for the catalog jobs. Filenames are keyed by a
//! sha1 of the URL path and a whitelisted extension, matching Django so both
//! backends dedupe against the same `media/` files.
use std::path::Path;
use sha1::{Digest, Sha1};
/// Whitelisted image extension from a URL path, defaulting to `png` (Django's
/// `_ext_for`).
pub fn ext_for(url: &str) -> String {
let path = reqwest::Url::parse(url)
.map(|u| u.path().to_string())
.unwrap_or_else(|_| url.to_string());
let ext = path
.rsplit_once('.')
.map(|(_, e)| e.to_lowercase())
.unwrap_or_default();
match ext.as_str() {
"jpg" | "jpeg" | "png" | "webp" | "gif" => ext,
_ => "png".to_string(),
}
}
/// First 10 hex chars of sha1(url path) (Django's `_hash_for`).
pub fn hash_for(url: &str) -> String {
let path = reqwest::Url::parse(url)
.map(|u| u.path().to_string())
.unwrap_or_else(|_| url.to_string());
let digest = Sha1::digest(path.as_bytes());
let hex = hex_lower(&digest);
hex[..10].to_string()
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
/// Download `url` to `abs_path`, skipping if a non-empty file already exists.
/// Returns `true` if the file is present afterwards (Django's
/// `download_image_to_path` returning the rel path vs None).
pub async fn download_image(client: &reqwest::Client, url: &str, abs_path: &Path) -> bool {
if let Ok(meta) = std::fs::metadata(abs_path) {
if meta.len() > 0 {
return true;
}
}
if let Some(parent) = abs_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
return false;
}
}
let resp = match client.get(url).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!(url, error = %e, "image download failed");
return false;
}
};
if !resp.status().is_success() {
tracing::warn!(url, status = %resp.status(), "image download non-200");
return false;
}
match resp.bytes().await {
Ok(bytes) => std::fs::write(abs_path, &bytes).is_ok(),
Err(e) => {
tracing::warn!(url, error = %e, "image body read failed");
false
}
}
}
/// HTTP client with the same `User-Agent` Django sent (some CDNs gate on it).
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent("Mozilla/5.0")
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_default()
}
+160
View File
@@ -0,0 +1,160 @@
//! Background jobs — the Rust port of Django's management commands + Celery
//! tasks. Each job implements [`BackgroundJob`]; [`run_job`] wraps execution
//! with the `api_job` row lifecycle (status / last_run / output), mirroring the
//! old `_run_command_job` helper. A lightweight in-process scheduler
//! ([`spawn_scheduler`]) replaces Celery beat.
//!
//! Design notes vs. the Django original:
//! - Jobs return their full output log as a `String` on success. On failure the
//! error is recorded in `error_message` (Django also kept partial stdout in
//! `last_output`; ours stays empty on failure).
//! - The job *names* and *display names* match Django's `Job.JOB_TYPES` exactly,
//! so the admin UI (which keys off them) is unchanged.
mod backup_db;
mod cleanup_duplicates;
mod download;
mod scheduler;
mod seed_banners;
mod seed_legacy;
mod sync_flavors;
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter};
use unicode_normalization::UnicodeNormalization;
use crate::entities::prelude::Job as JobEntity;
use crate::entities::job;
use crate::state::AppState;
pub use scheduler::spawn_scheduler;
/// A runnable background job. Implementors are pure work units; the `api_job`
/// row bookkeeping lives in [`run_job`].
#[async_trait]
pub trait BackgroundJob: Send + Sync {
/// Stable identifier, matching a `Job.JOB_TYPES` key (e.g. `"sync_flavors"`).
fn name(&self) -> &'static str;
/// Human label, matching the Django `get_name_display()` value.
fn display_name(&self) -> &'static str;
/// Do the work, returning a human-readable output log on success.
async fn run(&self, state: &AppState) -> anyhow::Result<String>;
}
/// All registered jobs. Order is irrelevant (the admin list re-sorts by name).
pub fn registry() -> Vec<Arc<dyn BackgroundJob>> {
vec![
Arc::new(sync_flavors::SyncFlavors),
Arc::new(cleanup_duplicates::CleanupDuplicates),
Arc::new(backup_db::BackupDb),
Arc::new(seed_legacy::SeedLegacy),
Arc::new(seed_banners::SeedBanners),
]
}
/// Look up a job by its `name`.
pub fn lookup(name: &str) -> Option<Arc<dyn BackgroundJob>> {
registry().into_iter().find(|j| j.name() == name)
}
/// Fetch the `api_job` row for `name`, creating a default one if absent
/// (mirrors `Job.objects.get_or_create(name=...)`).
pub async fn get_or_create_row(db: &DatabaseConnection, name: &str) -> Result<job::Model, DbErr> {
if let Some(j) = JobEntity::find()
.filter(job::Column::Name.eq(name))
.one(db)
.await?
{
return Ok(j);
}
job::ActiveModel {
name: Set(name.to_string()),
status: Set("pending".into()),
interval_hours: Set(0),
last_output: Set(String::new()),
error_message: Set(String::new()),
..Default::default()
}
.insert(db)
.await
}
/// Run a job end-to-end, recording lifecycle state in its `api_job` row. Skips
/// if the same job is already running (concurrency guard in [`AppState`]). Safe
/// to `tokio::spawn` as fire-and-forget — errors are logged + persisted.
pub async fn run_job(state: &AppState, job: Arc<dyn BackgroundJob>) {
let name = job.name();
// Reserve the slot; bail if another run holds it.
if !state.running_jobs.lock().await.insert(name.to_string()) {
tracing::warn!(job = name, "job already running; skipping");
return;
}
if let Err(e) = run_inner(state, job.as_ref()).await {
tracing::error!(job = name, error = %e, "job lifecycle bookkeeping failed");
}
state.running_jobs.lock().await.remove(name);
}
async fn run_inner(state: &AppState, job: &dyn BackgroundJob) -> anyhow::Result<()> {
let db = &state.db;
let row = get_or_create_row(db, job.name()).await?;
let mut am: job::ActiveModel = row.into();
am.status = Set("running".into());
am.last_run = Set(Some(crate::datetime::now_micros()));
am.last_output = Set(String::new());
am.error_message = Set(String::new());
let row = am.update(db).await?;
tracing::info!(job = job.name(), "job started");
match job.run(state).await {
Ok(output) => {
let mut am: job::ActiveModel = row.into();
am.status = Set("completed".into());
am.last_output = Set(output);
am.error_message = Set(String::new());
am.update(db).await?;
tracing::info!(job = job.name(), "job completed");
}
Err(e) => {
let mut am: job::ActiveModel = row.into();
am.status = Set("failed".into());
am.error_message = Set(e.to_string());
am.update(db).await?;
tracing::error!(job = job.name(), error = %e, "job failed");
}
}
Ok(())
}
/// Django's `slugify`: NFKD-normalize, drop non-ASCII, lowercase, keep only
/// `[a-z0-9_-]` plus whitespace, collapse runs of spaces/hyphens to a single
/// `-`, and strip leading/trailing `-`/`_`. Matching this exactly keeps the
/// media image paths (and category slugs) identical to Django's so files and
/// rows are reused, not duplicated.
pub fn slugify(value: &str) -> String {
let ascii: String = value.nfkd().filter(|c| c.is_ascii()).collect::<String>().to_lowercase();
// Keep word chars, hyphen and whitespace; drop the rest.
let kept: String = ascii
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || c.is_whitespace())
.collect();
// Collapse [-\s]+ into a single '-'.
let mut out = String::with_capacity(kept.len());
let mut prev_dash = false;
for c in kept.chars() {
if c == '-' || c.is_whitespace() {
if !prev_dash {
out.push('-');
prev_dash = true;
}
} else {
out.push(c);
prev_dash = false;
}
}
out.trim_matches(|c| c == '-' || c == '_').to_string()
}
+65
View File
@@ -0,0 +1,65 @@
//! In-process interval scheduler replacing Celery beat. Every minute it checks
//! each job's `interval_hours` against its `last_run` and dispatches any that
//! are due. Schedules live entirely in the `api_job` rows (edited via the admin
//! `update_job_schedule` endpoint), so this is restart-safe: `last_run` persists
//! and survives process restarts.
//!
//! Strangler note: when this scheduler owns the jobs, the Python Celery `beat`
//! container must be disabled, or both will run the same jobs (double work).
use std::time::Duration;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use crate::entities::prelude::Job as JobEntity;
use crate::entities::job;
use crate::state::AppState;
const TICK: Duration = Duration::from_secs(60);
/// Spawn the scheduler loop. Returns immediately; the loop runs for the life of
/// the process.
pub fn spawn_scheduler(state: AppState) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(TICK);
loop {
tick.tick().await;
if let Err(e) = tick_once(&state).await {
tracing::error!(error = %e, "scheduler tick failed");
}
}
});
}
async fn tick_once(state: &AppState) -> anyhow::Result<()> {
for job in super::registry() {
let Some(row) = JobEntity::find()
.filter(job::Column::Name.eq(job.name()))
.one(&state.db)
.await?
else {
// No row yet → never configured via the admin UI → not scheduled.
continue;
};
if row.interval_hours <= 0 {
continue;
}
let due = match row.last_run {
None => true,
Some(last) => {
crate::datetime::now_micros() - last
>= chrono::Duration::hours(row.interval_hours as i64)
}
};
if !due {
continue;
}
if state.running_jobs.lock().await.contains(job.name()) {
continue;
}
tracing::info!(job = job.name(), interval_hours = row.interval_hours, "scheduling due job");
let st = state.clone();
tokio::spawn(async move { super::run_job(&st, job).await });
}
Ok(())
}
+156
View File
@@ -0,0 +1,156 @@
//! Port of `seed_banners` — load/sync banner configs from `banners/*.json`.
//!
//! Parity with Django's `Banner.save()` invariant: at most one active banner,
//! and if none is active the saved banner becomes active. New banners take their
//! `settings` from JSON but schema stays empty on first create (Django only
//! syncs `schema` on the update path); subsequent runs backfill it.
use std::fs;
use async_trait::async_trait;
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
};
use serde_json::{json, Value};
use crate::entities::banner;
use crate::entities::prelude::Banner;
use crate::state::AppState;
use super::BackgroundJob;
pub struct SeedBanners;
#[async_trait]
impl BackgroundJob for SeedBanners {
fn name(&self) -> &'static str {
"seed_banners"
}
fn display_name(&self) -> &'static str {
"Seed Banner Configurations"
}
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
let db = &state.db;
let dir = &state.config.banners_dir;
let mut log: Vec<String> = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => {
log.push(format!("Banners directory not found at {dir}"));
return Ok(log.join("\n"));
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
if let Err(e) = sync_one(db, &path, &mut log).await {
log.push(format!("Failed to sync banner from {filename}: {e}"));
}
}
Ok(log.join("\n"))
}
}
async fn sync_one(
db: &DatabaseConnection,
path: &std::path::Path,
log: &mut Vec<String>,
) -> anyhow::Result<()> {
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
let content = fs::read_to_string(path)?;
let data: Value = serde_json::from_str(&content)?;
let Some(slug) = data.get("slug").and_then(|v| v.as_str()) else {
return Ok(());
};
let existing = Banner::find()
.filter(banner::Column::Slug.eq(slug))
.one(db)
.await?;
match existing {
None => {
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(slug).to_string();
let description =
data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
let settings = data.get("settings").cloned().unwrap_or_else(|| json!({}));
// Mirror Banner.save(): become active when no other banner is active.
let no_active = active_count(db, None).await? == 0;
banner::ActiveModel {
name: Set(name),
slug: Set(slug.to_string()),
description: Set(description),
settings: Set(settings),
schema: Set(json!([])),
is_enabled: Set(true),
is_active: Set(no_active),
created_at: Set(crate::datetime::now_micros()),
updated_at: Set(crate::datetime::now_micros()),
..Default::default()
}
.insert(db)
.await?;
log.push(format!("Created new banner '{slug}' from {filename}"));
}
Some(banner) => {
let json_settings = data.get("settings").cloned().unwrap_or_else(|| json!({}));
let json_schema = data.get("schema").cloned().unwrap_or_else(|| json!([]));
let mut db_settings = banner.settings.as_object().cloned().unwrap_or_default();
let mut updated = false;
if let Some(obj) = json_settings.as_object() {
for (k, v) in obj {
if !db_settings.contains_key(k) {
db_settings.insert(k.clone(), v.clone());
updated = true;
}
}
}
let schema_changed = banner.schema != json_schema;
if schema_changed {
updated = true;
}
if updated {
let pk = banner.id;
let was_active = banner.is_active;
let mut am: banner::ActiveModel = banner.into();
am.settings = Set(Value::Object(db_settings));
am.schema = Set(json_schema);
am.updated_at = Set(crate::datetime::now_micros());
// Banner.save() invariant on every save.
if was_active {
Banner::update_many()
.col_expr(banner::Column::IsActive, sea_orm::sea_query::Expr::value(false))
.filter(banner::Column::IsActive.eq(true))
.filter(banner::Column::Id.ne(pk))
.exec(db)
.await?;
} else if active_count(db, Some(pk)).await? == 0 {
am.is_active = Set(true);
}
am.update(db).await?;
log.push(format!("Synced new settings for banner '{slug}' from {filename}"));
}
}
}
Ok(())
}
/// Count active banners, optionally excluding one pk.
async fn active_count(db: &DatabaseConnection, exclude: Option<i32>) -> anyhow::Result<u64> {
let mut q = Banner::find().filter(banner::Column::IsActive.eq(true));
if let Some(pk) = exclude {
q = q.filter(banner::Column::Id.ne(pk));
}
Ok(q.count(db).await?)
}
+225
View File
@@ -0,0 +1,225 @@
//! Port of `seed_legacy_flavors` — upsert retired ("legacy") flavors from the
//! JSON dumps in the `legacy/` directory. Existing *active* (non-legacy) flavors
//! are left untouched; legacy rows are created or refreshed and always marked
//! unavailable.
use std::fs;
use std::path::Path;
use async_trait::async_trait;
use sea_orm::sea_query::{Expr, Func};
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
};
use serde_json::{json, Value};
use crate::entities::prelude::{Category, Flavor};
use crate::entities::{category, flavor};
use crate::state::AppState;
use super::download::{download_image, ext_for, hash_for, http_client};
use super::{slugify, BackgroundJob};
const PACK_KEYWORDS: &[&str] =
&["bundle", "set", "box", "probe", "sample", "taster", "shaker", "starter"];
pub struct SeedLegacy;
#[async_trait]
impl BackgroundJob for SeedLegacy {
fn name(&self) -> &'static str {
"seed_legacy"
}
fn display_name(&self) -> &'static str {
"Seed Legacy Data"
}
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
let db = &state.db;
let media_root = &state.config.media_root;
let mut log: Vec<String> = Vec::new();
// Categories, matched by name (Django get_or_create(name=...)).
let energy = goc_category(db, "Energy", "energy").await?;
let hydration = goc_category(db, "Hydration", "hydration").await?;
let iced_tea = goc_category(db, "Iced Tea", "iced-tea").await?;
let milkshake = goc_category(db, "Milkshake", "milkshake").await?;
let packs = goc_category(db, "Packs and other", "packs-and-other").await?;
let legacy_dir = &state.config.legacy_dir;
if !Path::new(legacy_dir).exists() {
log.push("Legacy directory not found.".to_string());
return Ok(log.join("\n"));
}
let client = http_client();
let mut processed = 0usize;
for entry in fs::read_dir(legacy_dir)?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_lowercase();
// Category from filename.
let file_cat: Option<&category::Model> = if fname.contains("energy") {
Some(&energy)
} else if fname.contains("iced_tea") || fname.contains("iced-tea") {
Some(&iced_tea)
} else if fname.contains("hydration") {
Some(&hydration)
} else if fname.contains("milkshake") {
Some(&milkshake)
} else {
None
};
let content = fs::read_to_string(&path)?;
let items: Value = serde_json::from_str(&content)?;
let Some(arr) = items.as_array() else { continue };
for data in arr {
let Some(name) = data.get("Name").and_then(|v| v.as_str()) else {
continue;
};
let taste = pystr(data.get("Geschmack"));
let desc = pystr(data.get("Beschreibung"));
let status_text = pystr(data.get("Status"));
let image_url = data.get("Bild_URL").and_then(|v| v.as_str());
let full_desc =
format!("**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}");
// Final category: filename, else name/desc heuristics, else energy.
let cat: &category::Model = file_cat.unwrap_or_else(|| {
let nl = name.to_lowercase();
let dl = desc.to_lowercase();
if nl.contains("hydration") || dl.contains("hydration") {
&hydration
} else if nl.contains("iced tea") || nl.contains("eistee") {
&iced_tea
} else if nl.contains("milkshake") {
&milkshake
} else if PACK_KEYWORDS.iter().any(|k| nl.contains(k)) {
&packs
} else {
&energy
}
});
// Existing flavor by case-insensitive name within the category.
let existing = Flavor::find()
.filter(
Expr::expr(Func::lower(Expr::col(flavor::Column::Name)))
.eq(name.to_lowercase()),
)
.filter(flavor::Column::CategoryId.eq(cat.id))
.one(db)
.await?;
if let Some(ref f) = existing {
if !f.is_legacy {
log.push(format!("Skipping active flavor: {name}"));
continue;
}
}
// Resolve image first (so we can set local paths on insert).
let mut local_paths: Option<Vec<String>> = None;
if let Some(url) = image_url.filter(|s| !s.is_empty()) {
let rel = format!(
"flavors/legacy_{}/00_{}.{}",
slugify(name),
hash_for(url),
ext_for(url)
);
let abs = Path::new(media_root).join(&rel);
if download_image(&client, url, &abs).await {
local_paths = Some(vec![rel]);
log.push(format!(" -> Image for: {name}"));
}
}
let image_urls = match image_url.filter(|s| !s.is_empty()) {
Some(u) => json!([u]),
None => json!([]),
};
match existing {
Some(f) => {
log.push(format!("Updating legacy flavor: {name}"));
let main_in_local = match (&f.main_image_path, &local_paths) {
(Some(m), Some(lp)) => lp.contains(m),
_ => true,
};
let mut am: flavor::ActiveModel = f.into();
am.description = Set(full_desc);
am.is_available = Set(false);
if let Some(lp) = local_paths {
am.local_image_paths = Set(Some(json!(lp)));
if !main_in_local {
am.main_image_path = Set(None);
}
}
am.image_url = Set(image_url.map(String::from));
am.image_urls = Set(Some(image_urls));
am.update(db).await?;
}
None => {
log.push(format!("Adding new legacy flavor: {name}"));
flavor::ActiveModel {
name: Set(name.to_string()),
category_id: Set(cat.id),
is_legacy: Set(true),
external_id: Set(None),
description: Set(full_desc),
is_available: Set(false),
local_image_paths: Set(local_paths.map(|lp| json!(lp))),
image_url: Set(image_url.map(String::from)),
image_urls: Set(Some(image_urls)),
created_at: Set(crate::datetime::now_micros()),
..Default::default()
}
.insert(db)
.await?;
}
}
processed += 1;
}
}
log.push(format!("Finished! Processed {processed} legacy flavors."));
Ok(log.join("\n"))
}
}
/// Python `str(data.get(key))` semantics: missing/null becomes the literal
/// "None" (the f-string interpolation the Django command relied on).
fn pystr(v: Option<&Value>) -> String {
match v.and_then(|x| x.as_str()) {
Some(s) => s.to_string(),
None => "None".to_string(),
}
}
async fn goc_category(
db: &DatabaseConnection,
name: &str,
slug: &str,
) -> anyhow::Result<category::Model> {
if let Some(c) = Category::find()
.filter(category::Column::Name.eq(name))
.one(db)
.await?
{
return Ok(c);
}
Ok(category::ActiveModel {
name: Set(name.to_string()),
slug: Set(slug.to_string()),
..Default::default()
}
.insert(db)
.await?)
}
+540
View File
@@ -0,0 +1,540 @@
//! Port of `sync_flavors` — pull the Holy Energy Shopify catalog and upsert
//! flavors, categories and product images. Mirrors the Django command's
//! category resolution, the Syrup-bundle variant expansion special case, and the
//! "mark missing products unavailable" pass. Data-format parity matters: both
//! backends write the same shared SQLite + media files.
use std::collections::HashMap;
use std::path::Path;
use async_trait::async_trait;
use futures::StreamExt;
use sea_orm::sea_query::{Expr, Func};
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
};
use serde_json::{json, Value};
use crate::entities::prelude::{Category, Flavor};
use crate::entities::{category, flavor};
use crate::state::AppState;
use super::download::{download_image, ext_for, hash_for, http_client};
use super::{slugify, BackgroundJob};
const PRODUCTS_URL: &str = "https://weareholy.com/products.json?limit=250";
const DOWNLOAD_WORKERS: usize = 8;
const PACKS_SLUG: &str = "packs-and-other";
const PACKS_NAME: &str = "Packs and other";
const FORM_WORDS: &[&str] =
&["bundle", "sachet", "sachetbox", "bottle", "box", "pack", "set", "sachets"];
const NON_FLAVOR_TOKENS: &[&str] = &[
"value", "shaker", "merch", "sticker", "packaging", "material", "other", "items", "mixed",
"collector", "collector's", "free", "products", "naturalrabatt", "sample", "samplebox",
];
const PACK_TITLE_KEYWORDS: &[&str] = &[
"bundle", "set", "box", "probe", "sample", "taster", "starter", "collection", "probier",
];
const ACCESSORY_TITLE_KEYWORDS: &[&str] = &["bürste", "reinigung", "ersatzteil", "zubehör"];
const TAG_FAMILY: &[(&[&str], &str)] = &[
(&["holy energy", "energy"], "Energy"),
(&["holy hydration", "hydration"], "Hydration"),
(&["holy iced tea", "iced tea"], "Iced Tea"),
(&["milkshake"], "Milkshake"),
];
pub struct SyncFlavors;
#[async_trait]
impl BackgroundJob for SyncFlavors {
fn name(&self) -> &'static str {
"sync_flavors"
}
fn display_name(&self) -> &'static str {
"Sync Holy Flavors"
}
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
let db = &state.db;
let media_root = &state.config.media_root;
let client = http_client();
let mut log: Vec<String> = Vec::new();
log.push(format!("Fetching from {PRODUCTS_URL}..."));
// Fetch failures are reported in the output but do not fail the job
// (Django's command caught the error and returned).
let data: Value = match client.get(PRODUCTS_URL).send().await {
Ok(resp) => match resp.error_for_status() {
Ok(resp) => match resp.json().await {
Ok(v) => v,
Err(e) => {
log.push(format!("Failed to fetch data: {e}"));
return Ok(log.join("\n"));
}
},
Err(e) => {
log.push(format!("Failed to fetch data: {e}"));
return Ok(log.join("\n"));
}
},
Err(e) => {
log.push(format!("Failed to fetch data: {e}"));
return Ok(log.join("\n"));
}
};
let products = data.get("products").and_then(|v| v.as_array()).cloned().unwrap_or_default();
log.push(format!("Found {} products.", products.len()));
let packs = goc_category_by_slug_named(db, PACKS_SLUG, PACKS_NAME).await?;
let mut created = 0usize;
let mut updated = 0usize;
let mut synced_ids: Vec<i64> = Vec::new();
for p in &products {
let title = p.get("title").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
let tags = parse_tags(p);
let tags_lower: Vec<String> = tags.iter().map(|t| t.to_lowercase()).collect();
let title_lower = title.to_lowercase();
// Syrup ships as one article whose variants are the flavors.
if is_syrup_variant_product(p) {
let (c, u) = sync_syrup_variants(db, &client, media_root, p, &mut synced_ids).await?;
created += c;
updated += u;
continue;
}
let Some(cat) =
resolve_category(db, p, &title_lower, &tags_lower, &packs).await?
else {
continue;
};
let variants = p.get("variants").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let is_available = variants.iter().any(|v| v.get("available").and_then(|a| a.as_bool()).unwrap_or(false));
let images = p.get("images").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let image_url = images.first().and_then(|i| i.get("src")).and_then(|s| s.as_str()).map(String::from);
let image_urls: Vec<String> =
images.iter().filter_map(|i| i.get("src").and_then(|s| s.as_str()).map(String::from)).collect();
let description = clean_html(p.get("body_html").and_then(|v| v.as_str()).unwrap_or(""));
let shop_url = p.get("handle").and_then(|v| v.as_str()).map(shop_url_for);
let Some(pid) = p.get("id").and_then(|v| v.as_i64()) else {
continue;
};
let was_created = upsert_flavor(
db,
&client,
media_root,
pid,
&title,
cat.id,
&description,
shop_url,
is_available,
image_url,
&image_urls,
&pid.to_string(),
)
.await?;
if was_created {
created += 1;
} else {
updated += 1;
}
synced_ids.push(pid);
}
// Mark catalog flavors no longer present as unavailable.
let discontinued = if synced_ids.is_empty() {
0
} else {
Flavor::update_many()
.col_expr(flavor::Column::IsAvailable, Expr::value(false))
.filter(flavor::Column::ExternalId.is_not_null())
.filter(flavor::Column::ExternalId.is_not_in(synced_ids.clone()))
.exec(db)
.await?
.rows_affected
};
log.push(format!(
"Finished! Created: {created}, Updated: {updated}, Marked Unavailable: {discontinued}"
));
Ok(log.join("\n"))
}
}
fn shop_url_for(handle: &str) -> String {
format!("https://weareholy.com/products/{handle}")
}
/// Tags as a lowercase-able list; Shopify returns either an array or a CSV string.
fn parse_tags(p: &Value) -> Vec<String> {
match p.get("tags") {
Some(Value::Array(a)) => a.iter().filter_map(|t| t.as_str().map(String::from)).collect(),
Some(Value::String(s)) => s.split(',').map(|t| t.trim().to_string()).collect(),
_ => Vec::new(),
}
}
/// Strip HTML tags (Django's `clean_html`, regex `<.*?>`).
fn clean_html(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut in_tag = false;
for c in raw.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
out.trim().to_string()
}
/// Drink family from a Shopify `product_type` ("42 - Syrup Bottle" -> "Syrup"),
/// or `None` for non-flavor types. Mirrors `_family_from_product_type`.
fn family_from_product_type(product_type: &str) -> Option<String> {
if product_type.is_empty() {
return None;
}
let stripped = strip_type_prefix(product_type);
let stripped = stripped.trim();
if stripped.is_empty() {
return None;
}
let mut tokens: Vec<&str> = stripped.split_whitespace().collect();
if tokens.iter().any(|t| NON_FLAVOR_TOKENS.contains(&t.to_lowercase().as_str())) {
return None;
}
while tokens.last().is_some_and(|t| FORM_WORDS.contains(&t.to_lowercase().as_str())) {
tokens.pop();
}
if tokens.is_empty() {
return None;
}
Some(tokens.join(" "))
}
/// Remove a leading `\d+\s*[-–—]\s*` numeric prefix; unchanged if it doesn't match.
fn strip_type_prefix(s: &str) -> String {
let t = s.trim_start();
let after_digits = t.trim_start_matches(|c: char| c.is_ascii_digit());
if after_digits.len() == t.len() {
return s.to_string(); // no leading digits → no match
}
let after_ws = after_digits.trim_start();
let mut chars = after_ws.chars();
match chars.next() {
Some('-') | Some('–') | Some('—') => chars.as_str().trim_start().to_string(),
_ => s.to_string(),
}
}
fn is_pack(title_lower: &str, tags_lower: &[String]) -> bool {
if PACK_TITLE_KEYWORDS.iter().any(|k| title_lower.contains(k)) {
return true;
}
title_lower.contains("shaker") || tags_lower.iter().any(|t| t == "merch")
}
fn is_syrup_variant_product(p: &Value) -> bool {
if family_from_product_type(p.get("product_type").and_then(|v| v.as_str()).unwrap_or(""))
.as_deref()
!= Some("Syrup")
{
return false;
}
let has_flavor_option = p
.get("options")
.and_then(|v| v.as_array())
.map(|opts| {
opts.iter()
.any(|o| o.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase() == "flavor")
})
.unwrap_or(false);
let variant_count = p.get("variants").and_then(|v| v.as_array()).map(|v| v.len()).unwrap_or(0);
has_flavor_option && variant_count > 1
}
/// Pick a category for a product, or `None` to skip. Mirrors `resolve_category`.
async fn resolve_category(
db: &DatabaseConnection,
p: &Value,
title_lower: &str,
tags_lower: &[String],
packs: &category::Model,
) -> anyhow::Result<Option<category::Model>> {
if is_pack(title_lower, tags_lower) {
return Ok(Some(packs.clone()));
}
if ACCESSORY_TITLE_KEYWORDS.iter().any(|k| title_lower.contains(k)) {
return Ok(Some(packs.clone()));
}
if let Some(family) =
family_from_product_type(p.get("product_type").and_then(|v| v.as_str()).unwrap_or(""))
{
return Ok(Some(category_for_family(db, &family).await?));
}
for (keywords, name) in TAG_FAMILY {
if keywords.iter().any(|k| tags_lower.iter().any(|t| t == k)) {
return Ok(Some(category_for_family(db, name).await?));
}
}
if tags_lower.iter().any(|t| t == "shaker") {
return Ok(Some(packs.clone()));
}
Ok(None)
}
/// get_or_create a category from a family name, keyed by `slugify(name)`.
async fn category_for_family(db: &DatabaseConnection, name: &str) -> anyhow::Result<category::Model> {
goc_category_by_slug_named(db, &slugify(name), name).await
}
async fn goc_category_by_slug_named(
db: &DatabaseConnection,
slug: &str,
name: &str,
) -> anyhow::Result<category::Model> {
if let Some(c) = Category::find()
.filter(category::Column::Slug.eq(slug))
.one(db)
.await?
{
return Ok(c);
}
Ok(category::ActiveModel {
name: Set(name.to_string()),
slug: Set(slug.to_string()),
..Default::default()
}
.insert(db)
.await?)
}
/// Expand the syrup bundle's variants into individual Syrup flavors keyed by
/// variant id. Mirrors `_sync_syrup_variants`.
async fn sync_syrup_variants(
db: &DatabaseConnection,
client: &reqwest::Client,
media_root: &str,
p: &Value,
synced_ids: &mut Vec<i64>,
) -> anyhow::Result<(usize, usize)> {
let cat = category_for_family(db, "Syrup").await?;
let description = clean_html(p.get("body_html").and_then(|v| v.as_str()).unwrap_or(""));
let shop_url = p.get("handle").and_then(|v| v.as_str()).map(shop_url_for);
let mut created = 0usize;
let mut updated = 0usize;
let variants = p.get("variants").and_then(|v| v.as_array()).cloned().unwrap_or_default();
for v in &variants {
let name = v
.get("option1")
.and_then(|x| x.as_str())
.or_else(|| v.get("title").and_then(|x| x.as_str()))
.unwrap_or("")
.trim()
.to_string();
let Some(variant_id) = v.get("id").and_then(|x| x.as_i64()) else {
continue;
};
if name.is_empty() {
continue;
}
let image_url = v
.get("featured_image")
.and_then(|f| f.get("src"))
.and_then(|s| s.as_str())
.map(String::from);
let image_urls: Vec<String> = image_url.clone().into_iter().collect();
let available = v.get("available").and_then(|a| a.as_bool()).unwrap_or(false);
let was_created = upsert_flavor(
db,
client,
media_root,
variant_id,
&name,
cat.id,
&description,
shop_url.clone(),
available,
image_url,
&image_urls,
&variant_id.to_string(),
)
.await?;
if was_created {
created += 1;
} else {
updated += 1;
}
synced_ids.push(variant_id);
}
Ok((created, updated))
}
/// Find-or-create one flavor row and refresh its fields. Returns `true` if
/// created. Match order: external_id → exact name in category → case-insensitive
/// name in category. Mirrors `_upsert_flavor`.
#[allow(clippy::too_many_arguments)]
async fn upsert_flavor(
db: &DatabaseConnection,
client: &reqwest::Client,
media_root: &str,
external_id: i64,
name: &str,
category_id: i32,
description: &str,
shop_url: Option<String>,
is_available: bool,
image_url: Option<String>,
image_urls: &[String],
dir_slug: &str,
) -> anyhow::Result<bool> {
let mut existing = Flavor::find()
.filter(flavor::Column::ExternalId.eq(external_id))
.one(db)
.await?;
if existing.is_none() {
existing = Flavor::find()
.filter(flavor::Column::Name.eq(name))
.filter(flavor::Column::CategoryId.eq(category_id))
.one(db)
.await?;
}
if existing.is_none() {
existing = Flavor::find()
.filter(Expr::expr(Func::lower(Expr::col(flavor::Column::Name))).eq(name.to_lowercase()))
.filter(flavor::Column::CategoryId.eq(category_id))
.one(db)
.await?;
}
let created = existing.is_none();
let local_paths = download_gallery(client, media_root, image_urls, dir_slug).await;
let (mut am, cur_main, cur_local_empty): (flavor::ActiveModel, Option<String>, bool) =
match existing {
Some(f) => {
let cur_main = f.main_image_path.clone();
let cur_local_empty = json_str_list(&f.local_image_paths).is_empty();
let f_id = f.id;
let f_name = f.name.clone();
let f_ext_none = f.external_id.is_none();
let f_is_legacy = f.is_legacy;
let mut am: flavor::ActiveModel = f.into();
if f_ext_none {
am.external_id = Set(Some(external_id));
}
if f_is_legacy {
am.is_legacy = Set(false);
}
if f_name != name {
let dup = Flavor::find()
.filter(flavor::Column::Name.eq(name))
.filter(flavor::Column::CategoryId.eq(category_id))
.filter(flavor::Column::Id.ne(f_id))
.count(db)
.await?
> 0;
if !dup {
am.name = Set(name.to_string());
}
}
(am, cur_main, cur_local_empty)
}
None => {
let am = flavor::ActiveModel {
external_id: Set(Some(external_id)),
name: Set(name.to_string()),
category_id: Set(category_id),
is_legacy: Set(false),
created_at: Set(crate::datetime::now_micros()),
..Default::default()
};
(am, None, true)
}
};
am.description = Set(description.to_string());
am.shop_url = Set(shop_url);
am.is_available = Set(is_available);
if !local_paths.is_empty() {
am.local_image_paths = Set(Some(json!(local_paths)));
if let Some(m) = cur_main {
if !local_paths.contains(&m) {
am.main_image_path = Set(None);
}
}
} else if cur_local_empty {
am.main_image_path = Set(None);
}
am.image_url = Set(image_url);
am.image_urls = Set(Some(json!(image_urls)));
if created {
am.insert(db).await?;
} else {
am.update(db).await?;
}
Ok(created)
}
/// Download every URL into `flavors/<dir_slug>/<NN>_<hash>.<ext>`, in parallel,
/// returning the saved rel paths in input order. Mirrors `download_gallery`.
async fn download_gallery(
client: &reqwest::Client,
media_root: &str,
image_urls: &[String],
dir_slug: &str,
) -> Vec<String> {
let targets: Vec<(usize, String, String)> = image_urls
.iter()
.enumerate()
.filter(|(_, u)| !u.is_empty())
.map(|(i, u)| {
let rel = format!("flavors/{dir_slug}/{i:02}_{}.{}", hash_for(u), ext_for(u));
(i, u.clone(), rel)
})
.collect();
if targets.is_empty() {
return Vec::new();
}
let succeeded: HashMap<usize, String> = futures::stream::iter(targets.clone().into_iter().map(
|(i, u, rel)| {
let client = client.clone();
let media_root = media_root.to_string();
async move {
let abs = Path::new(&media_root).join(&rel);
let ok = download_image(&client, &u, &abs).await;
(i, ok, rel)
}
},
))
.buffer_unordered(DOWNLOAD_WORKERS)
.filter_map(|(i, ok, rel)| async move { if ok { Some((i, rel)) } else { None } })
.collect()
.await;
targets.iter().filter_map(|(i, _, _)| succeeded.get(i).cloned()).collect()
}
/// Django JSONField (list) stored as TEXT → Vec<String>.
fn json_str_list(v: &Option<Value>) -> Vec<String> {
match v {
Some(Value::Array(arr)) => {
arr.iter().filter_map(|x| x.as_str().map(String::from)).collect()
}
_ => Vec::new(),
}
}
+221
View File
@@ -0,0 +1,221 @@
mod auth;
mod avatar;
mod blacklist;
mod config;
mod cookies;
mod datetime;
mod db;
mod dto;
mod email;
mod entities;
mod error;
mod jobs;
mod media;
mod merge;
mod mentions;
mod openapi;
mod pagination;
mod password;
mod password_policy;
mod routes;
mod search;
mod service;
mod state;
mod throttle;
mod tokens;
mod web;
use std::time::Duration;
use axum::extract::{DefaultBodyLimit, State};
use axum::http::header::{HeaderValue, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS};
use axum::http::{Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
use serde_json::json;
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::config::Config;
use crate::openapi::ApiDoc;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env()?;
let db = db::connect(&config.database_url).await?;
let bind_addr = config.bind_addr.clone();
let state = AppState {
db,
config,
running_jobs: std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
security: std::sync::Arc::new(crate::throttle::Security::new()),
};
// Background scheduler: runs jobs whose `interval_hours` has elapsed,
// replacing Celery beat. Restart-safe (last_run persists in the DB).
// Gated so it never double-schedules against a still-running Django beat
// during the strangler cutover — exactly one scheduler may own `api_job`.
if state.config.enable_scheduler {
tracing::info!("job scheduler ENABLED (this process owns api_job scheduling)");
jobs::spawn_scheduler(state.clone());
} else {
tracing::warn!(
"job scheduler DISABLED (ENABLE_SCHEDULER!=true) — Django beat assumed to own jobs"
);
}
// Periodically sweep aged-out rate-limit / code buckets so the in-memory
// maps stay bounded.
{
let security = state.security.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tick.tick().await;
security.gc();
}
});
}
// Lock CORS to the configured frontend origin(s) (mirrors Django's
// CORS_ALLOWED_ORIGINS) rather than reflecting any origin — credentialed CORS
// can't use a wildcard, and reflecting arbitrary origins is a foot-gun if the
// SameSite cookie policy ever loosens. Behind the strangler proxy the app is
// same-origin anyway; this matters for direct browser access.
let allowed_origins: Vec<axum::http::HeaderValue> = state
.config
.cors_allowed_origins
.iter()
.filter_map(|o| match o.parse() {
Ok(v) => Some(v),
Err(_) => {
tracing::warn!("ignoring invalid CORS origin: {o}");
None
}
})
.collect();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(allowed_origins))
.allow_credentials(true)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
// Request headers (Content-Type, Authorization, …) are still mirrored;
// only the origin is restricted. Mirroring headers under credentials is
// safe — the origin check is the security boundary.
.allow_headers(AllowHeaders::mirror_request());
let app = Router::new()
.route("/health/", get(health))
.nest("/api", routes::api_router())
.merge(SwaggerUi::new("/api/schema/swagger-ui").url("/api/schema/", ApiDoc::openapi()))
.layer(cors)
// Static security headers DRF/Django set via middleware. `if_not_present`
// so the nginx front (which may own HSTS in prod) can still override.
.layer(SetResponseHeaderLayer::if_not_present(
X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
X_FRAME_OPTIONS,
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
))
// Cap request bodies (avatar upload self-limits to 2 MB; this guards every
// other endpoint against oversized/slow-loris bodies). Set above the
// avatar limit so the handler's own 2 MB check stays authoritative.
.layer(DefaultBodyLimit::max(8 * 1024 * 1024))
// Whole-request deadline so a stalled client can't pin a worker forever.
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(30),
))
// Turn a handler panic into a 500 instead of dropping the connection.
.layer(CatchPanicLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
tracing::info!("listening on http://{bind_addr}");
// ConnectInfo gives handlers the peer address as a REMOTE_ADDR fallback for
// IP logging when no X-Forwarded-For header is present.
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
/// Liveness + readiness: the Docker healthcheck gates worker/beat/frontend on
/// this, so it must touch the DB — a backend that can't reach SQLite is not
/// healthy even if the process is up.
async fn health(State(state): State<AppState>) -> Response {
match state
.db
.execute(Statement::from_string(DatabaseBackend::Sqlite, "SELECT 1"))
.await
{
Ok(_) => Json(json!({ "status": "ok" })).into_response(),
Err(e) => {
tracing::error!("health check failed: database unreachable: {e}");
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "status": "error", "detail": "database unavailable" })),
)
.into_response()
}
}
}
/// Resolve when the process receives Ctrl+C or (on Unix) SIGTERM — Docker sends
/// SIGTERM on `stop`. Lets axum drain in-flight requests before exiting so a
/// running job's lifecycle row isn't abandoned mid-write.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received; draining in-flight requests");
}
+60
View File
@@ -0,0 +1,60 @@
//! Image-URL resolution mirroring `api/serializers/_helpers.py` +
//! `FlavorSerializer.get_image_url/get_image_urls`.
use crate::web::RequestCtx;
/// `absolute_media_url`: prefix a stored relative media path with MEDIA_URL and
/// make it absolute against the request.
pub fn media_url(ctx: &RequestCtx, media_prefix: &str, rel: &str) -> String {
let prefix = format!("/{}/", media_prefix.trim_matches('/'));
let path = format!("{}{}", prefix, rel.trim_start_matches('/'));
ctx.absolute(&path)
}
/// `absolute_image_url`: an ImageField stored as a relative path under MEDIA_URL.
pub fn image_field_url(ctx: &RequestCtx, media_prefix: &str, image: &str) -> String {
media_url(ctx, media_prefix, image)
}
/// Replicates `FlavorSerializer.get_image_url`.
pub fn flavor_primary_image(
ctx: &RequestCtx,
media_prefix: &str,
main_image_path: Option<&str>,
local_image_paths: &[String],
image: Option<&str>,
image_url: Option<&str>,
) -> Option<String> {
if let Some(p) = main_image_path.filter(|s| !s.is_empty()) {
return Some(media_url(ctx, media_prefix, p));
}
if let Some(first) = local_image_paths.first() {
return Some(media_url(ctx, media_prefix, first));
}
if let Some(img) = image.filter(|s| !s.is_empty()) {
return Some(image_field_url(ctx, media_prefix, img));
}
image_url.map(|s| s.to_string())
}
/// Replicates `FlavorSerializer.get_image_urls`.
pub fn flavor_image_list(
ctx: &RequestCtx,
media_prefix: &str,
local_image_paths: &[String],
image: Option<&str>,
image_urls: &[String],
) -> Vec<String> {
if !local_image_paths.is_empty() {
return local_image_paths
.iter()
.map(|p| media_url(ctx, media_prefix, p))
.collect();
}
let mut out = Vec::new();
if let Some(img) = image.filter(|s| !s.is_empty()) {
out.push(image_field_url(ctx, media_prefix, img));
}
out.extend(image_urls.iter().cloned());
out
}
+103
View File
@@ -0,0 +1,103 @@
//! Port of `api/services/mentions.py`: scan text for `@username`, resolve each
//! to a user (case-insensitive), and create a `mention` notification with
//! get_or_create semantics (no duplicate rows for the same target).
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{
ActiveValue::{NotSet, Set},
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
};
use crate::entities::prelude::*;
use crate::entities::{notification, user};
use crate::error::ApiResult;
/// Extract distinct `@word` handles. `\w` in Django = `[A-Za-z0-9_]`.
fn extract_handles(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'@' {
let start = i + 1;
let mut j = start;
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
if j > start {
let handle = &text[start..j];
if !out.iter().any(|h| h == handle) {
out.push(handle.to_string());
}
}
i = j;
} else {
i += 1;
}
}
out
}
/// `col IS value` — equality for `Some`, IS NULL for `None` (Django's lookup
/// translation for nullable FKs in get_or_create).
fn eq_or_null(col: notification::Column, value: Option<i32>) -> Condition {
match value {
Some(v) => Condition::all().add(col.eq(v)),
None => Condition::all().add(col.is_null()),
}
}
pub async fn parse_mentions(
db: &DatabaseConnection,
text: &str,
actor_id: i32,
rating_id: Option<i32>,
reply_id: Option<i32>,
) -> ApiResult<()> {
if text.is_empty() {
return Ok(());
}
for handle in extract_handles(text) {
// username__iexact
let recipient = User::find()
.filter(
Expr::expr(Func::lower(Expr::col(user::Column::Username)))
.eq(handle.to_lowercase()),
)
.one(db)
.await?;
let Some(recipient) = recipient else { continue };
if recipient.id == actor_id {
continue;
}
// get_or_create(recipient, actor, type="mention", rating, reply)
let exists = Notification::find()
.filter(notification::Column::RecipientId.eq(recipient.id))
.filter(notification::Column::ActorId.eq(actor_id))
.filter(notification::Column::NotificationType.eq("mention"))
.filter(eq_or_null(notification::Column::RatingId, rating_id))
.filter(eq_or_null(notification::Column::ReplyId, reply_id))
.one(db)
.await?;
if exists.is_some() {
continue;
}
let now = crate::datetime::now_micros();
let row = notification::ActiveModel {
id: NotSet,
is_read: Set(false),
created_at: Set(now),
actor_id: Set(actor_id),
rating_id: Set(rating_id),
recipient_id: Set(recipient.id),
reply_id: Set(reply_id),
ticket_id: Set(None),
notification_type: Set("mention".to_string()),
profile_comment_id: Set(None),
};
Notification::insert(row).exec(db).await?;
}
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
//! Port of `api/services/flavor.py::merge_flavors`. Merges one flavor row into
//! another, re-homing ratings and preserving rating integrity. Used by both the
//! admin `merge_flavors` endpoint and the `cleanup_duplicates` job.
use sea_orm::sea_query::Expr;
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter,
TransactionTrait,
};
use crate::entities::prelude::*;
use crate::entities::{flavor, notification, rating, reply};
/// Rank a rating for conflict resolution: longest comment wins, newer breaks
/// ties (mirrors Python's tuple comparison `(len(comment), created_at)`).
fn quality(r: &rating::Model) -> (usize, chrono::NaiveDateTime) {
(r.comment.as_deref().unwrap_or("").chars().count(), r.created_at)
}
/// Merge `remove` into `keep`. Re-assigns all of `remove`'s ratings; when a user
/// rated both flavors the higher-quality review survives and its replies +
/// notifications are re-pointed onto the surviving row. Runs in one transaction.
pub async fn merge_flavors(
db: &DatabaseConnection,
keep: &flavor::Model,
remove: &flavor::Model,
) -> Result<(), DbErr> {
if keep.id == remove.id {
return Ok(());
}
let txn = db.begin().await?;
let incomings = Rating::find()
.filter(rating::Column::FlavorId.eq(remove.id))
.all(&txn)
.await?;
for incoming in incomings {
let existing = Rating::find()
.filter(rating::Column::UserId.eq(incoming.user_id))
.filter(rating::Column::FlavorId.eq(keep.id))
.one(&txn)
.await?;
let Some(existing) = existing else {
// No conflict — move the rating wholesale.
let mut am: rating::ActiveModel = incoming.into();
am.flavor_id = Set(keep.id);
am.update(&txn).await?;
continue;
};
// Conflict: keep one row (unique user+flavor). If the incoming review is
// better, copy its content + timestamp onto the surviving row.
if quality(&incoming) > quality(&existing) {
let mut am: rating::ActiveModel = existing.clone().into();
am.score = Set(incoming.score);
am.comment = Set(incoming.comment.clone());
am.created_at = Set(incoming.created_at);
am.update(&txn).await?;
}
// Preserve community feedback attached to the discarded rating.
Reply::update_many()
.col_expr(reply::Column::RatingId, Expr::value(existing.id))
.filter(reply::Column::RatingId.eq(incoming.id))
.exec(&txn)
.await?;
Notification::update_many()
.col_expr(notification::Column::RatingId, Expr::value(existing.id))
.filter(notification::Column::RatingId.eq(incoming.id))
.exec(&txn)
.await?;
Rating::delete_by_id(incoming.id).exec(&txn).await?;
}
// Carry over the Shopify link if the kept flavor lacks one. external_id is
// unique, so the donor row must be gone before we assign it.
let inherited = if keep.external_id.is_none() {
remove.external_id
} else {
None
};
Flavor::delete_by_id(remove.id).exec(&txn).await?;
if let Some(eid) = inherited {
let mut am: flavor::ActiveModel = keep.clone().into();
am.external_id = Set(Some(eid));
am.update(&txn).await?;
}
txn.commit().await?;
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
use utoipa::OpenApi;
use crate::dto::{BannerOut, CategoryOut, FlavorOut, RatingOut, ReplyOut, SearchHit};
/// OpenAPI document. Component schemas are registered; per-path operation specs
/// will be added with `#[utoipa::path]` as handlers stabilize.
#[derive(OpenApi)]
#[openapi(
info(
title = "Holy Flavor Rater (Rust)",
description = "Strangler-migration Rust backend. Read endpoints ported first; the rest remain on Django.",
version = "0.1.0"
),
components(schemas(CategoryOut, FlavorOut, RatingOut, ReplyOut, BannerOut, SearchHit))
)]
pub struct ApiDoc;
+75
View File
@@ -0,0 +1,75 @@
//! DRF `PageNumberPagination` compatibility: `{ count, next, previous, results }`
//! with `page` query param and a fixed page size (DRF default 50; the flavor
//! list uses that default).
use serde::Serialize;
use crate::web::RequestCtx;
pub const DEFAULT_PAGE_SIZE: u64 = 50;
#[derive(Serialize)]
pub struct Paginated<T: Serialize> {
pub count: u64,
pub next: Option<String>,
pub previous: Option<String>,
pub results: Vec<T>,
}
/// Parse the `page` query value (DRF is 1-indexed; defaults to 1).
pub fn parse_page(raw: Option<&str>) -> u64 {
raw.and_then(|s| s.parse::<u64>().ok())
.filter(|p| *p >= 1)
.unwrap_or(1)
}
fn with_page(ctx: &RequestCtx, page: u64) -> String {
// Split off existing query, drop any prior `page=`, then re-add.
let (path, query) = match ctx.full_path.split_once('?') {
Some((p, q)) => (p, q),
None => (ctx.full_path.as_str(), ""),
};
let mut pairs: Vec<String> = query
.split('&')
.filter(|kv| !kv.is_empty() && !kv.starts_with("page="))
.map(|kv| kv.to_string())
.collect();
if page > 1 {
pairs.push(format!("page={page}"));
}
let q = pairs.join("&");
let rel = if q.is_empty() {
path.to_string()
} else {
format!("{path}?{q}")
};
ctx.absolute(&rel)
}
impl<T: Serialize> Paginated<T> {
pub fn build(
ctx: &RequestCtx,
results: Vec<T>,
count: u64,
page: u64,
page_size: u64,
) -> Self {
let total_pages = count.div_ceil(page_size.max(1));
let next = if page < total_pages {
Some(with_page(ctx, page + 1))
} else {
None
};
let previous = if page > 1 {
Some(with_page(ctx, page - 1))
} else {
None
};
Paginated {
count,
next,
previous,
results,
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Django-compatible password verification.
//!
//! Django's default hasher stores `pbkdf2_sha256$<iterations>$<salt>$<b64hash>`,
//! where the hash is `base64(pbkdf2_hmac_sha256(password, salt, iterations))`
//! with the derived key length equal to the SHA-256 digest size (32 bytes).
//! Salt is used as raw UTF-8 bytes. We re-derive and compare in constant time,
//! matching Django's `check_password`.
//!
//! Only `pbkdf2_sha256` is supported (the project's hasher). Other algorithms
//! (argon2/bcrypt) would need their own branch — login would fall through to a
//! rejection for those, so keep issuance on Django if the hasher ever changes.
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use sha2::Sha256;
use subtle::ConstantTimeEq;
const ALGORITHM: &str = "pbkdf2_sha256";
const DK_LEN: usize = 32; // SHA-256 digest size
/// Matches Django's current `PBKDF2PasswordHasher.iterations`. Django reads the
/// iteration count back out of the encoded hash, so any value it supports
/// verifies — we use its default so it never needs to silently re-hash on login.
const ITERATIONS: u32 = 1_200_000;
/// Verify a plaintext password against a Django password hash string.
pub fn verify_password(password: &str, encoded: &str) -> bool {
let mut parts = encoded.splitn(4, '$');
let (Some(algo), Some(iter_s), Some(salt), Some(b64hash)) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
if algo != ALGORITHM {
return false;
}
let Ok(iterations) = iter_s.parse::<u32>() else {
return false;
};
let Ok(expected) = B64.decode(b64hash) else {
return false;
};
let mut derived = [0u8; DK_LEN];
// pbkdf2_hmac writes the derived key; rounds == 0 is invalid for Django hashes.
if iterations == 0 {
return false;
}
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), iterations, &mut derived);
// Constant-time compare, length-checked first.
if expected.len() != derived.len() {
return false;
}
derived.ct_eq(expected.as_slice()).into()
}
/// Hash a plaintext password into Django's `pbkdf2_sha256$iter$salt$b64` format,
/// matching `make_password`. The salt is a random hex string (cryptographically
/// random via uuid v4); Django accepts any salt that contains no `$`.
pub fn hash_password(password: &str) -> String {
let salt = uuid::Uuid::new_v4().simple().to_string();
let mut derived = [0u8; DK_LEN];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), ITERATIONS, &mut derived);
let b64 = B64.encode(derived);
format!("{ALGORITHM}${ITERATIONS}${salt}${b64}")
}
#[cfg(test)]
mod tests {
use super::*;
// Generated by Python `hashlib.pbkdf2_hmac` (the exact routine Django's
// PBKDF2PasswordHasher calls) and confirmed to verify under Django's
// `check_password`. Proves cross-backend hash compatibility.
const DJANGO_HASH: &str =
"pbkdf2_sha256$100000$abcdefghijklmnop$M4PTvkbfZw/Dmbx4RsTgMD5Oo8Mh7BdYlHcR+JM6rT0=";
const DJANGO_PLAINTEXT: &str = "correct horse battery staple";
#[test]
fn verifies_known_django_hash() {
assert!(verify_password(DJANGO_PLAINTEXT, DJANGO_HASH));
}
#[test]
fn rejects_wrong_password_for_django_hash() {
assert!(!verify_password("wrong password", DJANGO_HASH));
}
#[test]
fn hash_then_verify_round_trips() {
let encoded = hash_password("hunter2-correct");
assert!(encoded.starts_with("pbkdf2_sha256$1200000$"));
assert!(verify_password("hunter2-correct", &encoded));
assert!(!verify_password("hunter3", &encoded));
}
#[test]
fn rejects_malformed_and_unsupported() {
assert!(!verify_password("x", "not-a-hash"));
assert!(!verify_password("x", "bcrypt$12$whatever"));
assert!(!verify_password("x", "pbkdf2_sha256$0$salt$AAAA"));
}
}
+268
View File
@@ -0,0 +1,268 @@
//! Django `AUTH_PASSWORD_VALIDATORS` ported faithfully.
//!
//! Django *configures* the four default validators
//! (`UserAttributeSimilarityValidator`, `MinimumLengthValidator`,
//! `CommonPasswordValidator`, `NumericPasswordValidator`) with default options,
//! but this project's DRF views never actually call `validate_password`, so the
//! live Django backend accepts any non-empty password at signup and anything at
//! all on change/reset. Enforcing the validators here is therefore a deliberate
//! hardening step that *diverges* from the current Django behaviour (chosen by
//! the maintainer): a password Django would accept may be rejected by Rust.
//!
//! [`validate`] aggregates every failing validator's message in the same order
//! Django's settings list them, mirroring `validate_password`'s behaviour of
//! raising a `ValidationError` containing all errors.
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::sync::OnceLock;
const MIN_LENGTH: usize = 8;
const MAX_SIMILARITY: f64 = 0.7;
/// User attributes the similarity validator compares against. Empty values are
/// skipped (mirrors Django ignoring falsy attributes). The verbose names match
/// Django's `AbstractUser` field `verbose_name`s so error text is identical.
pub struct UserAttrs<'a> {
pub username: &'a str,
pub first_name: &'a str,
pub last_name: &'a str,
pub email: &'a str,
}
/// Validate `password` against all four validators. Returns the list of
/// human-readable error messages; empty means the password is acceptable.
pub fn validate(password: &str, attrs: &UserAttrs) -> Vec<String> {
let mut errors = Vec::new();
// Order matches settings.AUTH_PASSWORD_VALIDATORS.
if let Err(e) = too_similar(password, attrs) {
errors.push(e);
}
if let Err(e) = min_length(password) {
errors.push(e);
}
if let Err(e) = too_common(password) {
errors.push(e);
}
if let Err(e) = entirely_numeric(password) {
errors.push(e);
}
errors
}
/// `MinimumLengthValidator(min_length=8)`. Length is counted in characters, like
/// Python's `len()` over a `str`.
fn min_length(password: &str) -> Result<(), String> {
if password.chars().count() < MIN_LENGTH {
Err(format!(
"This password is too short. It must contain at least {MIN_LENGTH} characters."
))
} else {
Ok(())
}
}
/// `NumericPasswordValidator`. Mirrors Python `str.isdigit()`, which is false for
/// the empty string and true only when every character is a digit.
fn entirely_numeric(password: &str) -> Result<(), String> {
if !password.is_empty() && password.chars().all(|c| c.is_ascii_digit()) {
Err("This password is entirely numeric.".to_string())
} else {
Ok(())
}
}
/// `CommonPasswordValidator`. Django compares `password.lower().strip()` against
/// the shipped 20k-entry list.
fn too_common(password: &str) -> Result<(), String> {
let normalized = password.to_lowercase();
if common_passwords().contains(normalized.trim()) {
Err("This password is too common.".to_string())
} else {
Ok(())
}
}
/// Django's `common-passwords.txt.gz`, decompressed once and cached. Embedded in
/// the binary so there's no runtime file dependency.
fn common_passwords() -> &'static HashSet<String> {
static SET: OnceLock<HashSet<String>> = OnceLock::new();
SET.get_or_init(|| {
let gz: &[u8] = include_bytes!("../assets/common-passwords.txt.gz");
let mut decoder = flate2::read::GzDecoder::new(gz);
let mut contents = String::new();
decoder
.read_to_string(&mut contents)
.expect("embedded common-passwords list must decompress");
contents.lines().map(|l| l.trim().to_string()).collect()
})
}
/// `UserAttributeSimilarityValidator(max_similarity=0.7)`. Compares the password
/// against each attribute and its `\W+`-split components.
fn too_similar(password: &str, attrs: &UserAttrs) -> Result<(), String> {
let password = password.to_lowercase();
let candidates = [
(attrs.username, "username"),
(attrs.first_name, "first name"),
(attrs.last_name, "last name"),
(attrs.email, "email address"),
];
for (value, verbose_name) in candidates {
if value.is_empty() {
continue;
}
let value_lower = value.to_lowercase();
let mut value_parts = split_non_word(&value_lower);
value_parts.push(value_lower);
for part in &value_parts {
if exceeds_maximum_length_ratio(&password, MAX_SIMILARITY, part) {
continue;
}
if quick_ratio(&password, part) >= MAX_SIMILARITY {
return Err(format!("The password is too similar to the {verbose_name}."));
}
}
}
Ok(())
}
/// Port of Django's `exceeds_maximum_length_ratio`: a cheap pre-filter that lets
/// the validator skip the ratio computation when the attribute is far shorter
/// than the password (in which case the similarity can't reach the threshold).
fn exceeds_maximum_length_ratio(password: &str, max_similarity: f64, value: &str) -> bool {
let pwd_len = password.chars().count();
let length_bound_similarity = max_similarity / 2.0 * pwd_len as f64;
let value_len = value.chars().count();
pwd_len >= 10 * value_len && (value_len as f64) < length_bound_similarity
}
/// Port of `difflib.SequenceMatcher.quick_ratio`: an upper bound on the real
/// ratio, computed as `2 * |multiset intersection| / (len(a) + len(b))`.
fn quick_ratio(a: &str, b: &str) -> f64 {
let mut fullbcount: HashMap<char, i32> = HashMap::new();
for c in b.chars() {
*fullbcount.entry(c).or_insert(0) += 1;
}
let mut avail: HashMap<char, i32> = HashMap::new();
let mut matches = 0i32;
for c in a.chars() {
let numb = match avail.get(&c) {
Some(&n) => n,
None => *fullbcount.get(&c).unwrap_or(&0),
};
avail.insert(c, numb - 1);
if numb > 0 {
matches += 1;
}
}
let total = a.chars().count() + b.chars().count();
if total == 0 {
1.0
} else {
2.0 * matches as f64 / total as f64
}
}
/// Equivalent of Python `re.split(r"\W+", s)`: split on maximal runs of
/// non-word characters (`\w` = alphanumeric or underscore), preserving the empty
/// strings that appear when `s` begins or ends with a separator.
fn split_non_word(s: &str) -> Vec<String> {
let mut result = Vec::new();
let mut token = String::new();
let mut in_sep = false;
for c in s.chars() {
if c.is_alphanumeric() || c == '_' {
in_sep = false;
token.push(c);
} else if !in_sep {
result.push(std::mem::take(&mut token));
in_sep = true;
}
}
result.push(token);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn attrs<'a>(username: &'a str, email: &'a str) -> UserAttrs<'a> {
UserAttrs {
username,
first_name: "",
last_name: "",
email,
}
}
#[test]
fn rejects_empty_and_short() {
let a = attrs("alice", "alice@example.com");
assert!(validate("", &a).iter().any(|e| e.contains("too short")));
assert!(validate("abc", &a).iter().any(|e| e.contains("too short")));
}
#[test]
fn rejects_entirely_numeric() {
let a = attrs("alice", "alice@example.com");
let errs = validate("12345678", &a);
assert!(errs.iter().any(|e| e.contains("entirely numeric")));
}
#[test]
fn rejects_common_password() {
let a = attrs("alice", "alice@example.com");
// "password" is in Django's list.
assert!(validate("password", &a)
.iter()
.any(|e| e.contains("too common")));
}
#[test]
fn rejects_similar_to_username() {
let a = attrs("alexander", "alex@example.com");
assert!(validate("alexander1", &a)
.iter()
.any(|e| e.contains("too similar")));
}
#[test]
fn rejects_similar_to_email_local_part() {
let a = attrs("zzz", "supersecret@example.com");
// Matches the email local-part component after \W+ split.
assert!(validate("supersecret", &a)
.iter()
.any(|e| e.contains("too similar")));
}
#[test]
fn accepts_strong_password() {
let a = attrs("alice", "alice@example.com");
assert!(validate("Tr0ub4dour-x9Qz", &a).is_empty());
}
#[test]
fn quick_ratio_matches_difflib_examples() {
// SequenceMatcher(a='', b='').quick_ratio() == 1.0
assert_eq!(quick_ratio("", ""), 1.0);
// Identical strings → 1.0
assert_eq!(quick_ratio("abc", "abc"), 1.0);
// Disjoint → 0.0
assert_eq!(quick_ratio("abc", "xyz"), 0.0);
}
#[test]
fn split_non_word_matches_re_split() {
assert_eq!(split_non_word("john"), vec!["john"]);
assert_eq!(split_non_word("a.b"), vec!["a", "b"]);
assert_eq!(split_non_word(".a"), vec!["", "a"]);
assert_eq!(split_non_word("a."), vec!["a", ""]);
assert_eq!(
split_non_word("john.doe@example.com"),
vec!["john", "doe", "example", "com"]
);
}
}
+448
View File
@@ -0,0 +1,448 @@
//! Port of `AdminViewSet` (`api/views/admin.py`), registered by DRF under
//! `admin-custom`. Every action requires staff (`IsAdminUser` = `is_staff`),
//! enforced by the [`AdminUser`] extractor. Covers dashboard stats, the system
//! config singleton, the jobs panel (list / trigger / schedule), flavor merge,
//! a test-email sender, and the admin user list/detail.
//!
//! Scheduling parity note: Django wrote `django_celery_beat.PeriodicTask` rows;
//! here the in-process scheduler reads `interval_hours` straight off the job row,
//! so `update_job_schedule` only persists that field. `next_run` is derived as
//! `last_run + interval_hours`.
use std::collections::HashMap;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
};
use serde_json::{json, Value};
use crate::auth::AdminUser;
use crate::datetime::drf_iso;
use crate::entities::prelude::{Flavor, Job, Rating, Reply, SystemConfig, Ticket, User, UserIp};
use crate::entities::{job, rating, system_config, ticket, user, user_ip};
use crate::error::{ApiError, ApiResult};
use crate::service::build_ratings;
use crate::state::AppState;
use crate::web::RequestCtx;
use crate::{email, jobs, merge};
pub fn router() -> Router<AppState> {
Router::new()
.route("/admin-custom/stats/", get(stats))
.route("/admin-custom/config/", get(config_get).patch(config_patch))
.route("/admin-custom/jobs/", get(jobs_list))
.route("/admin-custom/merge_flavors/", post(merge_flavors))
.route("/admin-custom/send_test_email/", post(send_test_email))
.route("/admin-custom/users/", get(users_list))
.route("/admin-custom/{pk}/trigger_job/", post(trigger_job))
.route("/admin-custom/{pk}/update_job_schedule/", patch(update_job_schedule))
.route(
"/admin-custom/{pk}/user_detail/",
get(user_detail_get).patch(user_detail_patch).delete(user_detail_delete),
)
}
fn parse_body(body: &Bytes) -> Value {
serde_json::from_slice(body).unwrap_or(Value::Null)
}
// ---------------------------------------------------------------- stats
async fn stats(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let db = &state.db;
let total_users = User::find().count(db).await?;
let total_ratings = Rating::find().count(db).await?;
let total_replies = Reply::find().count(db).await?;
let open_tickets = Ticket::find()
.filter(ticket::Column::Status.eq("open"))
.count(db)
.await?;
let email = &state.config.email;
let allowed_hosts: Vec<String> = std::env::var("ALLOWED_HOSTS")
.ok()
.map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect()
})
.unwrap_or_default();
Ok(Json(json!({
"total_users": total_users,
"total_ratings": total_ratings,
"total_replies": total_replies,
"open_tickets": open_tickets,
"email_config": {
"host": email.host.clone().unwrap_or_else(|| "None".to_string()),
"port": email.port,
"use_tls": email.use_tls,
"use_ssl": email.use_ssl,
"skip_verify": email.skip_cert_verification,
},
"server_info": {
"debug": cfg!(debug_assertions),
"allowed_hosts": allowed_hosts,
"frontend_url": state.config.frontend_url,
"media_root": state.config.media_root,
"static_root": std::env::var("STATIC_ROOT").unwrap_or_default(),
},
})))
}
// ---------------------------------------------------------------- config
fn config_json(c: &system_config::Model) -> Value {
json!({
"site_name": c.site_name,
"maintenance_mode": c.maintenance_mode,
"allow_new_signups": c.allow_new_signups,
"require_email_verification": c.require_email_verification,
"updated_at": drf_iso(&c.updated_at),
})
}
/// `SystemConfig.get_solo()` — the pk=1 singleton, created on first read.
async fn get_solo(state: &AppState) -> ApiResult<system_config::Model> {
if let Some(c) = SystemConfig::find_by_id(1).one(&state.db).await? {
return Ok(c);
}
Ok(system_config::ActiveModel {
id: Set(1),
site_name: Set("Holy Flavors Archive".into()),
maintenance_mode: Set(false),
allow_new_signups: Set(true),
require_email_verification: Set(true),
updated_at: Set(crate::datetime::now_micros()),
}
.insert(&state.db)
.await?)
}
async fn config_get(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let c = get_solo(&state).await?;
Ok(Json(config_json(&c)))
}
fn opt_bool(data: &Value, field: &str) -> ApiResult<Option<bool>> {
match data.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
_ => Err(ApiError::Validation(json!({ field: ["Must be a valid boolean."] }))),
}
}
async fn config_patch(
State(state): State<AppState>,
_admin: AdminUser,
body: Bytes,
) -> ApiResult<Json<Value>> {
let data = parse_body(&body);
let c = get_solo(&state).await?;
let mut am: system_config::ActiveModel = c.into();
match data.get("site_name") {
None | Some(Value::Null) => {}
Some(Value::String(s)) if s.chars().count() <= 100 => am.site_name = Set(s.clone()),
Some(Value::String(_)) => {
return Err(ApiError::Validation(json!({
"site_name": ["Ensure this field has no more than 100 characters."]
})))
}
Some(_) => {
return Err(ApiError::Validation(json!({ "site_name": ["Not a valid string."] })))
}
}
if let Some(b) = opt_bool(&data, "maintenance_mode")? {
am.maintenance_mode = Set(b);
}
if let Some(b) = opt_bool(&data, "allow_new_signups")? {
am.allow_new_signups = Set(b);
}
if let Some(b) = opt_bool(&data, "require_email_verification")? {
am.require_email_verification = Set(b);
}
am.updated_at = Set(crate::datetime::now_micros());
let c = am.update(&state.db).await?;
Ok(Json(config_json(&c)))
}
// ---------------------------------------------------------------- jobs
fn job_json(j: &job::Model) -> Value {
let name_display = jobs::lookup(&j.name)
.map(|b| b.display_name().to_string())
.unwrap_or_else(|| j.name.clone());
let next_run = if j.interval_hours > 0 {
j.last_run
.map(|lr| drf_iso(&(lr + chrono::Duration::hours(j.interval_hours as i64))))
} else {
None
};
json!({
"id": j.id,
"name": j.name,
"name_display": name_display,
"status": j.status,
"last_run": j.last_run.map(|d| drf_iso(&d)),
"next_run": next_run,
"interval_hours": j.interval_hours,
"last_output": j.last_output,
"error_message": j.error_message,
})
}
/// GET jobs — ensures a row exists for every known job, then lists by name.
async fn jobs_list(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
for b in jobs::registry() {
jobs::get_or_create_row(&state.db, b.name()).await?;
}
let rows = Job::find()
.order_by_asc(job::Column::Name)
.all(&state.db)
.await?;
let out: Vec<Value> = rows.iter().map(job_json).collect();
Ok(Json(json!(out)))
}
/// POST {pk}/trigger_job — dispatch the job asynchronously (fire-and-forget).
async fn trigger_job(
State(state): State<AppState>,
_admin: AdminUser,
Path(pk): Path<i32>,
) -> ApiResult<Json<Value>> {
let row = Job::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
let Some(job_impl) = jobs::lookup(&row.name) else {
return Err(ApiError::JsonStatus(
StatusCode::BAD_REQUEST,
json!({ "error": format!("No celery task registered for {}", row.name) }),
));
};
let task_name = format!("api.{}", row.name);
let mut am: job::ActiveModel = row.into();
am.status = Set("pending".into());
am.error_message = Set(String::new());
am.update(&state.db).await?;
let st = state.clone();
tokio::spawn(async move { jobs::run_job(&st, job_impl).await });
Ok(Json(json!({ "status": "Job queued", "task": task_name })))
}
/// PATCH {pk}/update_job_schedule — set `interval_hours`. The scheduler reads it.
async fn update_job_schedule(
State(state): State<AppState>,
_admin: AdminUser,
Path(pk): Path<i32>,
body: Bytes,
) -> ApiResult<Json<Value>> {
let row = Job::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
let data = parse_body(&body);
let interval = match data.get("interval_hours") {
Some(Value::Number(n)) => n.as_i64(),
Some(Value::String(s)) => s.parse::<i64>().ok(),
_ => None,
};
let row = if let Some(i) = interval {
let mut am: job::ActiveModel = row.into();
am.interval_hours = Set(i as i32);
am.update(&state.db).await?
} else {
row
};
Ok(Json(job_json(&row)))
}
// ---------------------------------------------------------------- merge_flavors
fn parse_id(data: &Value, field: &str) -> Option<i32> {
match data.get(field) {
Some(Value::Number(n)) => n.as_i64().map(|v| v as i32),
Some(Value::String(s)) => s.parse::<i32>().ok(),
_ => None,
}
.filter(|v| *v != 0)
}
async fn merge_flavors(
State(state): State<AppState>,
_admin: AdminUser,
body: Bytes,
) -> ApiResult<Json<Value>> {
let data = parse_body(&body);
let (keep_id, remove_id) = match (parse_id(&data, "keep_id"), parse_id(&data, "remove_id")) {
(Some(k), Some(r)) => (k, r),
_ => {
return Err(ApiError::JsonStatus(
StatusCode::BAD_REQUEST,
json!({ "error": "Both keep_id and remove_id are required" }),
))
}
};
let keep = Flavor::find_by_id(keep_id).one(&state.db).await?;
let remove = Flavor::find_by_id(remove_id).one(&state.db).await?;
let (keep, remove) = match (keep, remove) {
(Some(k), Some(r)) => (k, r),
_ => {
return Err(ApiError::JsonStatus(
StatusCode::NOT_FOUND,
json!({ "error": "One or both flavors not found" }),
))
}
};
if keep.category_id != remove.category_id {
return Err(ApiError::JsonStatus(
StatusCode::BAD_REQUEST,
json!({ "error": "Flavors must belong to the same category" }),
));
}
merge::merge_flavors(&state.db, &keep, &remove).await?;
Ok(Json(json!({ "status": "Flavors merged successfully" })))
}
// ---------------------------------------------------------------- send_test_email
async fn send_test_email(
State(state): State<AppState>,
AdminUser(me): AdminUser,
) -> ApiResult<Json<Value>> {
let body = format!(
"This is a test email sent to {} from the Holy Flavors Admin Interface.",
me.email
);
match email::send_mail(
&state.config.email,
"Holy Flavors Admin Test Email",
&body,
&[me.email.clone()],
)
.await
{
Ok(()) => Ok(Json(json!({ "status": "Test email sent!" }))),
Err(e) => Err(ApiError::JsonStatus(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": e }),
)),
}
}
// ---------------------------------------------------------------- users
/// IP addresses for a set of users, ordered newest-login first (UserIP ordering).
async fn ips_by_user(state: &AppState, ids: &[i32]) -> ApiResult<HashMap<i32, Vec<String>>> {
let mut map: HashMap<i32, Vec<String>> = HashMap::new();
if ids.is_empty() {
return Ok(map);
}
let rows = UserIp::find()
.filter(user_ip::Column::UserId.is_in(ids.to_vec()))
.order_by_desc(user_ip::Column::LastLogin)
.all(&state.db)
.await?;
for r in rows {
map.entry(r.user_id).or_default().push(r.ip_address);
}
Ok(map)
}
fn admin_user_json(u: &user::Model, ips: &[String]) -> Value {
json!({
"id": u.id,
"username": u.username,
"email": u.email,
"is_active": u.is_active,
"is_superuser": u.is_superuser,
"date_joined": drf_iso(&u.date_joined),
"last_login": u.last_login.map(|d| drf_iso(&d)),
"ips": ips,
})
}
/// GET users — admin list (AdminUserListSerializer), first 3 IPs each, -date_joined.
async fn users_list(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let users = User::find()
.order_by_desc(user::Column::DateJoined)
.all(&state.db)
.await?;
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
let ip_map = ips_by_user(&state, &ids).await?;
let out: Vec<Value> = users
.iter()
.map(|u| {
let ips: Vec<String> =
ip_map.get(&u.id).map(|v| v.iter().take(3).cloned().collect()).unwrap_or_default();
admin_user_json(u, &ips)
})
.collect();
Ok(Json(json!(out)))
}
/// AdminUserDetailSerializer: full IPs + the user's ratings.
async fn build_user_detail(
state: &AppState,
ctx: &RequestCtx,
u: &user::Model,
) -> ApiResult<Value> {
let ips = ips_by_user(state, &[u.id]).await?.remove(&u.id).unwrap_or_default();
let ratings = Rating::find()
.filter(rating::Column::UserId.eq(u.id))
.order_by_desc(rating::Column::CreatedAt)
.all(&state.db)
.await?;
let ratings = build_ratings(state, ctx, ratings).await?;
let mut v = admin_user_json(u, &ips);
v["ratings"] = serde_json::to_value(ratings).unwrap_or(Value::Null);
Ok(v)
}
async fn user_detail_get(
State(state): State<AppState>,
ctx: RequestCtx,
_admin: AdminUser,
Path(pk): Path<i32>,
) -> ApiResult<Json<Value>> {
let u = User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
Ok(Json(build_user_detail(&state, &ctx, &u).await?))
}
async fn user_detail_patch(
State(state): State<AppState>,
ctx: RequestCtx,
_admin: AdminUser,
Path(pk): Path<i32>,
body: Bytes,
) -> ApiResult<Json<Value>> {
let u = User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
let data = parse_body(&body);
let u = match data.get("is_active") {
Some(Value::Bool(b)) => {
let mut am: user::ActiveModel = u.into();
am.is_active = Set(*b);
am.update(&state.db).await?
}
_ => u,
};
Ok(Json(build_user_detail(&state, &ctx, &u).await?))
}
async fn user_detail_delete(
State(state): State<AppState>,
_admin: AdminUser,
Path(pk): Path<i32>,
) -> ApiResult<StatusCode> {
User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
crate::routes::users::delete_user_cascade(&state, pk).await?;
Ok(StatusCode::NO_CONTENT)
}
+253
View File
@@ -0,0 +1,253 @@
//! SimpleJWT-compatible auth issuance (login / refresh / verify / logout).
//!
//! Ports Django's cookie-backed JWT views (`api/views/auth.py`): tokens are set
//! as httpOnly cookies and the JSON body is just `{"status": "ok"}`. Refresh
//! rotates with blacklist-after-rotation. Tokens are HS256-signed with the same
//! `SECRET_KEY` as Django, so they remain valid on whichever backend serves a
//! given request during the strangler rollout.
use std::net::SocketAddr;
use axum::body::Bytes;
use axum::extract::{ConnectInfo, State};
use axum::http::header::{HeaderMap, HeaderValue, SET_COOKIE};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use serde_json::json;
use crate::blacklist::{blacklist, is_blacklisted, record_outstanding};
use crate::cookies::{
clear_cookie, cookie_value, set_cookie, COOKIE_ACCESS, COOKIE_REFRESH,
};
use crate::entities::prelude::*;
use crate::entities::user;
use crate::error::ApiResult;
use crate::password::verify_password;
use crate::state::AppState;
use crate::tokens::{
mint_access, mint_refresh, verify_any, verify_refresh, Minted, ACCESS_LIFETIME_SECS,
REFRESH_LIFETIME_SECS,
};
/// A well-formed pbkdf2_sha256 hash (iteration count matching `password.rs`)
/// used only to spend the same CPU on the user-not-found branch of login, so the
/// response time can't be used to enumerate usernames. Never matches any input.
const DUMMY_PASSWORD_HASH: &str =
"pbkdf2_sha256$1200000$dummytimingsalt$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pub fn router() -> Router<AppState> {
Router::new()
.route("/auth/token/", post(login))
.route("/auth/token/refresh/", post(refresh))
.route("/auth/token/verify/", post(verify))
.route("/auth/logout/", post(logout))
// Legacy alias kept for clients still calling POST /api/token/.
.route("/token/", post(login))
}
#[derive(Deserialize, Default)]
struct LoginReq {
#[serde(default)]
username: String,
#[serde(default)]
password: String,
}
/// POST /api/auth/token/ — validate credentials, set cookies.
async fn login(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<Response> {
// Brute-force guard, keyed by client IP (Django had no login throttle).
let ip = crate::web::client_ip(&headers, peer);
state
.security
.check_rate(&format!("login:ip:{ip}"), crate::throttle::LOGIN)?;
let req: LoginReq = serde_json::from_slice(&body).unwrap_or_default();
let secret = &state.config.secret_key;
let user = User::find()
.filter(user::Column::Username.eq(&req.username))
.one(&state.db)
.await?;
// Mirror SimpleJWT: a single generic 401 for missing user, bad password, or
// inactive account. Run a dummy verify on the not-found branch so response
// time doesn't reveal whether the username exists (timing enumeration).
let Some(user) = user else {
let _ = verify_password(&req.password, DUMMY_PASSWORD_HASH);
return Ok(invalid_credentials());
};
if !verify_password(&req.password, &user.password) || !user.is_active {
return Ok(invalid_credentials());
}
let refresh = mint_refresh(secret, user.id);
let access = mint_access(secret, user.id);
// RefreshToken.for_user records the outstanding token.
record_outstanding(
&state.db,
user.id,
&refresh.jti,
&refresh.token,
refresh.created_at(),
refresh.expires_at(),
)
.await?;
Ok(ok_with_cookies(state.config.jwt_cookie_secure, &access, &refresh))
}
#[derive(Deserialize, Default)]
struct RefreshReq {
refresh: Option<String>,
}
/// POST /api/auth/token/refresh/ — rotate using the body or cookie refresh token.
async fn refresh(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<Response> {
let secret = &state.config.secret_key;
let from_body = (!body.is_empty())
.then(|| serde_json::from_slice::<RefreshReq>(&body).ok())
.flatten()
.and_then(|r| r.refresh);
let token = from_body.or_else(|| read_cookie(&headers, COOKIE_REFRESH));
let Some(token) = token else {
return Ok(token_not_valid());
};
let Some(claims) = verify_refresh(&token, secret) else {
return Ok(token_not_valid());
};
if is_blacklisted(&state.db, &claims.jti).await? {
return Ok(token_not_valid());
}
// BLACKLIST_AFTER_ROTATION: blacklist the incoming token, then issue a new pair.
blacklist(
&state.db,
claims.user_id,
&claims.jti,
&token,
crate::tokens::TokenClaims::expires_naive(claims.exp),
)
.await?;
let new_refresh = mint_refresh(secret, claims.user_id);
let new_access = mint_access(secret, claims.user_id);
Ok(ok_with_cookies(
state.config.jwt_cookie_secure,
&new_access,
&new_refresh,
))
}
#[derive(Deserialize, Default)]
struct VerifyReq {
token: Option<String>,
}
/// POST /api/auth/token/verify/ — validate any token (and blacklist for refresh).
async fn verify(State(state): State<AppState>, body: Bytes) -> ApiResult<Response> {
let req: VerifyReq = serde_json::from_slice(&body).unwrap_or_default();
let Some(token) = req.token else {
// DRF serializer: missing required field → 400.
return Ok((
StatusCode::BAD_REQUEST,
Json(json!({ "token": ["This field is required."] })),
)
.into_response());
};
let Some(claims) = verify_any(&token, &state.config.secret_key) else {
return Ok(token_not_valid());
};
if is_blacklisted(&state.db, &claims.jti).await? {
return Ok(token_not_valid());
}
Ok(Json(json!({})).into_response())
}
/// POST /api/auth/logout/ — blacklist the refresh cookie and clear both cookies.
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> ApiResult<Response> {
let secret = &state.config.secret_key;
if let Some(token) = read_cookie(&headers, COOKIE_REFRESH) {
if let Some(claims) = verify_refresh(&token, secret) {
// Best-effort; a TokenError is ignored on the Django side too.
let _ = blacklist(
&state.db,
claims.user_id,
&claims.jti,
&token,
crate::tokens::TokenClaims::expires_naive(claims.exp),
)
.await;
}
}
let secure = state.config.jwt_cookie_secure;
let mut resp = Json(json!({ "status": "ok" })).into_response();
append_cookie(&mut resp, &clear_cookie(COOKIE_ACCESS, secure));
append_cookie(&mut resp, &clear_cookie(COOKIE_REFRESH, secure));
Ok(resp)
}
// --- helpers ---------------------------------------------------------------
fn read_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|h| cookie_value(h, name))
}
fn append_cookie(resp: &mut Response, value: &str) {
if let Ok(hv) = HeaderValue::from_str(value) {
resp.headers_mut().append(SET_COOKIE, hv);
}
}
fn ok_with_cookies(secure: bool, access: &Minted, refresh: &Minted) -> Response {
let mut resp = Json(json!({ "status": "ok" })).into_response();
append_cookie(
&mut resp,
&set_cookie(COOKIE_ACCESS, &access.token, ACCESS_LIFETIME_SECS, secure),
);
append_cookie(
&mut resp,
&set_cookie(COOKIE_REFRESH, &refresh.token, REFRESH_LIFETIME_SECS, secure),
);
resp
}
fn invalid_credentials() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({
"detail": "No active account found with the given credentials",
"code": "no_active_account",
})),
)
.into_response()
}
fn token_not_valid() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({
"detail": "Token is invalid or expired",
"code": "token_not_valid",
})),
)
.into_response()
}
+113
View File
@@ -0,0 +1,113 @@
use axum::extract::{Path, Query, State};
use axum::routing::get;
use axum::{Json, Router};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use crate::auth::OptionalUser;
use crate::dto::BannerOut;
use crate::entities::prelude::*;
use crate::entities::{banner, user};
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/banners/", get(list))
.route("/banners/active/", get(active))
.route("/banners/{id}/", get(retrieve))
}
fn to_dto(m: banner::Model) -> BannerOut {
BannerOut {
id: m.id,
name: m.name,
slug: m.slug,
description: m.description,
is_active: m.is_active,
is_enabled: m.is_enabled,
settings: m.settings,
schema: m.schema,
created_at: crate::datetime::drf_iso(&m.created_at),
updated_at: crate::datetime::drf_iso(&m.updated_at),
}
}
async fn is_superuser(state: &AppState, viewer: Option<i32>) -> ApiResult<bool> {
if let Some(uid) = viewer {
if let Some(u) = User::find_by_id(uid).one(&state.db).await? {
return Ok(u.is_superuser);
}
}
Ok(false)
}
/// GET /api/banners/ — unpaginated. Non-superusers see only enabled banners.
async fn list(
State(state): State<AppState>,
OptionalUser(viewer): OptionalUser,
) -> ApiResult<Json<Vec<BannerOut>>> {
let q = Banner::find();
let models = if is_superuser(&state, viewer).await? {
q.all(&state.db).await?
} else {
q.filter(banner::Column::IsEnabled.eq(true))
.all(&state.db)
.await?
};
Ok(Json(models.into_iter().map(to_dto).collect()))
}
/// GET /api/banners/{id}/
async fn retrieve(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> ApiResult<Json<BannerOut>> {
Banner::find_by_id(id)
.one(&state.db)
.await?
.map(|m| Json(to_dto(m)))
.ok_or(ApiError::NotFound)
}
#[derive(Deserialize)]
struct ActiveParam {
username: Option<String>,
}
/// GET /api/banners/active/ — the viewer's (or named user's) selected banner if
/// enabled, else the global default, else null.
async fn active(
State(state): State<AppState>,
OptionalUser(viewer): OptionalUser,
Query(params): Query<ActiveParam>,
) -> ApiResult<Json<Option<BannerOut>>> {
let mut target: Option<user::Model> = None;
if let Some(username) = params.username {
target = User::find()
.filter(user::Column::Username.eq(username))
.one(&state.db)
.await?;
}
if target.is_none() {
if let Some(uid) = viewer {
target = User::find_by_id(uid).one(&state.db).await?;
}
}
if let Some(u) = target {
if let Some(bid) = u.selected_banner_id {
if let Some(b) = Banner::find_by_id(bid).one(&state.db).await? {
if b.is_enabled {
return Ok(Json(Some(to_dto(b))));
}
}
}
}
let active = Banner::find()
.filter(banner::Column::IsActive.eq(true))
.one(&state.db)
.await?;
Ok(Json(active.map(to_dto)))
}
+63
View File
@@ -0,0 +1,63 @@
use axum::extract::{Path, Query, State};
use axum::routing::get;
use axum::{Json, Router};
use sea_orm::{EntityTrait, PaginatorTrait, QueryOrder, QuerySelect};
use serde::Deserialize;
use crate::dto::CategoryOut;
use crate::entities::category;
use crate::entities::prelude::*;
use crate::error::{ApiError, ApiResult};
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
use crate::state::AppState;
use crate::web::RequestCtx;
pub fn router() -> Router<AppState> {
Router::new()
.route("/categories/", get(list))
.route("/categories/{id}/", get(retrieve))
}
#[derive(Deserialize)]
struct PageParam {
page: Option<String>,
}
fn to_dto(m: category::Model) -> CategoryOut {
CategoryOut {
id: m.id,
name: m.name,
slug: m.slug,
}
}
/// GET /api/categories/ — paginated (DRF default), ordered by name.
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<CategoryOut>>> {
let page = parse_page(params.page.as_deref());
let size = DEFAULT_PAGE_SIZE;
let count = Category::find().count(&state.db).await?;
let models = Category::find()
.order_by_asc(category::Column::Name)
.limit(size)
.offset((page - 1) * size)
.all(&state.db)
.await?;
let results = models.into_iter().map(to_dto).collect();
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
}
/// GET /api/categories/{id}/
async fn retrieve(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> ApiResult<Json<CategoryOut>> {
Category::find_by_id(id)
.one(&state.db)
.await?
.map(|m| Json(to_dto(m)))
.ok_or(ApiError::NotFound)
}
+271
View File
@@ -0,0 +1,271 @@
use axum::extract::{Path, Query, State};
use axum::routing::get;
use axum::{Json, Router};
use sea_orm::sea_query::{Expr, Func, Order};
use sea_orm::{
ColumnTrait, Condition, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, RelationTrait,
};
use serde::Deserialize;
use crate::auth::{AuthUser, OptionalUser};
use crate::dto::{FlavorOut, SearchHit};
use crate::entities::prelude::*;
use crate::entities::{category, flavor, rating};
use crate::error::{ApiError, ApiResult};
use crate::media::image_field_url;
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
use crate::search::{extract_category_slug, query_words, score_relevance};
use crate::service::build_flavors;
use crate::state::AppState;
use crate::web::RequestCtx;
pub fn router() -> Router<AppState> {
Router::new()
.route("/flavors/", get(list))
.route("/flavors/search/", get(search))
.route("/flavors/top/", get(top))
.route("/flavors/newest/", get(newest))
.route("/flavors/followed_top/", get(followed_top))
.route("/flavors/{id}/", get(retrieve))
}
#[derive(Deserialize)]
struct ListParams {
page: Option<String>,
category: Option<i32>,
#[serde(rename = "category__slug")]
category_slug: Option<String>,
search: Option<String>,
}
/// `AVG(api_rating.score)` expression for ordering by average rating.
fn avg_score_expr() -> sea_orm::sea_query::SimpleExpr {
Func::avg(Expr::col((rating::Entity, rating::Column::Score))).into()
}
/// GET /api/flavors/ — paginated, ordered by -average_rating.
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Paginated<FlavorOut>>> {
let page = parse_page(params.page.as_deref());
let size = DEFAULT_PAGE_SIZE;
// Category slug can also come embedded in the search query (get_queryset).
let (slug_from_search, _remaining) = params
.search
.as_deref()
.map(extract_category_slug)
.unwrap_or((None, String::new()));
let mut q = Flavor::find();
if let Some(cat) = params.category {
q = q.filter(flavor::Column::CategoryId.eq(cat));
}
let slug = params.category_slug.clone().or(slug_from_search);
if let Some(slug) = slug {
q = q
.join(JoinType::InnerJoin, flavor::Relation::Category.def())
.filter(category::Column::Slug.eq(slug));
}
if let Some(search) = params.search.as_deref() {
let (_, remaining) = extract_category_slug(search);
let mut cond = Condition::any();
let mut has = false;
for w in query_words(&remaining) {
let like = format!("%{w}%");
cond = cond
.add(flavor::Column::Name.like(&like))
.add(flavor::Column::Description.like(&like));
has = true;
}
if has {
q = q.filter(cond);
}
}
let count = q.clone().count(&state.db).await?;
let models = q
.join(JoinType::LeftJoin, flavor::Relation::Rating.def())
.group_by(flavor::Column::Id)
.order_by(avg_score_expr(), Order::Desc)
.limit(size)
.offset((page - 1) * size)
.all(&state.db)
.await?;
let results = build_flavors(&state, &ctx, models, viewer, &[]).await?;
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
}
/// GET /api/flavors/{id}/
async fn retrieve(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Path(id): Path<i32>,
) -> ApiResult<Json<FlavorOut>> {
let model = Flavor::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut dtos = build_flavors(&state, &ctx, vec![model], viewer, &[]).await?;
dtos.pop().map(Json).ok_or(ApiError::NotFound)
}
#[derive(Deserialize)]
struct CategoryParam {
category: Option<String>,
}
/// GET /api/flavors/top/ — top 10 flavors that have ratings.
async fn top(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Query(params): Query<CategoryParam>,
) -> ApiResult<Json<Vec<FlavorOut>>> {
let mut q = Flavor::find().join(JoinType::InnerJoin, flavor::Relation::Rating.def());
if let Some(slug) = params.category {
q = q
.join(JoinType::InnerJoin, flavor::Relation::Category.def())
.filter(category::Column::Slug.eq(slug));
}
let models = q
.group_by(flavor::Column::Id)
.order_by(avg_score_expr(), Order::Desc)
.limit(10)
.all(&state.db)
.await?;
Ok(Json(build_flavors(&state, &ctx, models, viewer, &[]).await?))
}
/// GET /api/flavors/newest/ — 10 most recently created flavors.
async fn newest(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
) -> ApiResult<Json<Vec<FlavorOut>>> {
let models = Flavor::find()
.order_by_desc(flavor::Column::CreatedAt)
.limit(10)
.all(&state.db)
.await?;
Ok(Json(build_flavors(&state, &ctx, models, viewer, &[]).await?))
}
/// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the
/// caller follows. Requires authentication.
async fn followed_top(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
) -> ApiResult<Json<Vec<FlavorOut>>> {
// Followed user ids from the m2m table api_user_following(from_user_id, to_user_id).
use sea_orm::{ConnectionTrait, Statement};
let stmt = Statement::from_sql_and_values(
state.db.get_database_backend(),
"SELECT to_user_id FROM api_user_following WHERE from_user_id = ?",
[uid.into()],
);
let rows = state.db.query_all(stmt).await?;
let followed: Vec<i32> = rows
.iter()
.filter_map(|r| r.try_get::<i32>("", "to_user_id").ok())
.collect();
if followed.is_empty() {
return Ok(Json(Vec::new()));
}
let models = Flavor::find()
.join(JoinType::InnerJoin, flavor::Relation::Rating.def())
.filter(rating::Column::UserId.is_in(followed))
.group_by(flavor::Column::Id)
.order_by(avg_score_expr(), Order::Desc)
.limit(10)
.all(&state.db)
.await?;
Ok(Json(build_flavors(&state, &ctx, models, Some(uid), &[]).await?))
}
#[derive(Deserialize)]
struct SearchParam {
q: Option<String>,
}
/// GET /api/flavors/search/?q= — up to 15 ranked lightweight hits.
async fn search(
State(state): State<AppState>,
ctx: RequestCtx,
Query(params): Query<SearchParam>,
) -> ApiResult<Json<Vec<SearchHit>>> {
let media = &state.config.media_url;
let query = params.q.unwrap_or_default().to_lowercase();
let query = query.trim();
if query.is_empty() {
return Ok(Json(Vec::new()));
}
let (slug, remaining) = extract_category_slug(query);
// find_also_related(Category) adds the join; don't add a second one.
let mut q = Flavor::find();
if let Some(slug) = slug {
q = q.filter(category::Column::Slug.eq(slug));
}
let words = query_words(&remaining);
if !words.is_empty() {
let mut cond = Condition::any();
for w in &words {
let like = format!("%{w}%");
cond = cond
.add(flavor::Column::Name.like(&like))
.add(flavor::Column::Description.like(&like));
}
q = q.filter(cond);
}
// (flavor, category) joined load, ordered by name.
let rows = q
.order_by_asc(flavor::Column::Name)
.find_also_related(Category)
.all(&state.db)
.await?;
let mut seen = std::collections::HashSet::new();
let mut hits: Vec<(i32, SearchHit)> = Vec::new();
for (f, cat) in rows {
let cat = match cat {
Some(c) => c,
None => continue,
};
let key = format!("{}|{}", f.name.trim().to_lowercase(), cat.slug);
if !seen.insert(key) {
continue;
}
let image_url = if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) {
Some(image_field_url(&ctx, media, img))
} else {
f.image_url.clone()
};
let relevance = score_relevance(&f.name, &remaining);
hits.push((
relevance,
SearchHit {
id: f.id,
name: f.name,
kind: "flavor".to_string(),
subtitle: cat.name,
image_url,
slug: None,
},
));
}
// Sort by -relevance, then name.
hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.name.cmp(&b.1.name)));
let out: Vec<SearchHit> = hits.into_iter().take(15).map(|(_, h)| h).collect();
Ok(Json(out))
}
+31
View File
@@ -0,0 +1,31 @@
pub mod admin;
pub mod auth;
pub mod banners;
pub mod categories;
pub mod flavors;
pub mod ratings;
pub mod replies;
pub mod social;
pub mod tickets;
pub mod users;
use axum::Router;
use crate::state::AppState;
/// All ported endpoints, mounted under `/api`. Paths carry DRF's trailing
/// slash; axum 0.8 does not auto-redirect, and the frontend calls with the
/// slash, so routes are declared with it explicitly.
pub fn api_router() -> Router<AppState> {
Router::new()
.merge(auth::router())
.merge(categories::router())
.merge(flavors::router())
.merge(ratings::router())
.merge(replies::router())
.merge(users::router())
.merge(social::router())
.merge(tickets::router())
.merge(banners::router())
.merge(admin::router())
}
+467
View File
@@ -0,0 +1,467 @@
//! Port of `RatingViewSet` (`api/views/rating.py`). Writes go through the shared
//! SQLite file, so inserts/updates here are visible to Django and vice versa.
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{Method, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, Statement, TransactionTrait,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::dto::{RatingOut, ReplyOut};
use crate::entities::prelude::*;
use crate::entities::{notification, rating, reply, user};
use crate::error::{ApiError, ApiResult};
use crate::mentions::parse_mentions;
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
use crate::service::{build_ratings, build_replies};
use crate::state::AppState;
use crate::web::RequestCtx;
const FEED_PAGE_SIZE: u64 = 10;
pub fn router() -> Router<AppState> {
Router::new()
.route("/ratings/", get(list).post(create))
.route("/ratings/feed/", get(feed))
.route("/ratings/recent/", get(recent))
.route(
"/ratings/{id}/",
get(retrieve).put(update).patch(update).delete(destroy),
)
.route("/ratings/{id}/reply/", post(reply))
}
#[derive(Deserialize)]
struct PageParam {
page: Option<String>,
}
/// Load the acting user row (for superuser checks). 401 if the id is stale.
async fn load_actor(state: &AppState, uid: i32) -> ApiResult<user::Model> {
User::find_by_id(uid)
.one(&state.db)
.await?
.ok_or(ApiError::Unauthorized)
}
/// Read an integer from a JSON value (accepts only JSON numbers, like DRF).
fn as_i32(v: &Value) -> Option<i32> {
v.as_i64().and_then(|n| i32::try_from(n).ok())
}
/// GET /api/ratings/ — paginated, ordered by -created_at (IsAuthenticatedOrReadOnly).
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<RatingOut>>> {
let page = parse_page(params.page.as_deref());
let size = DEFAULT_PAGE_SIZE;
let count = Rating::find().count(&state.db).await?;
let models = Rating::find()
.order_by_desc(rating::Column::CreatedAt)
.limit(size)
.offset((page - 1) * size)
.all(&state.db)
.await?;
let results = build_ratings(&state, &ctx, models).await?;
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
}
/// GET /api/ratings/{id}/
async fn retrieve(
State(state): State<AppState>,
ctx: RequestCtx,
Path(id): Path<i32>,
) -> ApiResult<Json<RatingOut>> {
let model = Rating::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
dtos.pop().map(Json).ok_or(ApiError::NotFound)
}
/// POST /api/ratings/ — create a rating for the authenticated user.
async fn create(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
body: Bytes,
) -> ApiResult<(StatusCode, Json<RatingOut>)> {
// django_ratelimit(key="user", rate="10/m") on RatingViewSet.create.
state
.security
.check_rate(&format!("rating:user:{uid}"), crate::throttle::RATING_CREATE)?;
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
// DRF validation, aggregated and field-keyed, in serializer field order.
let mut errors = serde_json::Map::new();
// flavor (PrimaryKeyRelatedField, required)
let flavor_id = match data.get("flavor") {
None | Some(Value::Null) => {
errors.insert("flavor".into(), json!(["This field is required."]));
None
}
Some(v) => match as_i32(v) {
Some(id) => Some(id),
None => {
errors.insert(
"flavor".into(),
json!(["Incorrect type. Expected pk value, received str."]),
);
None
}
},
};
// score (IntegerField, 1..=10, required)
let score = match data.get("score") {
None | Some(Value::Null) => {
errors.insert("score".into(), json!(["This field is required."]));
None
}
Some(v) => match as_i32(v) {
Some(s) if s < 1 => {
errors.insert(
"score".into(),
json!(["Ensure this value is greater than or equal to 1."]),
);
None
}
Some(s) if s > 10 => {
errors.insert(
"score".into(),
json!(["Ensure this value is less than or equal to 10."]),
);
None
}
Some(s) => Some(s),
None => {
errors.insert("score".into(), json!(["A valid integer is required."]));
None
}
},
};
// comment (optional TextField)
let comment = match data.get("comment") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(s.clone()),
Some(_) => {
errors.insert("comment".into(), json!(["Not a valid string."]));
None
}
};
// Flavor must exist (PrimaryKeyRelatedField pk check).
if let Some(fid) = flavor_id {
if !errors.contains_key("flavor") {
let exists = Flavor::find_by_id(fid).one(&state.db).await?.is_some();
if !exists {
errors.insert(
"flavor".into(),
json!([format!("Invalid pk \"{fid}\" - object does not exist.")]),
);
}
}
}
if !errors.is_empty() {
return Err(ApiError::Validation(Value::Object(errors)));
}
let (flavor_id, score) = (flavor_id.unwrap(), score.unwrap());
// perform_create: reject a second rating of the same flavor by this user.
let dup = Rating::find()
.filter(rating::Column::UserId.eq(uid))
.filter(rating::Column::FlavorId.eq(flavor_id))
.one(&state.db)
.await?
.is_some();
if dup {
return Err(ApiError::Validation(json!([
"You have already rated this flavor."
])));
}
let now = crate::datetime::now_micros();
let model = rating::ActiveModel {
id: NotSet,
user_id: Set(uid),
flavor_id: Set(flavor_id),
score: Set(score),
comment: Set(comment.clone()),
created_at: Set(now),
}
.insert(&state.db)
.await?;
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
parse_mentions(&state.db, text, uid, Some(model.id), None).await?;
}
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
let out = dtos.pop().ok_or(ApiError::Internal)?;
Ok((StatusCode::CREATED, Json(out)))
}
/// PUT/PATCH /api/ratings/{id}/ — owner or superuser only. No mention parsing
/// (Django's perform_update is the default; mentions only fire on create).
async fn update(
State(state): State<AppState>,
ctx: RequestCtx,
method: Method,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<Json<RatingOut>> {
let existing = Rating::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let actor = load_actor(&state, uid).await?;
if existing.user_id != uid && !actor.is_superuser {
return Err(ApiError::Forbidden(
"You cannot edit/delete this rating.".into(),
));
}
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let partial = method == Method::PATCH;
let mut errors = serde_json::Map::new();
let mut active: rating::ActiveModel = existing.clone().into();
// flavor
match data.get("flavor") {
Some(Value::Null) | None if !partial => {
errors.insert("flavor".into(), json!(["This field is required."]));
}
Some(v) if !v.is_null() => match as_i32(v) {
Some(fid) => {
if Flavor::find_by_id(fid).one(&state.db).await?.is_some() {
active.flavor_id = Set(fid);
} else {
errors.insert(
"flavor".into(),
json!([format!("Invalid pk \"{fid}\" - object does not exist.")]),
);
}
}
None => {
errors.insert(
"flavor".into(),
json!(["Incorrect type. Expected pk value, received str."]),
);
}
},
_ => {}
}
// score
match data.get("score") {
Some(Value::Null) | None if !partial => {
errors.insert("score".into(), json!(["This field is required."]));
}
Some(v) if !v.is_null() => match as_i32(v) {
Some(s) if s < 1 => {
errors.insert(
"score".into(),
json!(["Ensure this value is greater than or equal to 1."]),
);
}
Some(s) if s > 10 => {
errors.insert(
"score".into(),
json!(["Ensure this value is less than or equal to 10."]),
);
}
Some(s) => active.score = Set(s),
None => {
errors.insert("score".into(), json!(["A valid integer is required."]));
}
},
_ => {}
}
// comment
match data.get("comment") {
Some(Value::String(s)) => active.comment = Set(Some(s.clone())),
Some(Value::Null) => active.comment = Set(None),
Some(_) => {
errors.insert("comment".into(), json!(["Not a valid string."]));
}
None => {}
}
if !errors.is_empty() {
return Err(ApiError::Validation(Value::Object(errors)));
}
let model = active.update(&state.db).await?;
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
dtos.pop().map(Json).ok_or(ApiError::Internal)
}
/// DELETE /api/ratings/{id}/ — owner or superuser only.
async fn destroy(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<StatusCode> {
let existing = Rating::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let actor = load_actor(&state, uid).await?;
if existing.user_id != uid && !actor.is_superuser {
return Err(ApiError::Forbidden(
"You cannot edit/delete this rating.".into(),
));
}
// Django on_delete=CASCADE is emulated in Python, not enforced by SQLite FKs,
// so replicate the collector: drop notifications pointing at this rating or
// any of its replies, then the replies, then the rating itself.
let txn = state.db.begin().await?;
let reply_ids: Vec<i32> = Reply::find()
.filter(reply::Column::RatingId.eq(id))
.all(&txn)
.await?
.into_iter()
.map(|r| r.id)
.collect();
Notification::delete_many()
.filter(
Condition::any()
.add(notification::Column::RatingId.eq(id))
.add(notification::Column::ReplyId.is_in(reply_ids)),
)
.exec(&txn)
.await?;
Reply::delete_many()
.filter(reply::Column::RatingId.eq(id))
.exec(&txn)
.await?;
Rating::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(StatusCode::NO_CONTENT)
}
/// GET /api/ratings/feed/ — ratings by followed users, FeedPagination (size 10).
async fn feed(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<RatingOut>>> {
let page = parse_page(params.page.as_deref());
let size = FEED_PAGE_SIZE;
// Followed user ids from api_user_following(from_user_id, to_user_id).
let stmt = Statement::from_sql_and_values(
state.db.get_database_backend(),
"SELECT to_user_id FROM api_user_following WHERE from_user_id = ?",
[uid.into()],
);
let rows = state.db.query_all(stmt).await?;
let followed: Vec<i32> = rows
.iter()
.filter_map(|r| r.try_get::<i32>("", "to_user_id").ok())
.collect();
if followed.is_empty() {
return Ok(Json(Paginated::build(&ctx, Vec::new(), 0, page, size)));
}
let count = Rating::find()
.filter(rating::Column::UserId.is_in(followed.clone()))
.count(&state.db)
.await?;
let models = Rating::find()
.filter(rating::Column::UserId.is_in(followed))
.order_by_desc(rating::Column::CreatedAt)
.limit(size)
.offset((page - 1) * size)
.all(&state.db)
.await?;
let results = build_ratings(&state, &ctx, models).await?;
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
}
/// GET /api/ratings/recent/ — 10 newest with a non-empty comment (AllowAny).
async fn recent(
State(state): State<AppState>,
ctx: RequestCtx,
) -> ApiResult<Json<Vec<RatingOut>>> {
let models = Rating::find()
.filter(rating::Column::Comment.is_not_null())
.filter(rating::Column::Comment.ne(""))
.order_by_desc(rating::Column::CreatedAt)
.limit(10)
.all(&state.db)
.await?;
Ok(Json(build_ratings(&state, &ctx, models).await?))
}
/// POST /api/ratings/{id}/reply/ — add a reply, notify the rating owner, and
/// fan out @mentions.
async fn reply(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<(StatusCode, Json<ReplyOut>)> {
let rating = Rating::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let text = data.get("text").and_then(|v| v.as_str()).unwrap_or("");
if text.is_empty() {
return Err(ApiError::Validation(json!({ "error": "Text is required" })));
}
let now = crate::datetime::now_micros();
let reply_model = reply::ActiveModel {
id: NotSet,
user_id: Set(uid),
rating_id: Set(rating.id),
text: Set(text.to_string()),
created_at: Set(now),
}
.insert(&state.db)
.await?;
if rating.user_id != uid {
notification::ActiveModel {
id: NotSet,
is_read: Set(false),
created_at: Set(now),
actor_id: Set(uid),
rating_id: Set(Some(rating.id)),
recipient_id: Set(rating.user_id),
reply_id: Set(Some(reply_model.id)),
ticket_id: Set(None),
notification_type: Set("reply".to_string()),
profile_comment_id: Set(None),
}
.insert(&state.db)
.await?;
}
parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?;
let mut dtos = build_replies(&state, vec![reply_model]).await?;
let out = dtos.pop().ok_or(ApiError::Internal)?;
Ok((StatusCode::CREATED, Json(out)))
}
+157
View File
@@ -0,0 +1,157 @@
//! Port of `ReplyViewSet` (`api/views/rating.py`). All actions require
//! authentication. Creation is intentionally not exposed: Django marks `rating`
//! read-only on the serializer, so replies are only ever made via the rating's
//! `reply` action — the frontend never POSTs to `/replies/`.
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{Method, StatusCode};
use axum::routing::get;
use axum::{Json, Router};
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, TransactionTrait,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::dto::ReplyOut;
use crate::entities::prelude::*;
use crate::entities::{notification, reply, user};
use crate::error::{ApiError, ApiResult};
use crate::mentions::parse_mentions;
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
use crate::service::build_replies;
use crate::state::AppState;
use crate::web::RequestCtx;
pub fn router() -> Router<AppState> {
Router::new()
.route("/replies/", get(list))
.route(
"/replies/{id}/",
get(retrieve).put(update).patch(update).delete(destroy),
)
}
#[derive(Deserialize)]
struct PageParam {
page: Option<String>,
}
async fn load_actor(state: &AppState, uid: i32) -> ApiResult<user::Model> {
User::find_by_id(uid)
.one(&state.db)
.await?
.ok_or(ApiError::Unauthorized)
}
/// GET /api/replies/ — paginated, ordered by created_at asc.
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(_uid): AuthUser,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<ReplyOut>>> {
let page = parse_page(params.page.as_deref());
let size = DEFAULT_PAGE_SIZE;
let count = Reply::find().count(&state.db).await?;
let models = Reply::find()
.order_by_asc(reply::Column::CreatedAt)
.limit(size)
.offset((page - 1) * size)
.all(&state.db)
.await?;
let results = build_replies(&state, models).await?;
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
}
/// GET /api/replies/{id}/
async fn retrieve(
State(state): State<AppState>,
AuthUser(_uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<Json<ReplyOut>> {
let model = Reply::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut dtos = build_replies(&state, vec![model]).await?;
dtos.pop().map(Json).ok_or(ApiError::NotFound)
}
/// PUT/PATCH /api/replies/{id}/ — owner or superuser; re-parses @mentions.
async fn update(
State(state): State<AppState>,
method: Method,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<Json<ReplyOut>> {
let existing = Reply::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let actor = load_actor(&state, uid).await?;
if existing.user_id != uid && !actor.is_superuser {
return Err(ApiError::Forbidden("You cannot edit this reply.".into()));
}
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let partial = method == Method::PATCH;
let mut active: reply::ActiveModel = existing.clone().into();
match data.get("text") {
Some(Value::String(s)) if !s.is_empty() => active.text = Set(s.clone()),
Some(Value::String(_)) => {
return Err(ApiError::Validation(
json!({ "text": ["This field may not be blank."] }),
));
}
Some(Value::Null) | None if !partial => {
return Err(ApiError::Validation(
json!({ "text": ["This field is required."] }),
));
}
Some(v) if !v.is_null() => {
return Err(ApiError::Validation(
json!({ "text": ["Not a valid string."] }),
));
}
_ => {}
}
let model = active.update(&state.db).await?;
parse_mentions(&state.db, &model.text, uid, Some(model.rating_id), Some(model.id)).await?;
let mut dtos = build_replies(&state, vec![model]).await?;
dtos.pop().map(Json).ok_or(ApiError::Internal)
}
/// DELETE /api/replies/{id}/ — owner or superuser.
async fn destroy(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<StatusCode> {
let existing = Reply::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let actor = load_actor(&state, uid).await?;
if existing.user_id != uid && !actor.is_superuser {
return Err(ApiError::Forbidden("You cannot delete this reply.".into()));
}
// Emulate Django's CASCADE: clear notifications referencing this reply first
// (SQLite FKs are enforced but have no ON DELETE CASCADE).
let txn = state.db.begin().await?;
Notification::delete_many()
.filter(notification::Column::ReplyId.eq(id))
.exec(&txn)
.await?;
Reply::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(StatusCode::NO_CONTENT)
}
+90
View File
@@ -0,0 +1,90 @@
//! Port of `NotificationViewSet` (`api/views/user.py`). Read-only list/retrieve
//! of the caller's own notifications (no pagination — plain array), plus
//! mark-read actions. All actions require authentication.
use axum::extract::{Path, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
};
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::dto::NotificationOut;
use crate::entities::prelude::*;
use crate::entities::notification;
use crate::error::{ApiError, ApiResult};
use crate::service::build_notifications;
use crate::state::AppState;
use crate::web::RequestCtx;
pub fn router() -> Router<AppState> {
Router::new()
.route("/notifications/", get(list))
.route("/notifications/mark_all_read/", post(mark_all_read))
.route("/notifications/{id}/", get(retrieve))
.route("/notifications/{id}/mark_read/", post(mark_read))
}
/// GET /api/notifications/ — caller's notifications, newest first (no pagination).
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
) -> ApiResult<Json<Vec<NotificationOut>>> {
let models = Notification::find()
.filter(notification::Column::RecipientId.eq(uid))
.order_by_desc(notification::Column::CreatedAt)
.all(&state.db)
.await?;
Ok(Json(build_notifications(&state, &ctx, models).await?))
}
/// GET /api/notifications/{id}/ — own only (404 otherwise).
async fn retrieve(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<Json<NotificationOut>> {
let n = Notification::find_by_id(id)
.filter(notification::Column::RecipientId.eq(uid))
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut dtos = build_notifications(&state, &ctx, vec![n]).await?;
dtos.pop().map(Json).ok_or(ApiError::NotFound)
}
/// POST /api/notifications/mark_all_read/
async fn mark_all_read(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
) -> ApiResult<Json<Value>> {
Notification::update_many()
.col_expr(notification::Column::IsRead, sea_orm::sea_query::Expr::value(true))
.filter(notification::Column::RecipientId.eq(uid))
.filter(notification::Column::IsRead.eq(false))
.exec(&state.db)
.await?;
Ok(Json(json!({ "status": "all read" })))
}
/// POST /api/notifications/{id}/mark_read/ — own only.
async fn mark_read(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<Json<Value>> {
let n = Notification::find_by_id(id)
.filter(notification::Column::RecipientId.eq(uid))
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut active: notification::ActiveModel = n.into();
active.is_read = Set(true);
active.update(&state.db).await?;
Ok(Json(json!({ "status": "marked read" })))
}
+337
View File
@@ -0,0 +1,337 @@
//! Port of `TicketViewSet` (`api/views/support.py`). All actions require
//! authentication; non-superusers only see/act on their own tickets. Deletion
//! is superuser-only. `update_status` requires staff (DRF `IsAdminUser`).
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{Method, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, TransactionTrait,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::dto::{TicketMessageOut, TicketOut};
use crate::entities::prelude::*;
use crate::entities::{notification, ticket, ticket_message, user};
use crate::error::{ApiError, ApiResult};
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
use crate::service::{build_tickets, ticket_message_out};
use crate::state::AppState;
use crate::web::RequestCtx;
pub fn router() -> Router<AppState> {
Router::new()
.route("/tickets/", get(list).post(create))
.route(
"/tickets/{id}/",
get(retrieve).put(update).patch(update).delete(destroy),
)
.route("/tickets/{id}/add_message/", post(add_message))
.route("/tickets/{id}/update_status/", post(update_status))
}
#[derive(Deserialize)]
struct PageParam {
page: Option<String>,
}
async fn load_user(state: &AppState, uid: i32) -> ApiResult<user::Model> {
User::find_by_id(uid)
.one(&state.db)
.await?
.ok_or(ApiError::Unauthorized)
}
const STATUSES: &[&str] = &["open", "in_progress", "resolved", "closed"];
/// Tickets visible to `me`: all for superusers, own otherwise (-updated_at).
fn scoped_query(me: &user::Model) -> sea_orm::Select<Ticket> {
let q = Ticket::find().order_by_desc(ticket::Column::UpdatedAt);
if me.is_superuser {
q
} else {
q.filter(ticket::Column::UserId.eq(me.id))
}
}
/// GET /api/tickets/
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<TicketOut>>> {
let me = load_user(&state, uid).await?;
let page = parse_page(params.page.as_deref());
let size = DEFAULT_PAGE_SIZE;
let count = scoped_query(&me).count(&state.db).await?;
let models = scoped_query(&me)
.limit(size)
.offset((page - 1) * size)
.all(&state.db)
.await?;
let results = build_tickets(&state, &ctx, models).await?;
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
}
/// GET /api/tickets/{id}/
async fn retrieve(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<Json<TicketOut>> {
let me = load_user(&state, uid).await?;
let t = scoped_query(&me)
.filter(ticket::Column::Id.eq(id))
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
dtos.pop().map(Json).ok_or(ApiError::NotFound)
}
fn require_str(data: &Value, field: &str, partial: bool, max: Option<usize>) -> ApiResult<Option<String>> {
match data.get(field) {
Some(Value::String(s)) if !s.is_empty() => {
if let Some(m) = max {
if s.chars().count() > m {
return Err(ApiError::Validation(json!({
field: [format!("Ensure this field has no more than {m} characters.")]
})));
}
}
Ok(Some(s.clone()))
}
Some(Value::String(_)) => Err(ApiError::Validation(
json!({ field: ["This field may not be blank."] }),
)),
Some(v) if !v.is_null() => {
Err(ApiError::Validation(json!({ field: ["Not a valid string."] })))
}
_ if partial => Ok(None),
_ => Err(ApiError::Validation(
json!({ field: ["This field is required."] }),
)),
}
}
/// POST /api/tickets/ — create + notify other admins (ticket_new).
async fn create(
State(state): State<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
body: Bytes,
) -> ApiResult<(StatusCode, Json<TicketOut>)> {
let me = load_user(&state, uid).await?;
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let subject = require_str(&data, "subject", false, Some(200))?.unwrap();
let description = require_str(&data, "description", false, None)?.unwrap();
let now = crate::datetime::now_micros();
let t = ticket::ActiveModel {
subject: Set(subject),
description: Set(description),
status: Set("open".into()),
created_at: Set(now),
updated_at: Set(now),
user_id: Set(me.id),
..Default::default()
}
.insert(&state.db)
.await?;
// Notify every other admin.
let admins = User::find()
.filter(user::Column::IsSuperuser.eq(true))
.filter(user::Column::Id.ne(me.id))
.all(&state.db)
.await?;
for admin in admins {
notification::ActiveModel {
is_read: Set(false),
created_at: Set(crate::datetime::now_micros()),
actor_id: Set(me.id),
recipient_id: Set(admin.id),
ticket_id: Set(Some(t.id)),
notification_type: Set("ticket_new".into()),
..Default::default()
}
.insert(&state.db)
.await?;
}
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
Ok((StatusCode::CREATED, Json(dtos.pop().ok_or(ApiError::Internal)?)))
}
/// PUT/PATCH /api/tickets/{id}/ — subject/description only (status, user read-only).
async fn update(
State(state): State<AppState>,
ctx: RequestCtx,
method: Method,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<Json<TicketOut>> {
let me = load_user(&state, uid).await?;
let existing = scoped_query(&me)
.filter(ticket::Column::Id.eq(id))
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let partial = method == Method::PATCH;
let mut active: ticket::ActiveModel = existing.into();
if let Some(s) = require_str(&data, "subject", partial, Some(200))? {
active.subject = Set(s);
}
if let Some(s) = require_str(&data, "description", partial, None)? {
active.description = Set(s);
}
active.updated_at = Set(crate::datetime::now_micros());
let t = active.update(&state.db).await?;
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
dtos.pop().map(Json).ok_or(ApiError::Internal)
}
/// DELETE /api/tickets/{id}/ — superuser only; cascades messages + notifications.
async fn destroy(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
) -> ApiResult<StatusCode> {
let me = load_user(&state, uid).await?;
if !me.is_superuser {
return Err(ApiError::Forbidden("Only admins can delete tickets.".into()));
}
// get_object is scoped; superusers see all, so a missing ticket is 404.
Ticket::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let txn = state.db.begin().await?;
Notification::delete_many()
.filter(notification::Column::TicketId.eq(id))
.exec(&txn)
.await?;
TicketMessage::delete_many()
.filter(ticket_message::Column::TicketId.eq(id))
.exec(&txn)
.await?;
Ticket::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(StatusCode::NO_CONTENT)
}
/// POST /api/tickets/{id}/add_message/
async fn add_message(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<(StatusCode, Json<TicketMessageOut>)> {
let me = load_user(&state, uid).await?;
let t = scoped_query(&me)
.filter(ticket::Column::Id.eq(id))
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let text = match data.get("text") {
Some(Value::String(s)) if !s.is_empty() => s.clone(),
_ => return Err(ApiError::Validation(json!({ "error": "Text is required" }))),
};
let msg = ticket_message::ActiveModel {
text: Set(text),
created_at: Set(crate::datetime::now_micros()),
ticket_id: Set(t.id),
user_id: Set(me.id),
..Default::default()
}
.insert(&state.db)
.await?;
// Notify the counterparty: admin → ticket owner; user → all other admins.
if me.is_superuser {
if t.user_id != me.id {
notify_ticket(&state, me.id, t.user_id, t.id, "ticket_reply").await?;
}
} else {
let admins = User::find()
.filter(user::Column::IsSuperuser.eq(true))
.filter(user::Column::Id.ne(me.id))
.all(&state.db)
.await?;
for admin in admins {
notify_ticket(&state, me.id, admin.id, t.id, "ticket_reply").await?;
}
}
Ok((StatusCode::CREATED, Json(ticket_message_out(msg, &me))))
}
async fn notify_ticket(
state: &AppState,
actor: i32,
recipient: i32,
ticket_id: i32,
kind: &str,
) -> ApiResult<()> {
notification::ActiveModel {
is_read: Set(false),
created_at: Set(crate::datetime::now_micros()),
actor_id: Set(actor),
recipient_id: Set(recipient),
ticket_id: Set(Some(ticket_id)),
notification_type: Set(kind.into()),
..Default::default()
}
.insert(&state.db)
.await?;
Ok(())
}
/// POST /api/tickets/{id}/update_status/ — staff only.
async fn update_status(
State(state): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<Json<Value>> {
let me = load_user(&state, uid).await?;
if !me.is_staff {
return Err(ApiError::Forbidden(
"You do not have permission to perform this action.".into(),
));
}
// update_status uses get_object on the (admin) scoped queryset → all tickets.
let t = Ticket::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let new_status = data.get("status").and_then(|v| v.as_str());
match new_status {
Some(s) if STATUSES.contains(&s) => {
let mut active: ticket::ActiveModel = t.into();
active.status = Set(s.to_string());
active.updated_at = Set(crate::datetime::now_micros());
active.update(&state.db).await?;
Ok(Json(json!({ "status": "updated" })))
}
_ => Err(ApiError::Validation(json!({ "error": "Invalid status" }))),
}
}
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
//! Port of `api/services/search.py` (category-keyword extraction + relevance).
/// (keyword, category-slug) pairs, matched as substrings of the query.
const CATEGORY_KEYWORDS: &[(&str, &str)] = &[
("iced tea", "iced-tea"),
("eistee", "iced-tea"),
("energy", "energy"),
("hydration", "hydration"),
("milkshake", "milkshake"),
];
/// Returns (matched category slug, remaining query with keyword removed).
pub fn extract_category_slug(query: &str) -> (Option<String>, String) {
let lowered = query.to_lowercase();
let lowered = lowered.trim();
for (kw, slug) in CATEGORY_KEYWORDS {
if lowered.contains(kw) {
let remaining = lowered.replace(kw, "");
return (Some(slug.to_string()), remaining.trim().to_string());
}
}
(None, lowered.to_string())
}
/// Significant (len > 2) words from the remaining query, for icontains matching.
pub fn query_words(remaining: &str) -> Vec<String> {
remaining
.split_whitespace()
.filter(|w| w.len() > 2)
.map(|w| w.to_string())
.collect()
}
pub fn score_relevance(name: &str, query: &str) -> i32 {
let name = name.to_lowercase();
if name == query {
3
} else if name.starts_with(query) {
2
} else if name.contains(query) {
1
} else {
0
}
}
+744
View File
@@ -0,0 +1,744 @@
//! Assembly of `FlavorOut` (with nested ratings/replies) from entity models,
//! batching related lookups to avoid N+1. Mirrors FlavorSerializer +
//! RatingSerializer behaviour.
use std::collections::{BTreeMap, HashMap};
use sea_orm::{
ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, QueryFilter, QueryOrder, Statement,
};
use serde_json::Value;
use crate::dto::{
FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut, TicketOut,
UserOut,
};
use crate::entities::prelude::*;
use crate::entities::{category, flavor, notification, profile_comment, rating, reply, ticket, ticket_message, user};
use crate::error::ApiResult;
use crate::media::{flavor_image_list, flavor_primary_image, image_field_url};
use crate::state::AppState;
use crate::web::RequestCtx;
/// Comma-join i32 ids for an inlined SQL `IN (...)` list. Safe: ints only.
fn id_list(ids: &[i32]) -> String {
ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",")
}
/// Optional avatar → absolute media URL (empty path treated as absent).
fn avatar_url(ctx: &RequestCtx, media: &str, avatar: &Option<String>) -> Option<String> {
avatar
.as_deref()
.filter(|s| !s.is_empty())
.map(|a| image_field_url(ctx, media, a))
}
/// Django JSONField (list) stored as TEXT → Vec<String>, tolerating null/non-array.
fn json_string_list(v: &Option<Value>) -> Vec<String> {
match v {
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
fn empty_distribution() -> BTreeMap<String, i64> {
(1..=10).map(|i| (i.to_string(), 0i64)).collect()
}
/// Build `ReplyOut`s for a flat list of replies, resolving each author's
/// username (StringRelatedField). Mirrors `ReplySerializer`.
pub async fn build_replies(
state: &AppState,
replies: Vec<reply::Model>,
) -> ApiResult<Vec<ReplyOut>> {
if replies.is_empty() {
return Ok(Vec::new());
}
let mut user_ids: Vec<i32> = replies.iter().map(|r| r.user_id).collect();
user_ids.sort_unstable();
user_ids.dedup();
let users = User::find()
.filter(user::Column::Id.is_in(user_ids))
.all(&state.db)
.await?;
let names: HashMap<i32, String> = users.into_iter().map(|u| (u.id, u.username)).collect();
Ok(replies
.into_iter()
.map(|rp| ReplyOut {
id: rp.id,
user: names.get(&rp.user_id).cloned().unwrap_or_default(),
rating: rp.rating_id,
text: rp.text,
created_at: crate::datetime::drf_iso(&rp.created_at),
})
.collect())
}
/// Build `RatingOut`s for a flat list of ratings, preserving input order and
/// resolving flavor/category/author/replies. Mirrors `RatingSerializer`.
/// Used by the rating list/feed/recent/retrieve/create endpoints.
pub async fn build_ratings(
state: &AppState,
ctx: &RequestCtx,
ratings: Vec<rating::Model>,
) -> ApiResult<Vec<RatingOut>> {
let media = &state.config.media_url;
if ratings.is_empty() {
return Ok(Vec::new());
}
let flavor_ids: Vec<i32> = ratings.iter().map(|r| r.flavor_id).collect();
let rating_ids: Vec<i32> = ratings.iter().map(|r| r.id).collect();
let flavors = Flavor::find()
.filter(flavor::Column::Id.is_in(flavor_ids))
.all(&state.db)
.await?;
let flavor_map: HashMap<i32, flavor::Model> = flavors.into_iter().map(|f| (f.id, f)).collect();
let category_ids: Vec<i32> = flavor_map.values().map(|f| f.category_id).collect();
let cats = Category::find()
.filter(category::Column::Id.is_in(category_ids))
.all(&state.db)
.await?;
let cat_map: HashMap<i32, (String, String)> =
cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect();
let replies = Reply::find()
.filter(reply::Column::RatingId.is_in(rating_ids))
.order_by_asc(reply::Column::CreatedAt)
.all(&state.db)
.await?;
let mut user_ids: Vec<i32> = ratings.iter().map(|r| r.user_id).collect();
user_ids.extend(replies.iter().map(|r| r.user_id));
user_ids.sort_unstable();
user_ids.dedup();
let users = User::find()
.filter(user::Column::Id.is_in(user_ids))
.all(&state.db)
.await?;
let user_map: HashMap<i32, user::Model> = users.into_iter().map(|u| (u.id, u)).collect();
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
for rp in &replies {
let username = user_map
.get(&rp.user_id)
.map(|u| u.username.clone())
.unwrap_or_default();
replies_by_rating
.entry(rp.rating_id)
.or_default()
.push(ReplyOut {
id: rp.id,
user: username,
rating: rp.rating_id,
text: rp.text.clone(),
created_at: crate::datetime::drf_iso(&rp.created_at),
});
}
let mut out = Vec::with_capacity(ratings.len());
for r in &ratings {
let user = user_map.get(&r.user_id);
let username = user.map(|u| u.username.clone()).unwrap_or_default();
let user_avatar = user
.and_then(|u| u.avatar.as_deref())
.filter(|s| !s.is_empty())
.map(|a| image_field_url(ctx, media, a));
let fl = flavor_map.get(&r.flavor_id);
let (cat_name, cat_slug) = fl
.and_then(|f| cat_map.get(&f.category_id))
.cloned()
.unwrap_or_default();
let flavor_image = fl.and_then(|f| {
if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) {
Some(image_field_url(ctx, media, img))
} else {
f.image_url.clone()
}
});
out.push(RatingOut {
id: r.id,
user: username,
user_id: r.user_id,
user_avatar,
flavor: r.flavor_id,
flavor_name: fl.map(|f| f.name.clone()).unwrap_or_default(),
flavor_image,
category_name: cat_name,
category_slug: cat_slug,
is_available: fl.map(|f| f.is_available).unwrap_or(false),
is_legacy: fl.map(|f| f.is_legacy).unwrap_or(false),
score: r.score,
comment: r.comment.clone(),
created_at: crate::datetime::drf_iso(&r.created_at),
replies: replies_by_rating.remove(&r.id).unwrap_or_default(),
});
}
Ok(out)
}
pub async fn build_flavors(
state: &AppState,
ctx: &RequestCtx,
flavors: Vec<flavor::Model>,
viewer_id: Option<i32>,
followed_ids: &[i32],
) -> ApiResult<Vec<FlavorOut>> {
let media = &state.config.media_url;
if flavors.is_empty() {
return Ok(Vec::new());
}
let flavor_ids: Vec<i32> = flavors.iter().map(|f| f.id).collect();
let category_ids: Vec<i32> = flavors.iter().map(|f| f.category_id).collect();
// Categories
let cats = Category::find()
.filter(crate::entities::category::Column::Id.is_in(category_ids))
.all(&state.db)
.await?;
let cat_map: HashMap<i32, (String, String)> = cats
.into_iter()
.map(|c| (c.id, (c.name, c.slug)))
.collect();
// Ratings for these flavors (RatingSerializer order: -created_at)
let ratings = Rating::find()
.filter(rating::Column::FlavorId.is_in(flavor_ids.clone()))
.order_by_desc(rating::Column::CreatedAt)
.all(&state.db)
.await?;
let rating_ids: Vec<i32> = ratings.iter().map(|r| r.id).collect();
// Replies (order: created_at asc)
let replies = if rating_ids.is_empty() {
Vec::new()
} else {
Reply::find()
.filter(reply::Column::RatingId.is_in(rating_ids))
.order_by_asc(reply::Column::CreatedAt)
.all(&state.db)
.await?
};
// Users referenced by ratings + replies
let mut user_ids: Vec<i32> = ratings.iter().map(|r| r.user_id).collect();
user_ids.extend(replies.iter().map(|r| r.user_id));
user_ids.sort_unstable();
user_ids.dedup();
let users = if user_ids.is_empty() {
Vec::new()
} else {
User::find()
.filter(user::Column::Id.is_in(user_ids))
.all(&state.db)
.await?
};
let user_map: HashMap<i32, user::Model> =
users.into_iter().map(|u| (u.id, u)).collect();
// Replies grouped by rating id
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
for rp in &replies {
let username = user_map
.get(&rp.user_id)
.map(|u| u.username.clone())
.unwrap_or_default();
replies_by_rating
.entry(rp.rating_id)
.or_default()
.push(ReplyOut {
id: rp.id,
user: username,
rating: rp.rating_id,
text: rp.text.clone(),
created_at: crate::datetime::drf_iso(&rp.created_at),
});
}
// Flavor lookup for per-rating flavor fields
let flavor_map: HashMap<i32, &flavor::Model> =
flavors.iter().map(|f| (f.id, f)).collect();
// Ratings grouped by flavor id
let mut ratings_by_flavor: HashMap<i32, Vec<RatingOut>> = HashMap::new();
for r in &ratings {
let user = user_map.get(&r.user_id);
let username = user.map(|u| u.username.clone()).unwrap_or_default();
let user_avatar = user
.and_then(|u| u.avatar.as_deref())
.filter(|s| !s.is_empty())
.map(|a| image_field_url(ctx, media, a));
let fl = flavor_map.get(&r.flavor_id);
let (cat_name, cat_slug) = fl
.and_then(|f| cat_map.get(&f.category_id))
.cloned()
.unwrap_or_default();
let flavor_image = fl.and_then(|f| {
if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) {
Some(image_field_url(ctx, media, img))
} else {
f.image_url.clone()
}
});
ratings_by_flavor
.entry(r.flavor_id)
.or_default()
.push(RatingOut {
id: r.id,
user: username,
user_id: r.user_id,
user_avatar,
flavor: r.flavor_id,
flavor_name: fl.map(|f| f.name.clone()).unwrap_or_default(),
flavor_image,
category_name: cat_name,
category_slug: cat_slug,
is_available: fl.map(|f| f.is_available).unwrap_or(false),
is_legacy: fl.map(|f| f.is_legacy).unwrap_or(false),
score: r.score,
comment: r.comment.clone(),
created_at: crate::datetime::drf_iso(&r.created_at),
replies: replies_by_rating.remove(&r.id).unwrap_or_default(),
});
}
// Average + distribution per flavor, from loaded ratings
let followed_set: std::collections::HashSet<i32> = followed_ids.iter().copied().collect();
let mut avg_by_flavor: HashMap<i32, Option<f64>> = HashMap::new();
let mut followed_avg_by_flavor: HashMap<i32, Option<f64>> = HashMap::new();
let mut dist_by_flavor: HashMap<i32, BTreeMap<String, i64>> = HashMap::new();
for fid in &flavor_ids {
let mut dist = empty_distribution();
let mut sum = 0i64;
let mut n = 0i64;
let mut fsum = 0i64;
let mut fn_ = 0i64;
for r in ratings.iter().filter(|r| r.flavor_id == *fid) {
if (1..=10).contains(&r.score) {
*dist.get_mut(&r.score.to_string()).unwrap() += 1;
}
sum += r.score as i64;
n += 1;
if followed_set.contains(&r.user_id) {
fsum += r.score as i64;
fn_ += 1;
}
}
avg_by_flavor.insert(*fid, if n == 0 { None } else { Some(sum as f64 / n as f64) });
followed_avg_by_flavor
.insert(*fid, if fn_ == 0 { None } else { Some(fsum as f64 / fn_ as f64) });
dist_by_flavor.insert(*fid, dist);
}
let mut out = Vec::with_capacity(flavors.len());
for f in &flavors {
let (cat_name, cat_slug) = cat_map.get(&f.category_id).cloned().unwrap_or_default();
let local_paths = json_string_list(&f.local_image_paths);
let image_urls = json_string_list(&f.image_urls);
let user_rating = viewer_id.and_then(|vid| {
ratings
.iter()
.find(|r| r.flavor_id == f.id && r.user_id == vid)
.map(|r| r.score)
});
out.push(FlavorOut {
id: f.id,
name: f.name.clone(),
category: f.category_id,
category_name: cat_name,
category_slug: cat_slug,
description: f.description.clone(),
average_rating: avg_by_flavor.get(&f.id).copied().flatten(),
followed_average_rating: followed_avg_by_flavor.get(&f.id).copied().flatten(),
user_rating,
ratings: ratings_by_flavor.remove(&f.id).unwrap_or_default(),
image_url: flavor_primary_image(
ctx,
media,
f.main_image_path.as_deref(),
&local_paths,
f.image.as_deref(),
f.image_url.as_deref(),
),
image_urls: flavor_image_list(ctx, media, &local_paths, f.image.as_deref(), &image_urls),
is_available: f.is_available,
is_legacy: f.is_legacy,
shop_url: f.shop_url.clone(),
rating_distribution: dist_by_flavor.remove(&f.id).unwrap_or_else(empty_distribution),
});
}
Ok(out)
}
/// `following`/`followers`/`unread` counts keyed by user id, from raw aggregate
/// queries. Used by `build_users` to avoid N+1.
async fn social_counts(
state: &AppState,
ids: &[i32],
) -> ApiResult<(HashMap<i32, i64>, HashMap<i32, i64>, HashMap<i32, i64>)> {
let mut following: HashMap<i32, i64> = HashMap::new();
let mut followers: HashMap<i32, i64> = HashMap::new();
let mut unread: HashMap<i32, i64> = HashMap::new();
if ids.is_empty() {
return Ok((following, followers, unread));
}
let list = id_list(ids);
let backend = DbBackend::Sqlite;
let q = |sql: String| Statement::from_string(backend, sql);
for row in state
.db
.query_all(q(format!(
"SELECT from_user_id AS uid, COUNT(*) AS c FROM api_user_following \
WHERE from_user_id IN ({list}) GROUP BY from_user_id"
)))
.await?
{
following.insert(row.try_get("", "uid")?, row.try_get("", "c")?);
}
for row in state
.db
.query_all(q(format!(
"SELECT to_user_id AS uid, COUNT(*) AS c FROM api_user_following \
WHERE to_user_id IN ({list}) GROUP BY to_user_id"
)))
.await?
{
followers.insert(row.try_get("", "uid")?, row.try_get("", "c")?);
}
for row in state
.db
.query_all(q(format!(
"SELECT recipient_id AS uid, COUNT(*) AS c FROM api_notification \
WHERE recipient_id IN ({list}) AND is_read = 0 GROUP BY recipient_id"
)))
.await?
{
unread.insert(row.try_get("", "uid")?, row.try_get("", "c")?);
}
Ok((following, followers, unread))
}
/// Set of user ids that `viewer` follows, restricted to `ids`.
async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> ApiResult<Vec<i32>> {
let Some(v) = viewer else { return Ok(Vec::new()) };
if ids.is_empty() {
return Ok(Vec::new());
}
let list = id_list(ids);
let rows = state
.db
.query_all(Statement::from_string(
DbBackend::Sqlite,
format!(
"SELECT to_user_id AS uid FROM api_user_following \
WHERE from_user_id = {v} AND to_user_id IN ({list})"
),
))
.await?;
rows.into_iter().map(|r| r.try_get("", "uid").map_err(Into::into)).collect()
}
/// Build `UserOut`s (mirrors `UserSerializer`), batching social counts and the
/// viewer's follow set. `viewer` is the authenticated request user, if any.
pub async fn build_users(
state: &AppState,
ctx: &RequestCtx,
users: Vec<user::Model>,
viewer: Option<i32>,
) -> ApiResult<Vec<UserOut>> {
let media = &state.config.media_url;
if users.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
let (following, followers, unread) = social_counts(state, &ids).await?;
let followed: std::collections::HashSet<i32> =
following_of(state, viewer, &ids).await?.into_iter().collect();
let banner_ids: Vec<i32> = users.iter().filter_map(|u| u.selected_banner_id).collect();
let banner_slugs: HashMap<i32, String> = if banner_ids.is_empty() {
HashMap::new()
} else {
Banner::find()
.filter(crate::entities::banner::Column::Id.is_in(banner_ids))
.all(&state.db)
.await?
.into_iter()
.map(|b| (b.id, b.slug))
.collect()
};
Ok(users
.into_iter()
.map(|u| UserOut {
avatar: avatar_url(ctx, media, &u.avatar),
following_count: *following.get(&u.id).unwrap_or(&0),
followers_count: *followers.get(&u.id).unwrap_or(&0),
is_following: followed.contains(&u.id),
unread_notifications_count: *unread.get(&u.id).unwrap_or(&0),
is_superuser: u.is_superuser,
selected_banner_slug: u
.selected_banner_id
.and_then(|b| banner_slugs.get(&b).cloned()),
selected_banner: u.selected_banner_id,
id: u.id,
username: u.username,
email: u.email,
theme: u.theme,
language: u.language,
drawer_anchor: u.drawer_anchor,
})
.collect())
}
/// Convenience: build a single `UserOut`.
pub async fn build_user(
state: &AppState,
ctx: &RequestCtx,
u: user::Model,
viewer: Option<i32>,
) -> ApiResult<UserOut> {
let mut v = build_users(state, ctx, vec![u], viewer).await?;
v.pop().ok_or(crate::error::ApiError::Internal)
}
/// Build `ProfileCommentOut`s (mirrors `ProfileCommentSerializer`).
pub async fn build_profile_comments(
state: &AppState,
ctx: &RequestCtx,
comments: Vec<profile_comment::Model>,
) -> ApiResult<Vec<ProfileCommentOut>> {
let media = &state.config.media_url;
if comments.is_empty() {
return Ok(Vec::new());
}
let mut author_ids: Vec<i32> = comments.iter().map(|c| c.author_id).collect();
author_ids.sort_unstable();
author_ids.dedup();
let authors: HashMap<i32, user::Model> = User::find()
.filter(user::Column::Id.is_in(author_ids))
.all(&state.db)
.await?
.into_iter()
.map(|u| (u.id, u))
.collect();
Ok(comments
.into_iter()
.map(|c| {
let a = authors.get(&c.author_id);
ProfileCommentOut {
id: c.id,
author_username: a.map(|u| u.username.clone()).unwrap_or_default(),
author_avatar: a.and_then(|u| avatar_url(ctx, media, &u.avatar)),
text: c.text,
created_at: crate::datetime::drf_iso(&c.created_at),
}
})
.collect())
}
/// Build `NotificationOut`s (mirrors `NotificationSerializer`), resolving actor,
/// flavor (via rating or reply→rating) and profile-comment owner.
pub async fn build_notifications(
state: &AppState,
ctx: &RequestCtx,
notes: Vec<notification::Model>,
) -> ApiResult<Vec<NotificationOut>> {
let media = &state.config.media_url;
if notes.is_empty() {
return Ok(Vec::new());
}
let reply_ids: Vec<i32> = notes.iter().filter_map(|n| n.reply_id).collect();
let replies: HashMap<i32, reply::Model> = if reply_ids.is_empty() {
HashMap::new()
} else {
Reply::find()
.filter(reply::Column::Id.is_in(reply_ids))
.all(&state.db)
.await?
.into_iter()
.map(|r| (r.id, r))
.collect()
};
// Rating ids referenced directly or via a reply.
let mut rating_ids: Vec<i32> = notes.iter().filter_map(|n| n.rating_id).collect();
rating_ids.extend(replies.values().map(|r| r.rating_id));
rating_ids.sort_unstable();
rating_ids.dedup();
let ratings: HashMap<i32, rating::Model> = if rating_ids.is_empty() {
HashMap::new()
} else {
Rating::find()
.filter(rating::Column::Id.is_in(rating_ids))
.all(&state.db)
.await?
.into_iter()
.map(|r| (r.id, r))
.collect()
};
let flavor_ids: Vec<i32> = ratings.values().map(|r| r.flavor_id).collect();
let flavors: HashMap<i32, flavor::Model> = if flavor_ids.is_empty() {
HashMap::new()
} else {
Flavor::find()
.filter(flavor::Column::Id.is_in(flavor_ids))
.all(&state.db)
.await?
.into_iter()
.map(|f| (f.id, f))
.collect()
};
let pc_ids: Vec<i32> = notes.iter().filter_map(|n| n.profile_comment_id).collect();
let pcs: HashMap<i32, profile_comment::Model> = if pc_ids.is_empty() {
HashMap::new()
} else {
ProfileComment::find()
.filter(profile_comment::Column::Id.is_in(pc_ids))
.all(&state.db)
.await?
.into_iter()
.map(|c| (c.id, c))
.collect()
};
let mut user_ids: Vec<i32> = notes.iter().map(|n| n.actor_id).collect();
user_ids.extend(pcs.values().map(|c| c.profile_owner_id));
user_ids.sort_unstable();
user_ids.dedup();
let users: HashMap<i32, user::Model> = User::find()
.filter(user::Column::Id.is_in(user_ids))
.all(&state.db)
.await?
.into_iter()
.map(|u| (u.id, u))
.collect();
// Resolve the flavor (name, id) a notification points at.
let flavor_of = |n: &notification::Model| -> Option<&flavor::Model> {
if let Some(rid) = n.rating_id {
return ratings.get(&rid).and_then(|r| flavors.get(&r.flavor_id));
}
if let Some(rep_id) = n.reply_id {
let rep = replies.get(&rep_id)?;
return ratings.get(&rep.rating_id).and_then(|r| flavors.get(&r.flavor_id));
}
None
};
Ok(notes
.iter()
.map(|n| {
let actor = users.get(&n.actor_id);
let fl = flavor_of(n);
NotificationOut {
id: n.id,
actor_username: actor.map(|u| u.username.clone()).unwrap_or_default(),
actor_avatar: actor.and_then(|u| avatar_url(ctx, media, &u.avatar)),
notification_type: n.notification_type.clone(),
rating: n.rating_id,
reply: n.reply_id,
ticket: n.ticket_id,
profile_comment: n.profile_comment_id,
profile_owner_username: n
.profile_comment_id
.and_then(|id| pcs.get(&id))
.and_then(|c| users.get(&c.profile_owner_id))
.map(|u| u.username.clone()),
is_read: n.is_read,
created_at: crate::datetime::drf_iso(&n.created_at),
flavor_name: fl.map(|f| f.name.clone()),
flavor_id: fl.map(|f| f.id),
}
})
.collect())
}
/// Build a single `TicketMessageOut` when the author is already loaded
/// (used by the `add_message` action's 201 response).
pub fn ticket_message_out(msg: ticket_message::Model, author: &user::Model) -> TicketMessageOut {
TicketMessageOut {
id: msg.id,
username: author.username.clone(),
text: msg.text,
created_at: crate::datetime::drf_iso(&msg.created_at),
is_admin: author.is_superuser,
}
}
/// Build `TicketOut`s (mirrors `TicketSerializer` + nested `TicketMessageSerializer`).
pub async fn build_tickets(
state: &AppState,
ctx: &RequestCtx,
tickets: Vec<ticket::Model>,
) -> ApiResult<Vec<TicketOut>> {
let media = &state.config.media_url;
if tickets.is_empty() {
return Ok(Vec::new());
}
let ticket_ids: Vec<i32> = tickets.iter().map(|t| t.id).collect();
let messages = TicketMessage::find()
.filter(ticket_message::Column::TicketId.is_in(ticket_ids))
.order_by_asc(ticket_message::Column::CreatedAt)
.all(&state.db)
.await?;
let mut user_ids: Vec<i32> = tickets.iter().map(|t| t.user_id).collect();
user_ids.extend(messages.iter().map(|m| m.user_id));
user_ids.sort_unstable();
user_ids.dedup();
let users: HashMap<i32, user::Model> = User::find()
.filter(user::Column::Id.is_in(user_ids))
.all(&state.db)
.await?
.into_iter()
.map(|u| (u.id, u))
.collect();
let mut msgs_by_ticket: HashMap<i32, Vec<TicketMessageOut>> = HashMap::new();
for m in messages {
let u = users.get(&m.user_id);
msgs_by_ticket.entry(m.ticket_id).or_default().push(TicketMessageOut {
id: m.id,
username: u.map(|x| x.username.clone()).unwrap_or_default(),
text: m.text,
created_at: crate::datetime::drf_iso(&m.created_at),
is_admin: u.map(|x| x.is_superuser).unwrap_or(false),
});
}
Ok(tickets
.into_iter()
.map(|t| {
let u = users.get(&t.user_id);
TicketOut {
user: t.user_id,
username: u.map(|x| x.username.clone()).unwrap_or_default(),
user_email: u.map(|x| x.email.clone()).unwrap_or_default(),
user_avatar: u.and_then(|x| avatar_url(ctx, media, &x.avatar)),
subject: t.subject,
description: t.description,
status: t.status,
created_at: crate::datetime::drf_iso(&t.created_at),
updated_at: crate::datetime::drf_iso(&t.updated_at),
messages: msgs_by_ticket.remove(&t.id).unwrap_or_default(),
id: t.id,
}
})
.collect())
}
+21
View File
@@ -0,0 +1,21 @@
use std::collections::HashSet;
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use tokio::sync::Mutex;
use crate::config::Config;
use crate::throttle::Security;
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
pub config: Config,
/// Names of jobs currently executing. Guards against a manual trigger and
/// the scheduler (or two triggers) running the same job concurrently —
/// these jobs mutate shared catalog rows and are not safe to overlap.
pub running_jobs: Arc<Mutex<HashSet<String>>>,
/// Rate limiting + confirmation-code TTL/attempt state (replaces
/// `django_ratelimit`). Shared across handlers via `Arc`.
pub security: Arc<Security>,
}
+188
View File
@@ -0,0 +1,188 @@
//! In-process rate limiting + confirmation-code hardening.
//!
//! Replaces what Django got from `django_ratelimit` (request throttling) and
//! adds two protections the Django app lacked: a per-account attempt lockout on
//! the code-verifying endpoints, and a TTL on the 6-digit confirmation codes
//! (`email_confirmation_code` has no expiry column in the shared schema, so the
//! TTL is tracked here in memory).
//!
//! Everything is in-memory. Two consequences worth knowing:
//! - A process restart resets the counters (fail-open) — acceptable: the worst
//! case is a brute-force attacker getting their window reset, still bounded by
//! the very low per-hour limits, and the strangler runs a single Rust process.
//! - Code expiry is best-effort: `code_expired` only reports `true` when we hold
//! a record that is past its TTL. An unknown code (minted before a restart, or
//! by Django directly) falls through to the existing DB-equality check, so a
//! legitimate user is never locked out worse than Django did.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::error::ApiError;
/// A request rate, mirroring django_ratelimit's `N/period` notation.
#[derive(Clone, Copy)]
pub struct Rate {
pub limit: usize,
pub window: Duration,
}
impl Rate {
pub const fn per_minute(limit: usize) -> Self {
Rate { limit, window: Duration::from_secs(60) }
}
pub const fn per_hour(limit: usize) -> Self {
Rate { limit, window: Duration::from_secs(3600) }
}
}
// --- Django-parity request limits (key + rate match api/views) -------------
/// `signup` — django_ratelimit key="ip" rate="5/h".
pub const SIGNUP: Rate = Rate::per_hour(5);
/// `request_password_reset` — key="ip" rate="3/h".
pub const PASSWORD_RESET_REQUEST: Rate = Rate::per_hour(3);
/// `complete_password_reset` — key="ip" rate="5/h".
pub const PASSWORD_RESET_COMPLETE: Rate = Rate::per_hour(5);
/// `request_account_deletion` — key="user" rate="3/h".
pub const ACCOUNT_DELETION_REQUEST: Rate = Rate::per_hour(3);
/// `RatingViewSet.create` — key="user" rate="10/m".
pub const RATING_CREATE: Rate = Rate::per_minute(10);
// --- Added hardening (no Django equivalent) --------------------------------
/// Login brute-force guard, keyed by IP (Django had none).
pub const LOGIN: Rate = Rate::per_minute(10);
/// Verification-email resends, keyed by IP — caps the email-bomb vector.
pub const RESEND_VERIFICATION: Rate = Rate::per_hour(5);
/// Per-account attempt lockout on the code-verifying endpoints. Generous enough
/// that a real user fat-fingering a code never trips it, low enough to make the
/// 6-digit space (1e6) infeasible even for an attacker rotating source IPs.
pub const CODE_ATTEMPTS: Rate = Rate::per_hour(10);
/// How long a freshly minted confirmation code stays valid.
pub const CODE_TTL: Duration = Duration::from_secs(15 * 60);
/// Shared security state: a sliding-window request log plus the code-mint clock.
#[derive(Default)]
pub struct Security {
/// key -> timestamps of recent hits (oldest first).
hits: Mutex<HashMap<String, VecDeque<Instant>>>,
/// confirmation-code key -> mint time.
codes: Mutex<HashMap<String, Instant>>,
}
impl Security {
pub fn new() -> Self {
Self::default()
}
/// Record a hit on `key` and reject (429) if it exceeds `rate`. Uses a
/// sliding-window log so a burst can't straddle a fixed boundary.
pub fn check_rate(&self, key: &str, rate: Rate) -> Result<(), ApiError> {
let now = Instant::now();
let mut map = self.hits.lock().unwrap();
let log = map.entry(key.to_string()).or_default();
// Drop hits that have aged out of the window.
while let Some(&front) = log.front() {
if now.duration_since(front) >= rate.window {
log.pop_front();
} else {
break;
}
}
if log.len() >= rate.limit {
// Retry once the oldest in-window hit expires.
let retry_after = log
.front()
.map(|&t| rate.window.saturating_sub(now.duration_since(t)))
.unwrap_or(rate.window);
return Err(ApiError::RateLimited {
retry_after: retry_after.as_secs().max(1),
});
}
log.push_back(now);
Ok(())
}
/// Note that a confirmation code was just minted for `key`.
pub fn record_code(&self, key: &str) {
self.codes.lock().unwrap().insert(key.to_string(), Instant::now());
}
/// True only when we hold a mint time for `key` that is older than `CODE_TTL`.
/// Unknown keys return `false` (fall through to the DB-equality check).
pub fn code_expired(&self, key: &str) -> bool {
self.codes
.lock()
.unwrap()
.get(key)
.is_some_and(|&t| t.elapsed() >= CODE_TTL)
}
/// Forget a code after it has been consumed (or invalidated).
pub fn clear_code(&self, key: &str) {
self.codes.lock().unwrap().remove(key);
}
/// Drop empty/expired buckets so the maps don't grow unbounded. Called
/// periodically from a background task.
pub fn gc(&self) {
let now = Instant::now();
// Longest request window in use is one hour; reuse it as the sweep horizon.
let max_window = Duration::from_secs(3600);
let mut hits = self.hits.lock().unwrap();
hits.retain(|_, log| {
while let Some(&front) = log.front() {
if now.duration_since(front) >= max_window {
log.pop_front();
} else {
break;
}
}
!log.is_empty()
});
drop(hits);
let mut codes = self.codes.lock().unwrap();
codes.retain(|_, &mut t| t.elapsed() < CODE_TTL);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_up_to_limit_then_blocks() {
let s = Security::new();
let rate = Rate::per_hour(3);
for _ in 0..3 {
assert!(s.check_rate("k", rate).is_ok());
}
let err = s.check_rate("k", rate).unwrap_err();
assert!(matches!(err, ApiError::RateLimited { .. }));
}
#[test]
fn keys_are_independent() {
let s = Security::new();
let rate = Rate::per_hour(1);
assert!(s.check_rate("a", rate).is_ok());
assert!(s.check_rate("b", rate).is_ok());
assert!(s.check_rate("a", rate).is_err());
}
#[test]
fn unknown_code_is_not_expired() {
let s = Security::new();
assert!(!s.code_expired("missing"));
}
#[test]
fn recorded_code_is_fresh() {
let s = Security::new();
s.record_code("k");
assert!(!s.code_expired("k"));
s.clear_code("k");
assert!(!s.code_expired("k"));
}
}
+154
View File
@@ -0,0 +1,154 @@
//! SimpleJWT-compatible token issuance.
//!
//! Mirrors `rest_framework_simplejwt`: HS256 signed with `SECRET_KEY`, claim set
//! `{ token_type, exp, iat, jti, user_id }`, `jti` a uuid4 hex string. Lifetimes
//! match `settings.SIMPLE_JWT` (access 60 min, refresh 14 days). Tokens minted
//! here verify in Django and vice versa, so the strangler proxy can route auth
//! to Rust while other endpoints stay on Django.
use chrono::{DateTime, NaiveDateTime, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const ACCESS_LIFETIME_SECS: i64 = 60 * 60; // ACCESS_TOKEN_LIFETIME = 60 min
pub const REFRESH_LIFETIME_SECS: i64 = 14 * 24 * 60 * 60; // REFRESH_TOKEN_LIFETIME = 14 days
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
pub token_type: String,
pub exp: i64,
pub iat: i64,
pub jti: String,
pub user_id: i32,
}
impl TokenClaims {
/// Convert an `exp` epoch into a naive-UTC datetime (for blacklist rows).
pub fn expires_naive(exp: i64) -> NaiveDateTime {
epoch_to_naive(exp)
}
}
/// A freshly minted token plus the fields needed to record it (jti/exp).
pub struct Minted {
pub token: String,
pub jti: String,
pub iat: i64,
pub exp: i64,
}
impl Minted {
/// `expires_at` as a naive-UTC datetime, for the outstanding-token row.
pub fn expires_at(&self) -> NaiveDateTime {
epoch_to_naive(self.exp)
}
/// `created_at` (== iat) as a naive-UTC datetime.
pub fn created_at(&self) -> NaiveDateTime {
epoch_to_naive(self.iat)
}
}
fn mint(secret: &str, user_id: i32, token_type: &str, lifetime: i64) -> Minted {
let now = Utc::now().timestamp();
let exp = now + lifetime;
let jti = Uuid::new_v4().simple().to_string(); // uuid4().hex — 32 hex chars
let claims = TokenClaims {
token_type: token_type.to_string(),
exp,
iat: now,
jti: jti.clone(),
user_id,
};
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.expect("HS256 encode never fails for serializable claims");
Minted {
token,
jti,
iat: now,
exp,
}
}
pub fn mint_access(secret: &str, user_id: i32) -> Minted {
mint(secret, user_id, "access", ACCESS_LIFETIME_SECS)
}
pub fn mint_refresh(secret: &str, user_id: i32) -> Minted {
mint(secret, user_id, "refresh", REFRESH_LIFETIME_SECS)
}
fn decode_claims(token: &str, secret: &str) -> Option<TokenClaims> {
let mut v = Validation::new(Algorithm::HS256);
v.validate_aud = false;
decode::<TokenClaims>(token, &DecodingKey::from_secret(secret.as_bytes()), &v)
.ok()
.map(|d| d.claims)
}
/// Verify signature + `exp` and require `token_type == "refresh"`.
pub fn verify_refresh(token: &str, secret: &str) -> Option<TokenClaims> {
let claims = decode_claims(token, secret)?;
(claims.token_type == "refresh").then_some(claims)
}
/// Verify signature + `exp` for any token type (matches SimpleJWT's
/// `UntypedToken` used by the verify endpoint).
pub fn verify_any(token: &str, secret: &str) -> Option<TokenClaims> {
decode_claims(token, secret)
}
fn epoch_to_naive(secs: i64) -> NaiveDateTime {
DateTime::<Utc>::from_timestamp(secs, 0)
.unwrap_or_default()
.naive_utc()
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "test-secret-matching-django";
#[test]
fn access_round_trips_with_correct_claims() {
let minted = mint_access(SECRET, 42);
let claims = verify_any(&minted.token, SECRET).expect("valid access token");
assert_eq!(claims.token_type, "access");
assert_eq!(claims.user_id, 42);
assert_eq!(claims.jti, minted.jti);
assert_eq!(claims.exp - claims.iat, ACCESS_LIFETIME_SECS);
assert_eq!(claims.jti.len(), 32); // uuid4().hex
}
#[test]
fn refresh_round_trips_and_is_typed() {
let minted = mint_refresh(SECRET, 7);
let claims = verify_refresh(&minted.token, SECRET).expect("valid refresh token");
assert_eq!(claims.token_type, "refresh");
assert_eq!(claims.exp - claims.iat, REFRESH_LIFETIME_SECS);
// An access token must not satisfy verify_refresh.
let access = mint_access(SECRET, 7);
assert!(verify_refresh(&access.token, SECRET).is_none());
}
#[test]
fn rejects_wrong_secret() {
let minted = mint_access(SECRET, 1);
assert!(verify_any(&minted.token, "other-secret").is_none());
}
#[test]
fn rejects_tampered_token() {
let mut t = mint_access(SECRET, 1).token;
// Flip a character in the payload segment.
let mid = t.len() / 2;
let ch = if &t[mid..=mid] == "A" { "B" } else { "A" };
t.replace_range(mid..=mid, ch);
assert!(verify_any(&t, SECRET).is_none());
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Per-request context used to rebuild absolute URLs the way DRF's
//! `request.build_absolute_uri()` does (scheme + host from the incoming
//! request). Behind the strangler nginx proxy, `X-Forwarded-Proto` / `Host`
//! carry the public values.
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use std::convert::Infallible;
use std::net::SocketAddr;
/// The client IP, mirroring Django's `log_user_ip`: prefer the first
/// `X-Forwarded-For` hop (set by the strangler proxy), else the peer address
/// (Django's `REMOTE_ADDR`). Used for IP logging and IP-keyed rate limiting.
pub fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| peer.ip().to_string())
}
#[derive(Clone, Debug)]
pub struct RequestCtx {
pub scheme: String,
pub host: String,
/// Full path + query of the current request (for pagination next/prev links).
pub full_path: String,
}
impl RequestCtx {
/// Absolute URL for a root-relative path like "/media/x.jpg".
pub fn absolute(&self, path: &str) -> String {
let path = if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
};
format!("{}://{}{}", self.scheme, self.host, path)
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequestCtx {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let h = &parts.headers;
let scheme = h
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| "http".to_string());
let host = h
.get("x-forwarded-host")
.or_else(|| h.get("host"))
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost")
.to_string();
// Nested routers rewrite `parts.uri` to the router-relative path, which
// would drop the `/api` prefix from pagination links. OriginalUri holds
// the full incoming path.
let uri = parts
.extensions
.get::<axum::extract::OriginalUri>()
.map(|o| &o.0)
.unwrap_or(&parts.uri);
let full_path = uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| uri.path().to_string());
Ok(RequestCtx {
scheme,
host,
full_path,
})
}
}
+55
View File
@@ -133,6 +133,54 @@ services:
redis:
condition: service_healthy
# --- Rust backend (strangler) --------------------------------------------
# Runs beside Django, sharing the SAME db.sqlite3, media/, banners/ and
# legacy/ via bind mounts and the SAME SECRET_KEY (so SimpleJWT tokens verify
# across both). nginx routes endpoint groups here one at a time; see
# backend-rs/README.md for the cutover order. ENABLE_SCHEDULER stays false
# until Django's beat container is stopped — only one scheduler may own
# api_job, or jobs run twice.
backend-rs:
build:
context: ./backend-rs
container_name: holy-rust
user: "app"
restart: unless-stopped
networks:
- holy-net
ports:
- 8001
env_file:
- .env
environment:
- DOMAIN=${DOMAIN:-localhost}
- BIND_ADDR=0.0.0.0:8001
- SECRET_KEY=${SECRET_KEY}
- DATABASE_URL=sqlite:///srv/backend/db.sqlite3?mode=rw
- MEDIA_ROOT=/srv/backend/media
- MEDIA_URL=media/
- BANNERS_DIR=/srv/backend/banners
- LEGACY_DIR=/srv/legacy
- BACKUP_DIR=/srv/backend/backups
- FRONTEND_URL=${FRONTEND_URL:-http://${DOMAIN:-localhost}}
- JWT_AUTH_COOKIE_SECURE=${JWT_AUTH_COOKIE_SECURE:-true}
# Keep false until Django beat is stopped (Phase 3 of cutover).
- ENABLE_SCHEDULER=${RUST_ENABLE_SCHEDULER:-false}
- RUST_LOG=${RUST_LOG:-info,holy_backend_rs=info,sea_orm=warn}
volumes:
- ./backend:/srv/backend
- ./legacy:/srv/legacy
depends_on:
# Wait for Django to apply migrations / create the schema before reading it.
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
frontend:
build:
context: ./frontend
@@ -151,9 +199,16 @@ services:
- holy-net
env_file:
- .env
volumes:
# Serve /media/ straight from the shared mount (read-only), decoupling it
# from either backend so Django can be retired without losing media.
- ./backend/media:/usr/share/nginx/media:ro
depends_on:
backend:
condition: service_healthy
# nginx resolves the rust upstream at startup, so the container must exist.
backend-rs:
condition: service_healthy
networks:
holy-net:
+4 -37
View File
@@ -31,43 +31,10 @@ COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Custom Nginx config to handle React Router (SPA) and proxy API requests
RUN printf 'server {\n\
listen 80;\n\
location / {\n\
root /usr/share/nginx/html;\n\
index index.html index.htm;\n\
try_files $uri $uri/ /index.html;\n\
}\n\
location /api/ {\n\
proxy_pass http://holy-backend:8000/api/;\n\
proxy_set_header Host $host;\n\
proxy_set_header X-Real-IP $remote_addr;\n\
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
proxy_set_header X-Forwarded-Proto $scheme;\n\
}\n\
location /admin/ {\n\
proxy_pass http://holy-backend:8000/admin/;\n\
proxy_set_header Host $host;\n\
proxy_set_header X-Real-IP $remote_addr;\n\
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
proxy_set_header X-Forwarded-Proto $scheme;\n\
}\n\
location /static/ {\n\
proxy_pass http://holy-backend:8000/static/;\n\
proxy_set_header Host $host;\n\
proxy_set_header X-Real-IP $remote_addr;\n\
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
proxy_set_header X-Forwarded-Proto $scheme;\n\
}\n\
location /media/ {\n\
proxy_pass http://holy-backend:8000/media/;\n\
proxy_set_header Host $host;\n\
proxy_set_header X-Real-IP $remote_addr;\n\
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
proxy_set_header X-Forwarded-Proto $scheme;\n\
}\n\
}' > /etc/nginx/conf.d/default.conf
# Nginx config: SPA + strangler API routing (Django/Rust per-group). See
# nginx.conf for the cutover layout. proxy_params.conf holds shared headers.
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY proxy_params.conf /etc/nginx/proxy_params.conf
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
+88
View File
@@ -0,0 +1,88 @@
# =========================================================================
# Strangler routing: Django (holy-backend) and Rust (holy-rust) run side by
# side, sharing one SQLite DB + media dir + SECRET_KEY. Every endpoint group
# below can be served by EITHER backend, so cutover is per-group and instant:
# change a `proxy_pass` target, then `docker exec holy-frontend nginx -s reload`
# (or recreate the container). Rollback is the same edit in reverse.
#
# Cutover order (see backend-rs/README.md):
# Phase 1 reads -> categories, flavors, banners
# Phase 2 auth+write -> auth/token, ratings, replies, users, notifications, tickets
# Phase 3 admin+jobs -> admin-custom (THEN stop Django beat + set
# RUST_ENABLE_SCHEDULER=true so exactly one scheduler runs)
# Phase 4 retire -> point catch-all /api/ at rust; serve /media/ from
# nginx; drop Django /admin/ + /static/ if unused.
# =========================================================================
upstream django_backend {
server holy-backend:8000;
}
upstream rust_backend {
server holy-rust:8001;
}
server {
listen 80;
# --- SPA -------------------------------------------------------------
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# --- API: per-group strangler routing --------------------------------
# Longest-prefix match wins, so these specific groups override the
# catch-all /api/ below. To cut a group over, swap django_backend ->
# rust_backend on its proxy_pass line.
# Phase 1 — read-only (safe to move first):
location /api/categories/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location /api/flavors/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location /api/banners/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
# Phase 2 — auth + writes:
location /api/auth/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location = /api/token/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; } # legacy JWT alias
location /api/ratings/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location /api/replies/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location /api/users/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location /api/notifications/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
location /api/tickets/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
# Phase 3 — admin + jobs (cut over, THEN flip the scheduler ownership):
location /api/admin-custom/ { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
# OpenAPI schema / Swagger UI (both backends serve these):
location /api/schema { proxy_pass http://django_backend; include /etc/nginx/proxy_params.conf; }
# Catch-all: anything not explicitly cut over stays on Django.
location /api/ {
proxy_pass http://django_backend;
include /etc/nginx/proxy_params.conf;
}
# --- Django-only paths (retire last) ---------------------------------
# Django admin site + its static assets. The app's own admin UI uses
# /api/admin-custom/, so these are only the Django admin site + DRF UI.
location /admin/ {
proxy_pass http://django_backend;
include /etc/nginx/proxy_params.conf;
}
location /static/ {
proxy_pass http://django_backend;
include /etc/nginx/proxy_params.conf;
}
# --- Media -----------------------------------------------------------
# Served straight from the shared bind mount (read-only) so media does not
# depend on either backend being up — a prerequisite for retiring Django.
location /media/ {
alias /usr/share/nginx/media/;
access_log off;
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Shared proxy headers. Both backends rebuild absolute URLs (DRF
# build_absolute_uri / Rust RequestCtx) from Host + X-Forwarded-Proto, so these
# must be set on every proxied location.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
# =========================================================================
# Strangler cutover driver: Django -> Rust, zero downtime, reversible.
#
# Flips nginx `proxy_pass` targets one phase-group at a time and reloads
# nginx, then (Phase 3) hands job scheduling from Django's Celery beat to
# the Rust in-process scheduler. Every phase has an inverse `rollback`.
#
# It edits frontend/nginx.conf in place. To make reloads INSTANT (no image
# rebuild), run `cutover.sh dev-mount` once first: it bind-mounts the conf
# into holy-frontend so `nginx -s reload` picks up edits live. Without that,
# each flip rebuilds the frontend image (slower, but still no API downtime).
#
# Usage:
# scripts/cutover.sh status # show current routing + scheduler
# scripts/cutover.sh phase0 # build + start holy-rust (shadow)
# scripts/cutover.sh phase1 # flip reads
# scripts/cutover.sh phase2 # flip auth + writes
# scripts/cutover.sh phase3 # flip admin, hand off scheduler
# scripts/cutover.sh phase4 # flip catch-all (retire Django path)
# scripts/cutover.sh rollback {1|2|3|4} # undo a phase
# scripts/cutover.sh smoke # hit holy-rust /health/ directly
#
# Invariant: exactly ONE scheduler owns api_job. Django beat owns it until
# phase3; Rust's ENABLE_SCHEDULER stays false until then. Never both.
# =========================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONF="$REPO/frontend/nginx.conf"
ENVFILE="$REPO/.env"
DC="docker compose"
# Endpoint groups per phase (nginx location prefixes, sans trailing proxy line).
READS=(/api/categories/ /api/flavors/ /api/banners/)
WRITES=(/api/auth/ "= /api/token/" /api/ratings/ /api/replies/ /api/users/ /api/notifications/ /api/tickets/)
ADMIN=(/api/admin-custom/)
CATCHALL=(/api/schema /api/)
c_red() { printf '\033[31m%s\033[0m\n' "$*"; }
c_grn() { printf '\033[32m%s\033[0m\n' "$*"; }
c_ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
die() { c_red "ERROR: $*" >&2; exit 1; }
[ -f "$CONF" ] || die "nginx.conf not found at $CONF"
# Flip the upstream on the location line(s) for a given prefix.
# $1 = location token (e.g. /api/flavors/ or '= /api/token/')
# $2 = target upstream (rust_backend | django_backend)
flip_one() {
local loc="$1" target="$2"
# Match: location <loc> { proxy_pass http://<anything>; ... }
# Anchor on the exact location token to avoid hitting the catch-all /api/.
local esc; esc="$(printf '%s' "$loc" | sed 's/[.[\*^$/]/\\&/g')"
# The catch-all "/api/" lives on its own multi-line block; handle separately.
if [ "$loc" = "/api/" ]; then
# Flip only the catch-all block's proxy_pass (the one not on a one-liner).
perl -0pi -e "s{(location /api/ \{\s*\n\s*proxy_pass http://)\w+;}{\${1}${target};}g" "$CONF"
return
fi
sed -i -E "s|(location ${esc}[[:space:]]+\{[[:space:]]+proxy_pass http://)[a-z_]+;|\1${target};|" "$CONF"
}
flip_group() {
local target="$1"; shift
local g
for g in "$@"; do flip_one "$g" "$target"; done
}
reload_nginx() {
if $DC ps frontend --format '{{.Names}}' 2>/dev/null | grep -q holy-frontend \
&& docker inspect holy-frontend --format '{{range .Mounts}}{{.Destination}}{{"\n"}}{{end}}' 2>/dev/null \
| grep -q '/etc/nginx/conf.d/default.conf'; then
c_ylw "conf is bind-mounted -> live reload"
docker exec holy-frontend nginx -t && docker exec holy-frontend nginx -s reload
else
c_ylw "conf baked into image -> rebuilding frontend (no API downtime)"
$DC up -d --build frontend
fi
}
set_env() { # set_env KEY value (idempotent upsert in .env)
local k="$1" v="$2"
[ -f "$ENVFILE" ] || touch "$ENVFILE"
if grep -qE "^${k}=" "$ENVFILE"; then
sed -i -E "s|^${k}=.*|${k}=${v}|" "$ENVFILE"
else
printf '%s=%s\n' "$k" "$v" >> "$ENVFILE"
fi
}
confirm() {
read -r -p "$1 [y/N] " a; [ "$a" = y ] || [ "$a" = Y ] || die "aborted"
}
cmd_status() {
c_grn "== nginx routing (frontend/nginx.conf) =="
grep -E 'location (= )?/api/' "$CONF" | sed -E 's/\s*include.*//' \
| sed -E 's/proxy_pass http:\/\/(rust_backend)/proxy_pass -> \x1b[32m\1\x1b[0m/; s/proxy_pass http:\/\/(django_backend)/proxy_pass -> \1/'
echo
c_grn "== scheduler ownership =="
local rust_sched; rust_sched="$(grep -E '^RUST_ENABLE_SCHEDULER=' "$ENVFILE" 2>/dev/null | cut -d= -f2 || true)"
echo "RUST_ENABLE_SCHEDULER=${rust_sched:-unset (=false)}"
if $DC ps beat --format '{{.Names}} {{.State}}' 2>/dev/null | grep -q running; then
echo "Django beat: RUNNING (Django owns api_job)"
else
echo "Django beat: stopped"
fi
}
cmd_smoke() {
c_grn "Hitting holy-rust /health/ directly (bypasses nginx)..."
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/ \
|| die "holy-rust not reachable — run phase0 first?"
echo; c_grn "OK"
}
cmd_phase0() {
grep -qE '^SECRET_KEY=.+' "$ENVFILE" 2>/dev/null \
|| die "SECRET_KEY must be set in .env to the SAME value Django uses (JWTs break otherwise)"
c_grn "Phase 0: building + starting holy-rust in shadow (no traffic)..."
$DC up -d --build backend-rs
c_ylw "Wait for healthy, then: scripts/cutover.sh smoke"
}
cmd_phase1() { c_grn "Phase 1: flip READS -> rust"; flip_group rust_backend "${READS[@]}"; reload_nginx; cmd_status; }
cmd_phase2() { c_grn "Phase 2: flip AUTH+WRITES -> rust"; flip_group rust_backend "${WRITES[@]}"; reload_nginx; cmd_status; }
cmd_phase3() {
c_grn "Phase 3: flip ADMIN -> rust, then hand off scheduler"
flip_group rust_backend "${ADMIN[@]}"; reload_nginx
c_ylw "About to STOP Django beat/worker/flower and ENABLE Rust scheduler."
c_ylw "This transfers api_job ownership. Ensure no job is mid-run."
confirm "Proceed with scheduler handover?"
$DC stop beat worker flower
set_env RUST_ENABLE_SCHEDULER true
$DC up -d backend-rs
c_grn "Done. Check: docker logs holy-rust | grep 'job scheduler ENABLED'"
cmd_status
}
cmd_phase4() {
c_grn "Phase 4: flip CATCH-ALL /api/ + /api/schema -> rust (Django serves only /admin/ + /static/)"
flip_group rust_backend "${CATCHALL[@]}"; reload_nginx
c_ylw "Django now handles only its admin site. To drop it entirely:"
c_ylw " $DC stop backend ; then remove /admin/ + /static/ locations from nginx.conf"
c_ylw " $DC stop redis # only a Celery broker + Django cache; Rust uses neither"
cmd_status
}
cmd_rollback() {
local p="${1:-}"
case "$p" in
1) c_grn "Rollback Phase 1"; flip_group django_backend "${READS[@]}"; reload_nginx ;;
2) c_grn "Rollback Phase 2"; flip_group django_backend "${WRITES[@]}"; reload_nginx ;;
3) c_grn "Rollback Phase 3: scheduler back to Django, admin back to Django"
set_env RUST_ENABLE_SCHEDULER false
$DC up -d backend-rs
$DC start beat worker flower
flip_group django_backend "${ADMIN[@]}"; reload_nginx ;;
4) c_grn "Rollback Phase 4"; flip_group django_backend "${CATCHALL[@]}"; reload_nginx ;;
*) die "usage: cutover.sh rollback {1|2|3|4}" ;;
esac
cmd_status
}
cmd_dev_mount() {
c_ylw "Add this under the 'frontend' service volumes in docker-compose.yml for live reloads:"
echo ' - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf'
echo ' - ./frontend/proxy_params.conf:/etc/nginx/proxy_params.conf'
c_ylw "Then: $DC up -d frontend"
}
case "${1:-}" in
status) cmd_status ;;
smoke) cmd_smoke ;;
phase0) cmd_phase0 ;;
phase1) cmd_phase1 ;;
phase2) cmd_phase2 ;;
phase3) cmd_phase3 ;;
phase4) cmd_phase4 ;;
rollback) cmd_rollback "${2:-}" ;;
dev-mount) cmd_dev_mount ;;
*) cat <<EOF
Strangler cutover driver. Phases are reversible; data never forks (shared DB).
status show routing + scheduler ownership
smoke curl holy-rust /health/ directly
phase0 build + start holy-rust (shadow, no traffic)
phase1 flip reads (categories, flavors, banners)
phase2 flip auth+writes (auth, token, ratings, replies, users, notifications, tickets)
phase3 flip admin + hand scheduler Django->Rust (CAREFUL)
phase4 flip catch-all /api/ (Django -> only /admin/ + /static/)
rollback {1|2|3|4} undo a phase
dev-mount print compose lines for live nginx reloads
See MIGRATION.md for the full runbook.
EOF
;;
esac