mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
rust backend + migration
This commit is contained in:
1 parent
0bdd7cedb0
commit
61f0aadc70
70 files changed
+13707
-37
No files matched your search
+191
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
Generated
+4123
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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).
|
||||||
|
```
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"))
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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>;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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?)
|
||||||
|
}
|
||||||
@@ -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?)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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" })))
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ¬ification::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())
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,54 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -151,9 +199,16 @@ services:
|
|||||||
- holy-net
|
- holy-net
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .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:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
# nginx resolves the rust upstream at startup, so the container must exist.
|
||||||
|
backend-rs:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
holy-net:
|
holy-net:
|
||||||
|
|||||||
+4
-37
@@ -31,43 +31,10 @@ COPY --from=build-stage /app/dist /usr/share/nginx/html
|
|||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Custom Nginx config to handle React Router (SPA) and proxy API requests
|
# Nginx config: SPA + strangler API routing (Django/Rust per-group). See
|
||||||
RUN printf 'server {\n\
|
# nginx.conf for the cutover layout. proxy_params.conf holds shared headers.
|
||||||
listen 80;\n\
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
location / {\n\
|
COPY proxy_params.conf /etc/nginx/proxy_params.conf
|
||||||
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
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Executable
+200
@@ -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
|
||||||
Reference in new issue
Block a user