From 61f0aadc706bf46f177ff148dc4c30bf8f6dc9e3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 9 Jun 2026 15:07:04 +0200 Subject: [PATCH] rust backend + migration --- MIGRATION.md | 191 + backend-rs/.dockerignore | 4 + backend-rs/.env.example | 45 + backend-rs/.gitignore | 2 + backend-rs/Cargo.lock | 4123 ++++++++++++++++++++ backend-rs/Cargo.toml | 93 + backend-rs/Dockerfile | 51 + backend-rs/README.md | 384 ++ backend-rs/src/auth.rs | 128 + backend-rs/src/avatar.rs | 57 + backend-rs/src/blacklist.rs | 89 + backend-rs/src/config.rs | 129 + backend-rs/src/cookies.rs | 40 + backend-rs/src/datetime.rs | 27 + backend-rs/src/db.rs | 40 + backend-rs/src/dto.rs | 184 + backend-rs/src/email.rs | 87 + backend-rs/src/entities/banner.rs | 25 + backend-rs/src/entities/category.rs | 24 + backend-rs/src/entities/flavor.rs | 54 + backend-rs/src/entities/job.rs | 26 + backend-rs/src/entities/mod.rs | 36 + backend-rs/src/entities/notification.rs | 26 + backend-rs/src/entities/profile_comment.rs | 18 + backend-rs/src/entities/rating.rs | 52 + backend-rs/src/entities/reply.rs | 43 + backend-rs/src/entities/system_config.rs | 19 + backend-rs/src/entities/ticket.rs | 22 + backend-rs/src/entities/ticket_message.rs | 18 + backend-rs/src/entities/user.rs | 43 + backend-rs/src/entities/user_ip.rs | 18 + backend-rs/src/error.rs | 86 + backend-rs/src/jobs/backup_db.rs | 111 + backend-rs/src/jobs/cleanup_duplicates.rs | 106 + backend-rs/src/jobs/download.rs | 84 + backend-rs/src/jobs/mod.rs | 160 + backend-rs/src/jobs/scheduler.rs | 65 + backend-rs/src/jobs/seed_banners.rs | 156 + backend-rs/src/jobs/seed_legacy.rs | 225 ++ backend-rs/src/jobs/sync_flavors.rs | 540 +++ backend-rs/src/main.rs | 221 ++ backend-rs/src/media.rs | 60 + backend-rs/src/mentions.rs | 103 + backend-rs/src/merge.rs | 97 + backend-rs/src/openapi.rs | 16 + backend-rs/src/pagination.rs | 75 + backend-rs/src/password.rs | 103 + backend-rs/src/password_policy.rs | 268 ++ backend-rs/src/routes/admin.rs | 448 +++ backend-rs/src/routes/auth.rs | 253 ++ backend-rs/src/routes/banners.rs | 113 + backend-rs/src/routes/categories.rs | 63 + backend-rs/src/routes/flavors.rs | 271 ++ backend-rs/src/routes/mod.rs | 31 + backend-rs/src/routes/ratings.rs | 467 +++ backend-rs/src/routes/replies.rs | 157 + backend-rs/src/routes/social.rs | 90 + backend-rs/src/routes/tickets.rs | 337 ++ backend-rs/src/routes/users.rs | 1317 +++++++ backend-rs/src/search.rs | 45 + backend-rs/src/service.rs | 744 ++++ backend-rs/src/state.rs | 21 + backend-rs/src/throttle.rs | 188 + backend-rs/src/tokens.rs | 154 + backend-rs/src/web.rs | 79 + docker-compose.yml | 55 + frontend/Dockerfile | 41 +- frontend/nginx.conf | 88 + frontend/proxy_params.conf | 8 + scripts/cutover.sh | 200 + 70 files changed, 13707 insertions(+), 37 deletions(-) create mode 100644 MIGRATION.md create mode 100644 backend-rs/.dockerignore create mode 100644 backend-rs/.env.example create mode 100644 backend-rs/.gitignore create mode 100644 backend-rs/Cargo.lock create mode 100644 backend-rs/Cargo.toml create mode 100644 backend-rs/Dockerfile create mode 100644 backend-rs/README.md create mode 100644 backend-rs/src/auth.rs create mode 100644 backend-rs/src/avatar.rs create mode 100644 backend-rs/src/blacklist.rs create mode 100644 backend-rs/src/config.rs create mode 100644 backend-rs/src/cookies.rs create mode 100644 backend-rs/src/datetime.rs create mode 100644 backend-rs/src/db.rs create mode 100644 backend-rs/src/dto.rs create mode 100644 backend-rs/src/email.rs create mode 100644 backend-rs/src/entities/banner.rs create mode 100644 backend-rs/src/entities/category.rs create mode 100644 backend-rs/src/entities/flavor.rs create mode 100644 backend-rs/src/entities/job.rs create mode 100644 backend-rs/src/entities/mod.rs create mode 100644 backend-rs/src/entities/notification.rs create mode 100644 backend-rs/src/entities/profile_comment.rs create mode 100644 backend-rs/src/entities/rating.rs create mode 100644 backend-rs/src/entities/reply.rs create mode 100644 backend-rs/src/entities/system_config.rs create mode 100644 backend-rs/src/entities/ticket.rs create mode 100644 backend-rs/src/entities/ticket_message.rs create mode 100644 backend-rs/src/entities/user.rs create mode 100644 backend-rs/src/entities/user_ip.rs create mode 100644 backend-rs/src/error.rs create mode 100644 backend-rs/src/jobs/backup_db.rs create mode 100644 backend-rs/src/jobs/cleanup_duplicates.rs create mode 100644 backend-rs/src/jobs/download.rs create mode 100644 backend-rs/src/jobs/mod.rs create mode 100644 backend-rs/src/jobs/scheduler.rs create mode 100644 backend-rs/src/jobs/seed_banners.rs create mode 100644 backend-rs/src/jobs/seed_legacy.rs create mode 100644 backend-rs/src/jobs/sync_flavors.rs create mode 100644 backend-rs/src/main.rs create mode 100644 backend-rs/src/media.rs create mode 100644 backend-rs/src/mentions.rs create mode 100644 backend-rs/src/merge.rs create mode 100644 backend-rs/src/openapi.rs create mode 100644 backend-rs/src/pagination.rs create mode 100644 backend-rs/src/password.rs create mode 100644 backend-rs/src/password_policy.rs create mode 100644 backend-rs/src/routes/admin.rs create mode 100644 backend-rs/src/routes/auth.rs create mode 100644 backend-rs/src/routes/banners.rs create mode 100644 backend-rs/src/routes/categories.rs create mode 100644 backend-rs/src/routes/flavors.rs create mode 100644 backend-rs/src/routes/mod.rs create mode 100644 backend-rs/src/routes/ratings.rs create mode 100644 backend-rs/src/routes/replies.rs create mode 100644 backend-rs/src/routes/social.rs create mode 100644 backend-rs/src/routes/tickets.rs create mode 100644 backend-rs/src/routes/users.rs create mode 100644 backend-rs/src/search.rs create mode 100644 backend-rs/src/service.rs create mode 100644 backend-rs/src/state.rs create mode 100644 backend-rs/src/throttle.rs create mode 100644 backend-rs/src/tokens.rs create mode 100644 backend-rs/src/web.rs create mode 100644 frontend/nginx.conf create mode 100644 frontend/proxy_params.conf create mode 100755 scripts/cutover.sh diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..d6f1f52 --- /dev/null +++ b/MIGRATION.md @@ -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. diff --git a/backend-rs/.dockerignore b/backend-rs/.dockerignore new file mode 100644 index 0000000..40f06d7 --- /dev/null +++ b/backend-rs/.dockerignore @@ -0,0 +1,4 @@ +/target +.env +.git +*.md diff --git a/backend-rs/.env.example b/backend-rs/.env.example new file mode 100644 index 0000000..2a4a481 --- /dev/null +++ b/backend-rs/.env.example @@ -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 diff --git a/backend-rs/.gitignore b/backend-rs/.gitignore new file mode 100644 index 0000000..fedaa2b --- /dev/null +++ b/backend-rs/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/backend-rs/Cargo.lock b/backend-rs/Cargo.lock new file mode 100644 index 0000000..b1ac8d8 --- /dev/null +++ b/backend-rs/Cargo.lock @@ -0,0 +1,4123 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "croner" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" +dependencies = [ + "chrono", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "holy_backend_rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "chrono", + "dotenvy", + "flate2", + "futures", + "image", + "jsonwebtoken", + "lettre", + "pbkdf2", + "reqwest", + "sea-orm", + "serde", + "serde_json", + "sha1", + "sha2", + "subtle", + "tar", + "thiserror", + "tokio", + "tokio-cron-scheduler", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "unicode-normalization", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.7", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "serde", + "winapi", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgvector" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +dependencies = [ + "serde", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sea-orm" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive_more", + "futures-util", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "strum", + "thiserror", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-macros" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9a3f90e336ec74803e8eb98c61bc98754c1adfba3b4f84d946237b752b1c88" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "bigdecimal", + "chrono", + "inherent", + "ordered-float", + "rust_decimal", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", + "sea-query", + "serde_json", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand 0.8.6", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-cron-scheduler" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5597b569b4712cf78aa0c9ae29742461b7bda1e49c2a5fdad1d79bf022f8f0" +dependencies = [ + "chrono", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/backend-rs/Cargo.toml b/backend-rs/Cargo.toml new file mode 100644 index 0000000..19eb89e --- /dev/null +++ b/backend-rs/Cargo.toml @@ -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`). +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" diff --git a/backend-rs/Dockerfile b/backend-rs/Dockerfile new file mode 100644 index 0000000..fc1c7b3 --- /dev/null +++ b/backend-rs/Dockerfile @@ -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"] diff --git a/backend-rs/README.md b/backend-rs/README.md new file mode 100644 index 0000000..ce6ea89 --- /dev/null +++ b/backend-rs/README.md @@ -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."}` 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>`) 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//` 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). +``` diff --git a/backend-rs/src/auth.rs b/backend-rs/src/auth.rs new file mode 100644 index 0000000..924c731 --- /dev/null +++ b/backend-rs/src/auth.rs @@ -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); + +/// 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 { + 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 { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_aud = false; + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .ok()?; + if data.claims.token_type != "access" { + return None; + } + Some(data.claims) +} + +impl FromRequestParts for OptionalUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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 for AuthUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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 for AdminUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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)) + } +} diff --git a/backend-rs/src/avatar.rs b/backend-rs/src/avatar.rs new file mode 100644 index 0000000..16b80bf --- /dev/null +++ b/backend-rs/src/avatar.rs @@ -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 `/avatars/` and the returned +//! value is the DB-stored relative path (e.g. `avatars/.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 { + 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, &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}")) +} diff --git a/backend-rs/src/blacklist.rs b/backend-rs/src/blacklist.rs new file mode 100644 index 0000000..06926fa --- /dev/null +++ b/backend-rs/src/blacklist.rs @@ -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 { + 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(()) +} diff --git a/backend-rs/src/config.rs b/backend-rs/src/config.rs new file mode 100644 index 0000000..ad68b7c --- /dev/null +++ b/backend-rs/src/config.rs @@ -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, + 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://?`). 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, + pub port: u16, + pub use_tls: bool, + pub use_ssl: bool, + pub host_user: Option, + pub host_password: Option, + 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 { + 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), + }) + } +} diff --git a/backend-rs/src/cookies.rs b/backend-rs/src/cookies.rs new file mode 100644 index 0000000..d6f6990 --- /dev/null +++ b/backend-rs/src/cookies.rs @@ -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 { + let prefix = format!("{name}="); + cookie_header + .split(';') + .map(str::trim) + .find_map(|kv| kv.strip_prefix(&prefix).map(str::to_string)) +} diff --git a/backend-rs/src/datetime.rs b/backend-rs/src/datetime.rs new file mode 100644 index 0000000..7d248f1 --- /dev/null +++ b/backend-rs/src/datetime.rs @@ -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() +} diff --git a/backend-rs/src/db.rs b/backend-rs/src/db.rs new file mode 100644 index 0000000..6ce9808 --- /dev/null +++ b/backend-rs/src/db.rs @@ -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 { + 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) +} diff --git a/backend-rs/src/dto.rs b/backend-rs/src/dto.rs new file mode 100644 index 0000000..ac09861 --- /dev/null +++ b/backend-rs/src/dto.rs @@ -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, + pub flavor: i32, + pub flavor_name: String, + pub flavor_image: Option, + pub category_name: String, + pub category_slug: String, + pub is_available: bool, + pub is_legacy: bool, + pub score: i32, + pub comment: Option, + pub created_at: String, + pub replies: Vec, +} + +#[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, + pub followed_average_rating: Option, + pub user_rating: Option, + pub ratings: Vec, + pub image_url: Option, + pub image_urls: Vec, + pub is_available: bool, + pub is_legacy: bool, + pub shop_url: Option, + /// Keys "1".."10" → count. BTreeMap keeps stable ordering. + pub rating_distribution: BTreeMap, +} + +/// 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, + pub slug: Option, +} + +#[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, + 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, + pub selected_banner_slug: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ProfileCommentOut { + pub id: i32, + pub author_username: String, + pub author_avatar: Option, + pub text: String, + pub created_at: String, +} + +#[derive(Serialize, ToSchema)] +pub struct NotificationOut { + pub id: i32, + pub actor_username: String, + pub actor_avatar: Option, + pub notification_type: String, + pub rating: Option, + pub reply: Option, + pub ticket: Option, + pub profile_comment: Option, + pub profile_owner_username: Option, + pub is_read: bool, + pub created_at: String, + pub flavor_name: Option, + pub flavor_id: Option, +} + +#[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, + pub subject: String, + pub description: String, + pub status: String, + pub created_at: String, + pub updated_at: String, + pub messages: Vec, +} + +/// Custom payload of the `users/profile/` action (not a serializer). +#[derive(Serialize, ToSchema)] +pub struct PublicProfileOut { + pub id: i32, + pub username: String, + pub theme: String, + pub avatar: Option, + pub following_count: i64, + pub followers_count: i64, + pub is_following: bool, + pub ratings: Vec, + pub comments: Vec, + pub followers: Vec, + pub following: Vec, +} + +/// 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, + pub my_ratings: Vec, +} + +#[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, +} diff --git a/backend-rs/src/email.rs b/backend-rs/src/email.rs new file mode 100644 index 0000000..5057e00 --- /dev/null +++ b/backend-rs/src/email.rs @@ -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::::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) { + 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"); + } + }); +} diff --git a/backend-rs/src/entities/banner.rs b/backend-rs/src/entities/banner.rs new file mode 100644 index 0000000..282115f --- /dev/null +++ b/backend-rs/src/entities/banner.rs @@ -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 {} diff --git a/backend-rs/src/entities/category.rs b/backend-rs/src/entities/category.rs new file mode 100644 index 0000000..d0bef0d --- /dev/null +++ b/backend-rs/src/entities/category.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::Flavor.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/entities/flavor.rs b/backend-rs/src/entities/flavor.rs new file mode 100644 index 0000000..640b874 --- /dev/null +++ b/backend-rs/src/entities/flavor.rs @@ -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, + /// Django JSONField (default list), stored as TEXT. + #[sea_orm(column_type = "Json")] + pub image_urls: Option, + pub image: Option, + /// Django JSONField (default list), stored as TEXT. + #[sea_orm(column_type = "Json")] + pub local_image_paths: Option, + pub main_image_path: Option, + pub shop_url: Option, + pub is_available: bool, + pub is_legacy: bool, + pub external_id: Option, + /// 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 for Entity { + fn to() -> RelationDef { + Relation::Category.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Rating.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/entities/job.rs b/backend-rs/src/entities/job.rs new file mode 100644 index 0000000..e5b6d05 --- /dev/null +++ b/backend-rs/src/entities/job.rs @@ -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, + pub next_run: Option, + 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 {} diff --git a/backend-rs/src/entities/mod.rs b/backend-rs/src/entities/mod.rs new file mode 100644 index 0000000..f777aca --- /dev/null +++ b/backend-rs/src/entities/mod.rs @@ -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_)`. + +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; +} diff --git a/backend-rs/src/entities/notification.rs b/backend-rs/src/entities/notification.rs new file mode 100644 index 0000000..4fa21e0 --- /dev/null +++ b/backend-rs/src/entities/notification.rs @@ -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, + pub recipient_id: i32, + pub reply_id: Option, + pub ticket_id: Option, + #[sea_orm(column_type = "String(StringLen::N(20))")] + pub notification_type: String, + pub profile_comment_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/entities/profile_comment.rs b/backend-rs/src/entities/profile_comment.rs new file mode 100644 index 0000000..a610d70 --- /dev/null +++ b/backend-rs/src/entities/profile_comment.rs @@ -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 {} diff --git a/backend-rs/src/entities/rating.rs b/backend-rs/src/entities/rating.rs new file mode 100644 index 0000000..6ede57f --- /dev/null +++ b/backend-rs/src/entities/rating.rs @@ -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, + 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 for Entity { + fn to() -> RelationDef { + Relation::Flavor.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Reply.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/entities/reply.rs b/backend-rs/src/entities/reply.rs new file mode 100644 index 0000000..f713ff5 --- /dev/null +++ b/backend-rs/src/entities/reply.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::Rating.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/entities/system_config.rs b/backend-rs/src/entities/system_config.rs new file mode 100644 index 0000000..f51b920 --- /dev/null +++ b/backend-rs/src/entities/system_config.rs @@ -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 {} diff --git a/backend-rs/src/entities/ticket.rs b/backend-rs/src/entities/ticket.rs new file mode 100644 index 0000000..ea8a9f0 --- /dev/null +++ b/backend-rs/src/entities/ticket.rs @@ -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 {} diff --git a/backend-rs/src/entities/ticket_message.rs b/backend-rs/src/entities/ticket_message.rs new file mode 100644 index 0000000..e7d64cf --- /dev/null +++ b/backend-rs/src/entities/ticket_message.rs @@ -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 {} diff --git a/backend-rs/src/entities/user.rs b/backend-rs/src/entities/user.rs new file mode 100644 index 0000000..5770bb8 --- /dev/null +++ b/backend-rs/src/entities/user.rs @@ -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, + 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, + pub pending_email: Option, + pub avatar: Option, + pub language: String, + pub selected_banner_id: Option, + pub drawer_anchor: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::rating::Entity")] + Rating, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Rating.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/entities/user_ip.rs b/backend-rs/src/entities/user_ip.rs new file mode 100644 index 0000000..59b1674 --- /dev/null +++ b/backend-rs/src/entities/user_ip.rs @@ -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 {} diff --git a/backend-rs/src/error.rs b/backend-rs/src/error.rs new file mode 100644 index 0000000..f6b2b20 --- /dev/null +++ b/backend-rs/src/error.rs @@ -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 = Result; diff --git a/backend-rs/src/jobs/backup_db.rs b/backend-rs/src/jobs/backup_db.rs new file mode 100644 index 0000000..0d6b8b0 --- /dev/null +++ b/backend-rs/src/jobs/backup_db.rs @@ -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 { + 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 = 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> { + 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) +} diff --git a/backend-rs/src/jobs/cleanup_duplicates.rs b/backend-rs/src/jobs/cleanup_duplicates.rs new file mode 100644 index 0000000..93bf800 --- /dev/null +++ b/backend-rs/src/jobs/cleanup_duplicates.rs @@ -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 = 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 { + let db = &state.db; + let mut log: Vec = 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> = + 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 = 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")) + } +} diff --git a/backend-rs/src/jobs/download.rs b/backend-rs/src/jobs/download.rs new file mode 100644 index 0000000..353ff30 --- /dev/null +++ b/backend-rs/src/jobs/download.rs @@ -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() +} diff --git a/backend-rs/src/jobs/mod.rs b/backend-rs/src/jobs/mod.rs new file mode 100644 index 0000000..cd93e4a --- /dev/null +++ b/backend-rs/src/jobs/mod.rs @@ -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; +} + +/// All registered jobs. Order is irrelevant (the admin list re-sorts by name). +pub fn registry() -> Vec> { + 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> { + 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 { + 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) { + 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::().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() +} diff --git a/backend-rs/src/jobs/scheduler.rs b/backend-rs/src/jobs/scheduler.rs new file mode 100644 index 0000000..48d5154 --- /dev/null +++ b/backend-rs/src/jobs/scheduler.rs @@ -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(()) +} diff --git a/backend-rs/src/jobs/seed_banners.rs b/backend-rs/src/jobs/seed_banners.rs new file mode 100644 index 0000000..2afd308 --- /dev/null +++ b/backend-rs/src/jobs/seed_banners.rs @@ -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 { + let db = &state.db; + let dir = &state.config.banners_dir; + let mut log: Vec = 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, +) -> 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) -> anyhow::Result { + 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?) +} diff --git a/backend-rs/src/jobs/seed_legacy.rs b/backend-rs/src/jobs/seed_legacy.rs new file mode 100644 index 0000000..4a31209 --- /dev/null +++ b/backend-rs/src/jobs/seed_legacy.rs @@ -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 { + let db = &state.db; + let media_root = &state.config.media_root; + let mut log: Vec = 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> = 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 { + 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?) +} diff --git a/backend-rs/src/jobs/sync_flavors.rs b/backend-rs/src/jobs/sync_flavors.rs new file mode 100644 index 0000000..65c7642 --- /dev/null +++ b/backend-rs/src/jobs/sync_flavors.rs @@ -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 { + let db = &state.db; + let media_root = &state.config.media_root; + let client = http_client(); + let mut log: Vec = 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 = 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 = 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 = + 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 { + 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 { + 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> { + 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 { + 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 { + 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, +) -> 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 = 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, + is_available: bool, + image_url: Option, + image_urls: &[String], + dir_slug: &str, +) -> anyhow::Result { + 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, 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//_.`, 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 { + 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 = 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. +fn json_str_list(v: &Option) -> Vec { + match v { + Some(Value::Array(arr)) => { + arr.iter().filter_map(|x| x.as_str().map(String::from)).collect() + } + _ => Vec::new(), + } +} diff --git a/backend-rs/src/main.rs b/backend-rs/src/main.rs new file mode 100644 index 0000000..aea5690 --- /dev/null +++ b/backend-rs/src/main.rs @@ -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 = 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::(), + ) + .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) -> 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"); +} diff --git a/backend-rs/src/media.rs b/backend-rs/src/media.rs new file mode 100644 index 0000000..dfa995b --- /dev/null +++ b/backend-rs/src/media.rs @@ -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 { + 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 { + 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 +} diff --git a/backend-rs/src/mentions.rs b/backend-rs/src/mentions.rs new file mode 100644 index 0000000..83c019a --- /dev/null +++ b/backend-rs/src/mentions.rs @@ -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 { + let mut out: Vec = 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) -> 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, + reply_id: Option, +) -> 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(()) +} diff --git a/backend-rs/src/merge.rs b/backend-rs/src/merge.rs new file mode 100644 index 0000000..1005cec --- /dev/null +++ b/backend-rs/src/merge.rs @@ -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(()) +} diff --git a/backend-rs/src/openapi.rs b/backend-rs/src/openapi.rs new file mode 100644 index 0000000..95069a2 --- /dev/null +++ b/backend-rs/src/openapi.rs @@ -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; diff --git a/backend-rs/src/pagination.rs b/backend-rs/src/pagination.rs new file mode 100644 index 0000000..9fd17cb --- /dev/null +++ b/backend-rs/src/pagination.rs @@ -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 { + pub count: u64, + pub next: Option, + pub previous: Option, + pub results: Vec, +} + +/// 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::().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 = 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 Paginated { + pub fn build( + ctx: &RequestCtx, + results: Vec, + 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, + } + } +} diff --git a/backend-rs/src/password.rs b/backend-rs/src/password.rs new file mode 100644 index 0000000..066ef42 --- /dev/null +++ b/backend-rs/src/password.rs @@ -0,0 +1,103 @@ +//! Django-compatible password verification. +//! +//! Django's default hasher stores `pbkdf2_sha256$$$`, +//! 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::() 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::(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::(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")); + } +} diff --git a/backend-rs/src/password_policy.rs b/backend-rs/src/password_policy.rs new file mode 100644 index 0000000..8aebf51 --- /dev/null +++ b/backend-rs/src/password_policy.rs @@ -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 { + 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 { + static SET: OnceLock> = 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 = HashMap::new(); + for c in b.chars() { + *fullbcount.entry(c).or_insert(0) += 1; + } + let mut avail: HashMap = 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 { + 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"] + ); + } +} diff --git a/backend-rs/src/routes/admin.rs b/backend-rs/src/routes/admin.rs new file mode 100644 index 0000000..d24f38f --- /dev/null +++ b/backend-rs/src/routes/admin.rs @@ -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 { + 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, _admin: AdminUser) -> ApiResult> { + 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 = 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 { + 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, _admin: AdminUser) -> ApiResult> { + let c = get_solo(&state).await?; + Ok(Json(config_json(&c))) +} + +fn opt_bool(data: &Value, field: &str) -> ApiResult> { + 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, + _admin: AdminUser, + body: Bytes, +) -> ApiResult> { + 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, _admin: AdminUser) -> ApiResult> { + 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 = 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, + _admin: AdminUser, + Path(pk): Path, +) -> ApiResult> { + 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, + _admin: AdminUser, + Path(pk): Path, + body: Bytes, +) -> ApiResult> { + 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::().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 { + match data.get(field) { + Some(Value::Number(n)) => n.as_i64().map(|v| v as i32), + Some(Value::String(s)) => s.parse::().ok(), + _ => None, + } + .filter(|v| *v != 0) +} + +async fn merge_flavors( + State(state): State, + _admin: AdminUser, + body: Bytes, +) -> ApiResult> { + 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, + AdminUser(me): AdminUser, +) -> ApiResult> { + 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>> { + let mut map: HashMap> = 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, _admin: AdminUser) -> ApiResult> { + let users = User::find() + .order_by_desc(user::Column::DateJoined) + .all(&state.db) + .await?; + let ids: Vec = users.iter().map(|u| u.id).collect(); + let ip_map = ips_by_user(&state, &ids).await?; + let out: Vec = users + .iter() + .map(|u| { + let ips: Vec = + 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 { + 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, + ctx: RequestCtx, + _admin: AdminUser, + Path(pk): Path, +) -> ApiResult> { + 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, + ctx: RequestCtx, + _admin: AdminUser, + Path(pk): Path, + body: Bytes, +) -> ApiResult> { + 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, + _admin: AdminUser, + Path(pk): Path, +) -> ApiResult { + 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) +} diff --git a/backend-rs/src/routes/auth.rs b/backend-rs/src/routes/auth.rs new file mode 100644 index 0000000..0950dfd --- /dev/null +++ b/backend-rs/src/routes/auth.rs @@ -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 { + 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, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Bytes, +) -> ApiResult { + // 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, +} + +/// POST /api/auth/token/refresh/ — rotate using the body or cookie refresh token. +async fn refresh( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> ApiResult { + let secret = &state.config.secret_key; + + let from_body = (!body.is_empty()) + .then(|| serde_json::from_slice::(&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, +} + +/// POST /api/auth/token/verify/ — validate any token (and blacklist for refresh). +async fn verify(State(state): State, body: Bytes) -> ApiResult { + 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, headers: HeaderMap) -> ApiResult { + 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 { + 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() +} diff --git a/backend-rs/src/routes/banners.rs b/backend-rs/src/routes/banners.rs new file mode 100644 index 0000000..401f8c4 --- /dev/null +++ b/backend-rs/src/routes/banners.rs @@ -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 { + 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) -> ApiResult { + 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, + OptionalUser(viewer): OptionalUser, +) -> ApiResult>> { + 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, + Path(id): Path, +) -> ApiResult> { + 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, +} + +/// 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, + OptionalUser(viewer): OptionalUser, + Query(params): Query, +) -> ApiResult>> { + let mut target: Option = 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))) +} diff --git a/backend-rs/src/routes/categories.rs b/backend-rs/src/routes/categories.rs new file mode 100644 index 0000000..07ceb96 --- /dev/null +++ b/backend-rs/src/routes/categories.rs @@ -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 { + Router::new() + .route("/categories/", get(list)) + .route("/categories/{id}/", get(retrieve)) +} + +#[derive(Deserialize)] +struct PageParam { + page: Option, +} + +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, + ctx: RequestCtx, + Query(params): Query, +) -> ApiResult>> { + 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, + Path(id): Path, +) -> ApiResult> { + Category::find_by_id(id) + .one(&state.db) + .await? + .map(|m| Json(to_dto(m))) + .ok_or(ApiError::NotFound) +} diff --git a/backend-rs/src/routes/flavors.rs b/backend-rs/src/routes/flavors.rs new file mode 100644 index 0000000..3eb07bd --- /dev/null +++ b/backend-rs/src/routes/flavors.rs @@ -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 { + 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, + category: Option, + #[serde(rename = "category__slug")] + category_slug: Option, + search: Option, +} + +/// `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, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, + Query(params): Query, +) -> ApiResult>> { + 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, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, + Path(id): Path, +) -> ApiResult> { + 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, +} + +/// GET /api/flavors/top/ — top 10 flavors that have ratings. +async fn top( + State(state): State, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, + Query(params): Query, +) -> ApiResult>> { + 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, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, +) -> ApiResult>> { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, +) -> ApiResult>> { + // 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 = rows + .iter() + .filter_map(|r| r.try_get::("", "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, +} + +/// GET /api/flavors/search/?q= — up to 15 ranked lightweight hits. +async fn search( + State(state): State, + ctx: RequestCtx, + Query(params): Query, +) -> ApiResult>> { + 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 = hits.into_iter().take(15).map(|(_, h)| h).collect(); + Ok(Json(out)) +} diff --git a/backend-rs/src/routes/mod.rs b/backend-rs/src/routes/mod.rs new file mode 100644 index 0000000..c034969 --- /dev/null +++ b/backend-rs/src/routes/mod.rs @@ -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 { + 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()) +} diff --git a/backend-rs/src/routes/ratings.rs b/backend-rs/src/routes/ratings.rs new file mode 100644 index 0000000..7471771 --- /dev/null +++ b/backend-rs/src/routes/ratings.rs @@ -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 { + 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, +} + +/// Load the acting user row (for superuser checks). 401 if the id is stale. +async fn load_actor(state: &AppState, uid: i32) -> ApiResult { + 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 { + 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, + ctx: RequestCtx, + Query(params): Query, +) -> ApiResult>> { + 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, + ctx: RequestCtx, + Path(id): Path, +) -> ApiResult> { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult<(StatusCode, Json)> { + // 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, + ctx: RequestCtx, + method: Method, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult { + 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 = 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Query(params): Query, +) -> ApiResult>> { + 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 = rows + .iter() + .filter_map(|r| r.try_get::("", "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, + ctx: RequestCtx, +) -> ApiResult>> { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult<(StatusCode, Json)> { + 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))) +} diff --git a/backend-rs/src/routes/replies.rs b/backend-rs/src/routes/replies.rs new file mode 100644 index 0000000..1825269 --- /dev/null +++ b/backend-rs/src/routes/replies.rs @@ -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 { + Router::new() + .route("/replies/", get(list)) + .route( + "/replies/{id}/", + get(retrieve).put(update).patch(update).delete(destroy), + ) +} + +#[derive(Deserialize)] +struct PageParam { + page: Option, +} + +async fn load_actor(state: &AppState, uid: i32) -> ApiResult { + 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, + ctx: RequestCtx, + AuthUser(_uid): AuthUser, + Query(params): Query, +) -> ApiResult>> { + 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, + AuthUser(_uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + 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, + method: Method, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult { + 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) +} diff --git a/backend-rs/src/routes/social.rs b/backend-rs/src/routes/social.rs new file mode 100644 index 0000000..219df96 --- /dev/null +++ b/backend-rs/src/routes/social.rs @@ -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 { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, +) -> ApiResult>> { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + 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, + AuthUser(uid): AuthUser, +) -> ApiResult> { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + 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" }))) +} diff --git a/backend-rs/src/routes/tickets.rs b/backend-rs/src/routes/tickets.rs new file mode 100644 index 0000000..7502327 --- /dev/null +++ b/backend-rs/src/routes/tickets.rs @@ -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 { + 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, +} + +async fn load_user(state: &AppState, uid: i32) -> ApiResult { + 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 { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Query(params): Query, +) -> ApiResult>> { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + 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) -> ApiResult> { + 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, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult<(StatusCode, Json)> { + 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, + ctx: RequestCtx, + method: Method, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult<(StatusCode, Json)> { + 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, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + 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" }))), + } +} diff --git a/backend-rs/src/routes/users.rs b/backend-rs/src/routes/users.rs new file mode 100644 index 0000000..886e434 --- /dev/null +++ b/backend-rs/src/routes/users.rs @@ -0,0 +1,1317 @@ +//! Port of `UserViewSet` (`api/views/user.py`): account lifecycle (signup, +//! verification, password reset, deletion), profile + preferences, avatar +//! upload, the social graph (follow/unfollow, profile comments), and the +//! dashboard / public profile aggregates. +//! +//! Generic `POST/PUT/PATCH/DELETE /users/` + `/users/{id}/` are intentionally +//! NOT exposed: Django's `ModelViewSet` allows any authenticated user to edit +//! any user via `UserSerializer` (no object-level permission), and create makes +//! a password-less user. The frontend never uses these — it goes through the +//! dedicated actions below — so omitting them removes a footgun. They 405. + +use std::net::SocketAddr; + +use axum::body::Bytes; +use axum::extract::{ConnectInfo, Multipart, Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use sea_orm::ActiveValue::Set; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, + QueryFilter, QueryOrder, QuerySelect, Statement, TransactionTrait, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use subtle::ConstantTimeEq; + +use crate::auth::{AuthUser, OptionalUser}; +use crate::dto::{DashboardOut, ProfileCommentOut, PublicProfileOut, UserOut}; +use crate::entities::prelude::*; +use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip}; +use crate::error::{ApiError, ApiResult}; +use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; +use crate::service::{ + build_flavors, build_profile_comments, build_ratings, build_user, build_users, +}; +use crate::state::AppState; +use crate::throttle; +use crate::web::RequestCtx; + +const THEMES: &[&str] = &[ + "holy_light", "holy_dark", "latte", "frappe", "macchiato", "mocha", "pink_pastel", + "mint_pastel", "lavender_pastel", "dracula", "nord", "gruvbox", "oceanic", "t0p_sai", + "t0p_trench", "t0p_blurryface", "t0p_clancy", +]; +const LANGUAGES: &[&str] = &["en", "de"]; +const DRAWER_ANCHORS: &[&str] = &["left", "right"]; + +pub fn router() -> Router { + Router::new() + .route("/users/", get(list)) + .route("/users/following_list/", get(following_list)) + .route("/users/signup/", post(signup)) + .route("/users/resend_verification/", post(resend_verification)) + .route("/users/verify_signup/", post(verify_signup)) + .route("/users/profile/{username}/", get(public_profile)) + .route("/users/me/", get(me)) + .route("/users/update_preferences/", patch(update_preferences)) + .route("/users/update_avatar/", post(update_avatar)) + .route("/users/change_password/", post(change_password)) + .route("/users/update_profile/", patch(update_profile)) + .route("/users/confirm_email/", post(confirm_email)) + .route("/users/request_password_reset/", post(request_password_reset)) + .route("/users/complete_password_reset/", post(complete_password_reset)) + .route("/users/request_account_deletion/", post(request_account_deletion)) + .route("/users/confirm_account_deletion/", post(confirm_account_deletion)) + .route("/users/dashboard/", get(dashboard)) + .route("/users/{id}/", get(retrieve)) + .route("/users/{id}/follow/", post(follow)) + .route("/users/{id}/unfollow/", post(unfollow)) + .route("/users/{id}/add_comment/", post(add_comment)) + .route( + "/users/{id}/delete_comment/{comment_id}/", + axum::routing::delete(delete_comment), + ) +} + +#[derive(Deserialize)] +struct PageParam { + page: Option, +} + +async fn load_user(state: &AppState, uid: i32) -> ApiResult { + User::find_by_id(uid) + .one(&state.db) + .await? + .ok_or(ApiError::Unauthorized) +} + +/// Non-empty string field, mirroring Django's `if not value` truthiness. +fn field_str(data: &Value, key: &str) -> Option { + match data.get(key) { + Some(Value::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + } +} + +fn parse_body(body: &Bytes) -> Value { + serde_json::from_slice(body).unwrap_or(Value::Null) +} + +fn err_json(status: StatusCode, body: Value) -> ApiError { + // Validation always renders the body verbatim with 400; for non-400 statuses + // we need a distinct path. + if status == StatusCode::BAD_REQUEST { + ApiError::Validation(body) + } else { + ApiError::JsonStatus(status, body) + } +} + +/// Constant-time comparison of a stored confirmation code against a supplied +/// one. Returns true only when both are present and equal. Codes are a fixed 6 +/// digits, so a length mismatch isn't secret — only the content comparison needs +/// to be constant-time to avoid leaking a correct prefix. +fn code_matches(stored: Option<&str>, provided: Option<&str>) -> bool { + match (stored, provided) { + (Some(s), Some(p)) if s.len() == p.len() => s.as_bytes().ct_eq(p.as_bytes()).into(), + _ => false, + } +} + +/// Enforce the ported Django password validators (`password_policy`), returning +/// a 400 whose body carries the aggregated messages under `error` (the key the +/// frontend reads for account flows) on failure. +fn check_password_policy( + password: &str, + attrs: &crate::password_policy::UserAttrs, +) -> Result<(), ApiError> { + let errors = crate::password_policy::validate(password, attrs); + if errors.is_empty() { + Ok(()) + } else { + Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": errors.join(" ") }), + )) + } +} + +fn gen_code() -> String { + // 6-digit numeric code (parity with `_generate_code`). + let n = uuid::Uuid::new_v4().as_u128(); + format!("{:06}", (n % 1_000_000) as u32) +} + +// Confirmation-code TTL keys (see `throttle::Security`). One namespace per flow +// so a code minted for one purpose can't satisfy another. +fn signup_code_key(username: &str) -> String { + format!("code:signup:{}", username.to_lowercase()) +} +fn pwreset_code_key(email: &str) -> String { + format!("code:pwreset:{}", email.to_lowercase()) +} +fn email_code_key(uid: i32) -> String { + format!("code:email:{uid}") +} +fn del_code_key(uid: i32) -> String { + format!("code:del:{uid}") +} + +// ---- social-graph raw SQL helpers --------------------------------------- + +async fn is_following(state: &AppState, from: i32, to: i32) -> ApiResult { + let row = state + .db + .query_one(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT 1 FROM api_user_following WHERE from_user_id = ? AND to_user_id = ? LIMIT 1", + [from.into(), to.into()], + )) + .await?; + Ok(row.is_some()) +} + +async fn add_follow(state: &AppState, from: i32, to: i32) -> ApiResult<()> { + state + .db + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO api_user_following (from_user_id, to_user_id) VALUES (?, ?)", + [from.into(), to.into()], + )) + .await?; + Ok(()) +} + +async fn remove_follow(state: &AppState, from: i32, to: i32) -> ApiResult<()> { + state + .db + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM api_user_following WHERE from_user_id = ? AND to_user_id = ?", + [from.into(), to.into()], + )) + .await?; + Ok(()) +} + +/// User ids `from` follows (the `following` set). +async fn following_ids(state: &AppState, from: i32) -> ApiResult> { + let rows = state + .db + .query_all(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT to_user_id AS uid FROM api_user_following WHERE from_user_id = ?", + [from.into()], + )) + .await?; + rows.into_iter().map(|r| r.try_get("", "uid").map_err(Into::into)).collect() +} + +/// User ids that follow `to` (the `followers` set). +async fn follower_ids(state: &AppState, to: i32) -> ApiResult> { + let rows = state + .db + .query_all(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT from_user_id AS uid FROM api_user_following WHERE to_user_id = ?", + [to.into()], + )) + .await?; + rows.into_iter().map(|r| r.try_get("", "uid").map_err(Into::into)).collect() +} + +async fn users_by_ids(state: &AppState, ids: &[i32]) -> ApiResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + Ok(User::find() + .filter(user::Column::Id.is_in(ids.to_vec())) + .order_by_asc(user::Column::Id) + .all(&state.db) + .await?) +} + +// ---- list / retrieve ----------------------------------------------------- + +/// GET /api/users/ +async fn list( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Query(params): Query, +) -> ApiResult>> { + let page = parse_page(params.page.as_deref()); + let size = DEFAULT_PAGE_SIZE; + let count = User::find().count(&state.db).await?; + let models = User::find() + .order_by_asc(user::Column::Id) + .limit(size) + .offset((page - 1) * size) + .all(&state.db) + .await?; + let results = build_users(&state, &ctx, models, Some(uid)).await?; + Ok(Json(Paginated::build(&ctx, results, count, page, size))) +} + +/// GET /api/users/{id}/ +async fn retrieve( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + let u = User::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + Ok(Json(build_user(&state, &ctx, u, Some(uid)).await?)) +} + +/// GET /api/users/following_list/ +async fn following_list( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, +) -> ApiResult>> { + let ids = following_ids(&state, uid).await?; + let users = users_by_ids(&state, &ids).await?; + Ok(Json(build_users(&state, &ctx, users, Some(uid)).await?)) +} + +// ---- me / preferences / profile ----------------------------------------- + +/// GET /api/users/me/ — also logs the caller's IP. +async fn me( + State(state): State, + ctx: RequestCtx, + headers: HeaderMap, + ConnectInfo(peer): ConnectInfo, + AuthUser(uid): AuthUser, +) -> ApiResult> { + let u = load_user(&state, uid).await?; + log_user_ip(&state, uid, &headers, peer).await?; + Ok(Json(build_user(&state, &ctx, u, Some(uid)).await?)) +} + +async fn log_user_ip( + state: &AppState, + uid: i32, + headers: &HeaderMap, + peer: SocketAddr, +) -> ApiResult<()> { + let ip = crate::web::client_ip(headers, peer); + + let existing = UserIp::find() + .filter(user_ip::Column::UserId.eq(uid)) + .filter(user_ip::Column::IpAddress.eq(ip.clone())) + .one(&state.db) + .await?; + let now = crate::datetime::now_micros(); + match existing { + Some(row) => { + let mut active: user_ip::ActiveModel = row.into(); + active.last_login = Set(now); + active.update(&state.db).await?; + } + None => { + user_ip::ActiveModel { + ip_address: Set(ip), + last_login: Set(now), + user_id: Set(uid), + ..Default::default() + } + .insert(&state.db) + .await?; + } + } + Ok(()) +} + +/// PATCH /api/users/update_preferences/ +async fn update_preferences( + State(state): State, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult> { + let u = load_user(&state, uid).await?; + let data = parse_body(&body); + let mut active: user::ActiveModel = u.clone().into(); + let mut updated = false; + + if let Some(theme) = data.get("theme").and_then(|v| v.as_str()) { + if THEMES.contains(&theme) { + active.theme = Set(theme.to_string()); + updated = true; + } + } + if let Some(lang) = data.get("language").and_then(|v| v.as_str()) { + if LANGUAGES.contains(&lang) { + active.language = Set(lang.to_string()); + updated = true; + } + } + if let Some(da) = data.get("drawer_anchor").and_then(|v| v.as_str()) { + if DRAWER_ANCHORS.contains(&da) { + active.drawer_anchor = Set(da.to_string()); + updated = true; + } + } + + // selected_banner: present + non-null. "" clears; otherwise must be enabled. + match data.get("selected_banner") { + None | Some(Value::Null) => {} + Some(Value::String(s)) if s.is_empty() => { + active.selected_banner_id = Set(None); + updated = true; + } + Some(v) => { + let banner_id = v.as_i64().map(|n| n as i32).or_else(|| { + v.as_str().and_then(|s| s.parse::().ok()) + }); + match banner_id { + Some(bid) => { + let banner = Banner::find_by_id(bid) + .filter(crate::entities::banner::Column::IsEnabled.eq(true)) + .one(&state.db) + .await?; + if banner.is_none() { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Invalid or disabled banner" }), + )); + } + active.selected_banner_id = Set(Some(bid)); + updated = true; + } + None => { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Invalid or disabled banner" }), + )); + } + } + } + } + + if !updated { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "No valid updates provided" }), + )); + } + let saved = active.update(&state.db).await?; + Ok(Json(json!({ + "status": "preferences updated", + "theme": saved.theme, + "language": saved.language, + "drawer_anchor": saved.drawer_anchor, + "selected_banner": saved.selected_banner_id, + }))) +} + +/// POST /api/users/update_avatar/ — multipart form, field `avatar`. +async fn update_avatar( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + mut multipart: Multipart, +) -> ApiResult> { + let mut data: Option<(String, Vec)> = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("invalid multipart: {e}")))? + { + if field.name() == Some("avatar") { + let name = field.file_name().unwrap_or("avatar").to_string(); + let bytes = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("read upload: {e}")))?; + data = Some((name, bytes.to_vec())); + break; + } + } + + let Some((name, bytes)) = data else { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "No avatar provided" }), + )); + }; + if bytes.len() > 2 * 1024 * 1024 { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "File size exceeds 2MB limit" }), + )); + } + + let media_root = state.config.media_root.clone(); + let rel = tokio::task::spawn_blocking(move || { + crate::avatar::process_and_save(&media_root, &name, &bytes) + }) + .await + .map_err(|_| ApiError::Internal)? + .map_err(ApiError::BadRequest)?; + + let u = load_user(&state, uid).await?; + let mut active: user::ActiveModel = u.into(); + active.avatar = Set(Some(rel)); + let saved = active.update(&state.db).await?; + Ok(Json(build_user(&state, &ctx, saved, Some(uid)).await?)) +} + +/// POST /api/users/change_password/ +async fn change_password( + State(state): State, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult> { + let u = load_user(&state, uid).await?; + let data = parse_body(&body); + let old = data.get("old_password").and_then(|v| v.as_str()).unwrap_or(""); + let new = data.get("new_password").and_then(|v| v.as_str()).unwrap_or(""); + + if !crate::password::verify_password(old, &u.password) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Wrong old password" }), + )); + } + check_password_policy( + new, + &crate::password_policy::UserAttrs { + username: &u.username, + first_name: &u.first_name, + last_name: &u.last_name, + email: &u.email, + }, + )?; + let mut active: user::ActiveModel = u.into(); + active.password = Set(crate::password::hash_password(new)); + active.update(&state.db).await?; + Ok(Json(json!({ "status": "password changed" }))) +} + +/// PATCH /api/users/update_profile/ +async fn update_profile( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult> { + let u = load_user(&state, uid).await?; + let data = parse_body(&body); + let mut active: user::ActiveModel = u.clone().into(); + let mut message = String::new(); + + if let Some(username) = field_str(&data, "username") { + if username != u.username { + let taken = User::find() + .filter(user::Column::Username.eq(username.clone())) + .one(&state.db) + .await? + .is_some(); + if taken { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Username already taken" }), + )); + } + active.username = Set(username); + } + } + + if let Some(email) = field_str(&data, "email") { + if email != u.email { + let code = gen_code(); + active.pending_email = Set(Some(email.clone())); + active.email_confirmation_code = Set(Some(code.clone())); + state.security.record_code(&email_code_key(uid)); + let new_username = match &active.username { + Set(v) => v.clone(), + _ => u.username.clone(), + }; + crate::email::spawn_mail( + state.config.email.clone(), + "Confirm your new email".into(), + format!("Hi {new_username},\n\nYour confirmation code is: {code}"), + vec![email], + ); + message = " Confirmation code sent to new email.".into(); + } + } + + let saved = active.update(&state.db).await?; + let mut out = serde_json::to_value(build_user(&state, &ctx, saved, Some(uid)).await?) + .map_err(|_| ApiError::Internal)?; + if !message.is_empty() { + if let Value::Object(ref mut map) = out { + map.insert("message".into(), Value::String(message)); + } + } + Ok(Json(out)) +} + +/// POST /api/users/confirm_email/ +async fn confirm_email( + State(state): State, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult> { + let code_key = email_code_key(uid); + state + .security + .check_rate(&format!("attempt:{code_key}"), throttle::CODE_ATTEMPTS)?; + if state.security.code_expired(&code_key) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Confirmation code has expired. Please request a new one." }), + )); + } + let u = load_user(&state, uid).await?; + let data = parse_body(&body); + let code = data.get("code").and_then(|v| v.as_str()); + + if !code_matches(u.email_confirmation_code.as_deref(), code) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Invalid or expired code" }), + )); + } + let Some(pending) = u.pending_email.clone() else { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "No pending email change" }), + )); + }; + let mut active: user::ActiveModel = u.into(); + active.email = Set(pending.clone()); + active.pending_email = Set(None); + active.email_confirmation_code = Set(None); + active.update(&state.db).await?; + state.security.clear_code(&code_key); + Ok(Json(json!({ "status": "Email confirmed", "email": pending }))) +} + +// ---- signup / verification ---------------------------------------------- + +/// POST /api/users/signup/ (AllowAny) +async fn signup( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Bytes, +) -> ApiResult<(StatusCode, Json)> { + let ip = crate::web::client_ip(&headers, peer); + state.security.check_rate(&format!("signup:ip:{ip}"), throttle::SIGNUP)?; + let data = parse_body(&body); + let (Some(username), Some(email), Some(password)) = ( + field_str(&data, "username"), + field_str(&data, "email"), + field_str(&data, "password"), + ) else { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Username, email and password required" }), + )); + }; + + if User::find() + .filter(user::Column::Username.eq(username.clone())) + .one(&state.db) + .await? + .is_some() + { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Username already exists" }), + )); + } + if User::find() + .filter(user::Column::Email.eq(email.clone())) + .one(&state.db) + .await? + .is_some() + { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Email already exists" }), + )); + } + + check_password_policy( + &password, + &crate::password_policy::UserAttrs { + username: &username, + first_name: "", + last_name: "", + email: &email, + }, + )?; + + let code = gen_code(); + let created = user::ActiveModel { + password: Set(crate::password::hash_password(&password)), + last_login: Set(None), + is_superuser: Set(false), + username: Set(username.clone()), + first_name: Set(String::new()), + last_name: Set(String::new()), + email: Set(email.clone()), + is_staff: Set(false), + is_active: Set(false), + date_joined: Set(crate::datetime::now_micros()), + theme: Set("holy_light".into()), + email_confirmation_code: Set(Some(code.clone())), + pending_email: Set(None), + avatar: Set(None), + language: Set("en".into()), + selected_banner_id: Set(None), + drawer_anchor: Set("right".into()), + ..Default::default() + } + .insert(&state.db) + .await?; + + let link = format!( + "{}/verify-email?username={}&code={}", + state.config.frontend_url, username, code + ); + let msg = format!( + "Hi {username},\n\nYour verification code is: {code}\n\n\ + Alternatively, you can complete your registration by clicking the link below:\n\ + {link}\n\nWelcome to the archive!" + ); + // Signup awaits delivery and rolls back the user on failure (parity with the + // try/except in the Django view). + if let Err(e) = crate::email::send_mail( + &state.config.email, + "Verify your Holy Flavors account", + &msg, + &[email.clone()], + ) + .await + { + let _ = User::delete_by_id(created.id).exec(&state.db).await; + tracing::error!(error = %e, "signup verification email failed"); + return Err(err_json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ + "error": "Failed to send verification email. Please try again later." + }), + )); + } + state.security.record_code(&signup_code_key(&username)); + + Ok(( + StatusCode::CREATED, + Json(json!({ + "status": "User created, please verify your email", + "username": username, + "email": email, + })), + )) +} + +/// POST /api/users/resend_verification/ (AllowAny) +async fn resend_verification( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Bytes, +) -> ApiResult> { + let ip = crate::web::client_ip(&headers, peer); + state + .security + .check_rate(&format!("resend:ip:{ip}"), throttle::RESEND_VERIFICATION)?; + let data = parse_body(&body); + let Some(username) = field_str(&data, "username") else { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Username required" }), + )); + }; + let user = User::find() + .filter(user::Column::Username.eq(username)) + .filter(user::Column::IsActive.eq(false)) + .one(&state.db) + .await?; + let Some(user) = user else { + return Err(err_json( + StatusCode::NOT_FOUND, + json!({ "error": "User not found or already active" }), + )); + }; + + let code = gen_code(); + let email = user.email.clone(); + let uname = user.username.clone(); + let mut active: user::ActiveModel = user.into(); + active.email_confirmation_code = Set(Some(code.clone())); + active.update(&state.db).await?; + + let link = format!( + "{}/verify-email?username={}&code={}", + state.config.frontend_url, uname, code + ); + let msg = format!( + "Hi {uname},\n\nYour new verification code is: {code}\n\n\ + Alternatively, you can complete your registration by clicking the link below:\n{link}" + ); + if let Err(e) = crate::email::send_mail( + &state.config.email, + "Verify your Holy Flavors account", + &msg, + &[email], + ) + .await + { + tracing::error!(error = %e, "resend verification email failed"); + return Err(err_json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "Failed to send email. Please try again later." }), + )); + } + state.security.record_code(&signup_code_key(&uname)); + Ok(Json(json!({ "status": "Verification code resent!" }))) +} + +/// POST /api/users/verify_signup/ (AllowAny) +async fn verify_signup(State(state): State, body: Bytes) -> ApiResult> { + let data = parse_body(&body); + let username = field_str(&data, "username").unwrap_or_default(); + let code = data.get("code").and_then(|v| v.as_str()); + + let code_key = signup_code_key(&username); + state + .security + .check_rate(&format!("attempt:{code_key}"), throttle::CODE_ATTEMPTS)?; + if state.security.code_expired(&code_key) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Verification code has expired. Please request a new one." }), + )); + } + + let user = User::find() + .filter(user::Column::Username.eq(username)) + .filter(user::Column::IsActive.eq(false)) + .one(&state.db) + .await?; + let Some(user) = user else { + return Err(err_json( + StatusCode::NOT_FOUND, + json!({ "error": "User not found or already active" }), + )); + }; + + if code_matches(user.email_confirmation_code.as_deref(), code) { + let mut active: user::ActiveModel = user.into(); + active.is_active = Set(true); + active.email_confirmation_code = Set(None); + active.update(&state.db).await?; + state.security.clear_code(&code_key); + Ok(Json(json!({ "status": "Account verified successfully!" }))) + } else { + Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Invalid verification code" }), + )) + } +} + +// ---- password reset / account deletion ---------------------------------- + +/// POST /api/users/request_password_reset/ (AllowAny). Always returns the same +/// generic message (no account enumeration). +async fn request_password_reset( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Bytes, +) -> ApiResult> { + let ip = crate::web::client_ip(&headers, peer); + state + .security + .check_rate(&format!("pwreset_req:ip:{ip}"), throttle::PASSWORD_RESET_REQUEST)?; + let generic = json!({ + "status": "If an account exists with this email, a reset code has been sent." + }); + let data = parse_body(&body); + let Some(email) = field_str(&data, "email") else { + return Ok(Json(generic)); + }; + let user = User::find() + .filter(user::Column::Email.eq(email.clone())) + .filter(user::Column::IsActive.eq(true)) + .one(&state.db) + .await?; + let Some(user) = user else { + return Ok(Json(generic)); + }; + + let code = gen_code(); + let uname = user.username.clone(); + let mut active: user::ActiveModel = user.into(); + active.email_confirmation_code = Set(Some(code.clone())); + active.update(&state.db).await?; + state.security.record_code(&pwreset_code_key(&email)); + + crate::email::spawn_mail( + state.config.email.clone(), + "Password Reset Request".into(), + format!("Hi {uname},\n\nYour password reset code is: {code}"), + vec![email], + ); + Ok(Json(generic)) +} + +/// POST /api/users/complete_password_reset/ (AllowAny) +async fn complete_password_reset( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Bytes, +) -> ApiResult> { + let ip = crate::web::client_ip(&headers, peer); + state + .security + .check_rate(&format!("pwreset_done:ip:{ip}"), throttle::PASSWORD_RESET_COMPLETE)?; + let data = parse_body(&body); + let email = field_str(&data, "email").unwrap_or_default(); + let code = data.get("code").and_then(|v| v.as_str()).unwrap_or_default(); + let new_password = data.get("password").and_then(|v| v.as_str()).unwrap_or(""); + + let code_key = pwreset_code_key(&email); + // Per-account attempt lockout on top of the IP limit, so rotating source IPs + // can't grind the 6-digit space against one account. + state + .security + .check_rate(&format!("attempt:{code_key}"), throttle::CODE_ATTEMPTS)?; + if state.security.code_expired(&code_key) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Reset code has expired. Please request a new one." }), + )); + } + + let user = User::find() + .filter(user::Column::Email.eq(email.clone())) + .filter(user::Column::EmailConfirmationCode.eq(code)) + .one(&state.db) + .await?; + let Some(user) = user else { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Invalid email or code" }), + )); + }; + + check_password_policy( + new_password, + &crate::password_policy::UserAttrs { + username: &user.username, + first_name: &user.first_name, + last_name: &user.last_name, + email: &user.email, + }, + )?; + + let mut active: user::ActiveModel = user.into(); + active.password = Set(crate::password::hash_password(new_password)); + active.email_confirmation_code = Set(None); + active.update(&state.db).await?; + state.security.clear_code(&code_key); + Ok(Json(json!({ "status": "Password reset successful!" }))) +} + +/// POST /api/users/request_account_deletion/ +async fn request_account_deletion( + State(state): State, + AuthUser(uid): AuthUser, + _body: Bytes, +) -> ApiResult> { + state + .security + .check_rate(&format!("deletion_req:user:{uid}"), throttle::ACCOUNT_DELETION_REQUEST)?; + let user = load_user(&state, uid).await?; + let code = gen_code(); + let uname = user.username.clone(); + let email = user.email.clone(); + let mut active: user::ActiveModel = user.into(); + active.email_confirmation_code = Set(Some(code.clone())); + active.update(&state.db).await?; + state.security.record_code(&del_code_key(uid)); + + crate::email::spawn_mail( + state.config.email.clone(), + "Confirm Account Deletion - Holy Flavors Archive".into(), + format!( + "Hi {uname},\n\nYou requested to delete your account. \ + This action is permanent.\n\nYour deletion code is: {code}" + ), + vec![email], + ); + Ok(Json(json!({ "status": "Code sent to your email" }))) +} + +/// POST /api/users/confirm_account_deletion/ — deletes the account (cascades). +async fn confirm_account_deletion( + State(state): State, + AuthUser(uid): AuthUser, + body: Bytes, +) -> ApiResult> { + let code_key = del_code_key(uid); + state + .security + .check_rate(&format!("attempt:{code_key}"), throttle::CODE_ATTEMPTS)?; + if state.security.code_expired(&code_key) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Deletion code has expired. Please request a new one." }), + )); + } + let user = load_user(&state, uid).await?; + let data = parse_body(&body); + let code = data.get("code").and_then(|v| v.as_str()); + if !code_matches(user.email_confirmation_code.as_deref(), code) { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Invalid or expired code" }), + )); + } + state.security.clear_code(&code_key); + delete_user_cascade(&state, uid).await?; + Ok(Json(json!({ "status": "Account deleted" }))) +} + +/// Emulate Django's app-level CASCADE for deleting a user. SQLite enforces FKs +/// but has no ON DELETE CASCADE, so every referencing row must be removed first, +/// in dependency order, inside one transaction. +pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult<()> { + let txn = state.db.begin().await?; + let exec = |sql: &'static str| { + Statement::from_sql_and_values(DbBackend::Sqlite, sql, [uid.into()]) + }; + + // Notifications referencing the user directly (recipient/actor) or via the + // user's ratings/replies/tickets/profile-comments. + txn.execute(exec( + "DELETE FROM api_notification WHERE recipient_id = ?1 OR actor_id = ?1 \ + OR rating_id IN (SELECT id FROM api_rating WHERE user_id = ?1) \ + OR reply_id IN (SELECT id FROM api_reply WHERE user_id = ?1) \ + OR ticket_id IN (SELECT id FROM api_ticket WHERE user_id = ?1) \ + OR profile_comment_id IN (SELECT id FROM api_profilecomment \ + WHERE author_id = ?1 OR profile_owner_id = ?1)", + )) + .await?; + // Replies: on the user's ratings, or authored by the user. + txn.execute(exec( + "DELETE FROM api_reply WHERE user_id = ?1 \ + OR rating_id IN (SELECT id FROM api_rating WHERE user_id = ?1)", + )) + .await?; + txn.execute(exec("DELETE FROM api_rating WHERE user_id = ?")).await?; + txn.execute(exec( + "DELETE FROM api_ticketmessage WHERE user_id = ?1 \ + OR ticket_id IN (SELECT id FROM api_ticket WHERE user_id = ?1)", + )) + .await?; + txn.execute(exec("DELETE FROM api_ticket WHERE user_id = ?")).await?; + txn.execute(exec( + "DELETE FROM api_profilecomment WHERE author_id = ?1 OR profile_owner_id = ?1", + )) + .await?; + txn.execute(exec("DELETE FROM api_userip WHERE user_id = ?")).await?; + txn.execute(exec( + "DELETE FROM api_user_following WHERE from_user_id = ?1 OR to_user_id = ?1", + )) + .await?; + // Token blacklist tables reference the user via outstanding tokens. + txn.execute(exec( + "DELETE FROM token_blacklist_blacklistedtoken WHERE token_id IN \ + (SELECT id FROM token_blacklist_outstandingtoken WHERE user_id = ?)", + )) + .await?; + txn.execute(exec( + "DELETE FROM token_blacklist_outstandingtoken WHERE user_id = ?", + )) + .await?; + txn.execute(exec("DELETE FROM api_user WHERE id = ?")).await?; + txn.commit().await?; + Ok(()) +} + +// ---- follow / unfollow --------------------------------------------------- + +/// POST /api/users/{id}/follow/ +async fn follow( + State(state): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + load_user(&state, uid).await?; + let target = User::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + if target.id == uid { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "You cannot follow yourself" }), + )); + } + if !is_following(&state, uid, target.id).await? { + add_follow(&state, uid, target.id).await?; + notification::ActiveModel { + is_read: Set(false), + created_at: Set(crate::datetime::now_micros()), + actor_id: Set(uid), + recipient_id: Set(target.id), + notification_type: Set("follow".into()), + ..Default::default() + } + .insert(&state.db) + .await?; + } + Ok(Json(json!({ "status": "followed" }))) +} + +/// POST /api/users/{id}/unfollow/ +async fn unfollow( + State(state): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> ApiResult> { + load_user(&state, uid).await?; + let target = User::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + remove_follow(&state, uid, target.id).await?; + Ok(Json(json!({ "status": "unfollowed" }))) +} + +// ---- profile comments ---------------------------------------------------- + +/// POST /api/users/{id}/add_comment/ +async fn add_comment( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult<(StatusCode, Json)> { + let me = load_user(&state, uid).await?; + let owner = User::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + let data = parse_body(&body); + let Some(text) = field_str(&data, "text") else { + return Err(err_json( + StatusCode::BAD_REQUEST, + json!({ "error": "Text is required" }), + )); + }; + + let comment = profile_comment::ActiveModel { + text: Set(text), + created_at: Set(crate::datetime::now_micros()), + author_id: Set(me.id), + profile_owner_id: Set(owner.id), + ..Default::default() + } + .insert(&state.db) + .await?; + + if owner.id != me.id { + notification::ActiveModel { + is_read: Set(false), + created_at: Set(crate::datetime::now_micros()), + actor_id: Set(me.id), + recipient_id: Set(owner.id), + notification_type: Set("profile_comment".into()), + profile_comment_id: Set(Some(comment.id)), + ..Default::default() + } + .insert(&state.db) + .await?; + } + + let mut dtos = build_profile_comments(&state, &ctx, vec![comment]).await?; + Ok((StatusCode::CREATED, Json(dtos.pop().ok_or(ApiError::Internal)?))) +} + +/// DELETE /api/users/{id}/delete_comment/{comment_id}/ +async fn delete_comment( + State(state): State, + AuthUser(uid): AuthUser, + Path((id, comment_id)): Path<(i32, i32)>, +) -> ApiResult { + let me = load_user(&state, uid).await?; + let owner = User::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + let comment = ProfileComment::find_by_id(comment_id) + .filter(profile_comment::Column::ProfileOwnerId.eq(owner.id)) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + + if comment.author_id != me.id && !me.is_superuser && owner.id != me.id { + return Err(ApiError::Forbidden( + "You do not have permission to perform this action.".into(), + )); + } + + let txn = state.db.begin().await?; + Notification::delete_many() + .filter(notification::Column::ProfileCommentId.eq(comment.id)) + .exec(&txn) + .await?; + ProfileComment::delete_by_id(comment.id).exec(&txn).await?; + txn.commit().await?; + Ok(StatusCode::NO_CONTENT) +} + +// ---- public profile / dashboard ----------------------------------------- + +/// GET /api/users/profile/{username}/ (AllowAny) +async fn public_profile( + State(state): State, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, + Path(username): Path, +) -> ApiResult> { + let user = User::find() + .filter(user::Column::Username.eq(username)) + .one(&state.db) + .await?; + let Some(user) = user else { + return Err(err_json( + StatusCode::NOT_FOUND, + json!({ "error": "User not found" }), + )); + }; + + let ratings_models = Rating::find() + .filter(rating::Column::UserId.eq(user.id)) + .order_by_desc(rating::Column::Score) + .all(&state.db) + .await?; + let ratings = build_ratings(&state, &ctx, ratings_models).await?; + + let comment_models = ProfileComment::find() + .filter(profile_comment::Column::ProfileOwnerId.eq(user.id)) + .order_by_desc(profile_comment::Column::CreatedAt) + .all(&state.db) + .await?; + let comments = build_profile_comments(&state, &ctx, comment_models).await?; + + let follower_ids_v = follower_ids(&state, user.id).await?; + let following_ids_v = following_ids(&state, user.id).await?; + let followers = build_users( + &state, + &ctx, + users_by_ids(&state, &follower_ids_v).await?, + viewer, + ) + .await?; + let following = build_users( + &state, + &ctx, + users_by_ids(&state, &following_ids_v).await?, + viewer, + ) + .await?; + + let is_following = match viewer { + Some(v) => is_following(&state, v, user.id).await?, + None => false, + }; + + Ok(Json(PublicProfileOut { + id: user.id, + username: user.username.clone(), + theme: user.theme.clone(), + avatar: user + .avatar + .as_deref() + .filter(|s| !s.is_empty()) + .map(|a| crate::media::image_field_url(&ctx, &state.config.media_url, a)), + following_count: following_ids_v.len() as i64, + followers_count: follower_ids_v.len() as i64, + is_following, + ratings, + comments, + followers, + following, + })) +} + +/// GET /api/users/dashboard/ +async fn dashboard( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, +) -> ApiResult> { + let me = load_user(&state, uid).await?; + let followed = following_ids(&state, uid).await?; + + let my_ratings_models = Rating::find() + .filter(rating::Column::UserId.eq(uid)) + .order_by_desc(rating::Column::CreatedAt) + .all(&state.db) + .await?; + let rated_count = my_ratings_models.len() as i64; + let rated_flavor_ids: std::collections::HashSet = + my_ratings_models.iter().map(|r| r.flavor_id).collect(); + let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?; + + // Missing flavors: not rated by me, excluding the "Packs and other" category, + // ordered by category name then flavor name. + let packs = crate::entities::category::Entity::find() + .filter(crate::entities::category::Column::Name.eq("Packs and other")) + .one(&state.db) + .await?; + let mut q = Flavor::find(); + if let Some(p) = packs { + q = q.filter(flavor::Column::CategoryId.ne(p.id)); + } + let mut all_flavors = q.all(&state.db).await?; + all_flavors.retain(|f| !rated_flavor_ids.contains(&f.id)); + + // Order by category name then flavor name (resolve category names first). + let cat_ids: Vec = all_flavors.iter().map(|f| f.category_id).collect(); + let cat_names: std::collections::HashMap = + crate::entities::category::Entity::find() + .filter(crate::entities::category::Column::Id.is_in(cat_ids)) + .all(&state.db) + .await? + .into_iter() + .map(|c| (c.id, c.name)) + .collect(); + all_flavors.sort_by(|a, b| { + let ca = cat_names.get(&a.category_id).map(String::as_str).unwrap_or(""); + let cb = cat_names.get(&b.category_id).map(String::as_str).unwrap_or(""); + ca.cmp(cb).then_with(|| a.name.cmp(&b.name)) + }); + + let missing_count = all_flavors.len() as i64; + let missing_flavors = build_flavors(&state, &ctx, all_flavors, Some(uid), &followed).await?; + let user = build_user(&state, &ctx, me, Some(uid)).await?; + + Ok(Json(DashboardOut { + user, + rated_count, + missing_count, + missing_flavors, + my_ratings, + })) +} diff --git a/backend-rs/src/search.rs b/backend-rs/src/search.rs new file mode 100644 index 0000000..49f37fa --- /dev/null +++ b/backend-rs/src/search.rs @@ -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) { + 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 { + 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 + } +} diff --git a/backend-rs/src/service.rs b/backend-rs/src/service.rs new file mode 100644 index 0000000..123cc59 --- /dev/null +++ b/backend-rs/src/service.rs @@ -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::>().join(",") +} + +/// Optional avatar → absolute media URL (empty path treated as absent). +fn avatar_url(ctx: &RequestCtx, media: &str, avatar: &Option) -> Option { + avatar + .as_deref() + .filter(|s| !s.is_empty()) + .map(|a| image_field_url(ctx, media, a)) +} + +/// Django JSONField (list) stored as TEXT → Vec, tolerating null/non-array. +fn json_string_list(v: &Option) -> Vec { + 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 { + (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, +) -> ApiResult> { + if replies.is_empty() { + return Ok(Vec::new()); + } + let mut user_ids: Vec = 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 = 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, +) -> ApiResult> { + let media = &state.config.media_url; + if ratings.is_empty() { + return Ok(Vec::new()); + } + + let flavor_ids: Vec = ratings.iter().map(|r| r.flavor_id).collect(); + let rating_ids: Vec = 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 = flavors.into_iter().map(|f| (f.id, f)).collect(); + + let category_ids: Vec = 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 = + 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 = 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 = users.into_iter().map(|u| (u.id, u)).collect(); + + let mut replies_by_rating: HashMap> = 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, + viewer_id: Option, + followed_ids: &[i32], +) -> ApiResult> { + let media = &state.config.media_url; + if flavors.is_empty() { + return Ok(Vec::new()); + } + + let flavor_ids: Vec = flavors.iter().map(|f| f.id).collect(); + let category_ids: Vec = 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 = 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 = 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 = 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 = + users.into_iter().map(|u| (u.id, u)).collect(); + + // Replies grouped by rating id + let mut replies_by_rating: HashMap> = 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 = + flavors.iter().map(|f| (f.id, f)).collect(); + + // Ratings grouped by flavor id + let mut ratings_by_flavor: HashMap> = 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 = followed_ids.iter().copied().collect(); + let mut avg_by_flavor: HashMap> = HashMap::new(); + let mut followed_avg_by_flavor: HashMap> = HashMap::new(); + let mut dist_by_flavor: HashMap> = 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, HashMap, HashMap)> { + let mut following: HashMap = HashMap::new(); + let mut followers: HashMap = HashMap::new(); + let mut unread: HashMap = 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, ids: &[i32]) -> ApiResult> { + 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, + viewer: Option, +) -> ApiResult> { + let media = &state.config.media_url; + if users.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec = users.iter().map(|u| u.id).collect(); + let (following, followers, unread) = social_counts(state, &ids).await?; + let followed: std::collections::HashSet = + following_of(state, viewer, &ids).await?.into_iter().collect(); + + let banner_ids: Vec = users.iter().filter_map(|u| u.selected_banner_id).collect(); + let banner_slugs: HashMap = 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, +) -> ApiResult { + 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, +) -> ApiResult> { + let media = &state.config.media_url; + if comments.is_empty() { + return Ok(Vec::new()); + } + let mut author_ids: Vec = comments.iter().map(|c| c.author_id).collect(); + author_ids.sort_unstable(); + author_ids.dedup(); + let authors: HashMap = 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, +) -> ApiResult> { + let media = &state.config.media_url; + if notes.is_empty() { + return Ok(Vec::new()); + } + + let reply_ids: Vec = notes.iter().filter_map(|n| n.reply_id).collect(); + let replies: HashMap = 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 = 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 = 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 = ratings.values().map(|r| r.flavor_id).collect(); + let flavors: HashMap = 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 = notes.iter().filter_map(|n| n.profile_comment_id).collect(); + let pcs: HashMap = 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 = 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 = 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, +) -> ApiResult> { + let media = &state.config.media_url; + if tickets.is_empty() { + return Ok(Vec::new()); + } + let ticket_ids: Vec = 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 = 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 = 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> = 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()) +} diff --git a/backend-rs/src/state.rs b/backend-rs/src/state.rs new file mode 100644 index 0000000..82cfae4 --- /dev/null +++ b/backend-rs/src/state.rs @@ -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>>, + /// Rate limiting + confirmation-code TTL/attempt state (replaces + /// `django_ratelimit`). Shared across handlers via `Arc`. + pub security: Arc, +} diff --git a/backend-rs/src/throttle.rs b/backend-rs/src/throttle.rs new file mode 100644 index 0000000..8c9afd5 --- /dev/null +++ b/backend-rs/src/throttle.rs @@ -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>>, + /// confirmation-code key -> mint time. + codes: Mutex>, +} + +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")); + } +} diff --git a/backend-rs/src/tokens.rs b/backend-rs/src/tokens.rs new file mode 100644 index 0000000..c304808 --- /dev/null +++ b/backend-rs/src/tokens.rs @@ -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 { + let mut v = Validation::new(Algorithm::HS256); + v.validate_aud = false; + decode::(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 { + 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 { + decode_claims(token, secret) +} + +fn epoch_to_naive(secs: i64) -> NaiveDateTime { + DateTime::::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()); + } +} diff --git a/backend-rs/src/web.rs b/backend-rs/src/web.rs new file mode 100644 index 0000000..d5933b0 --- /dev/null +++ b/backend-rs/src/web.rs @@ -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 FromRequestParts for RequestCtx { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + 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::() + .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, + }) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index cdf3c92..bb19c57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -133,6 +133,54 @@ services: redis: condition: service_healthy + # --- Rust backend (strangler) -------------------------------------------- + # Runs beside Django, sharing the SAME db.sqlite3, media/, banners/ and + # legacy/ via bind mounts and the SAME SECRET_KEY (so SimpleJWT tokens verify + # across both). nginx routes endpoint groups here one at a time; see + # backend-rs/README.md for the cutover order. ENABLE_SCHEDULER stays false + # until Django's beat container is stopped — only one scheduler may own + # api_job, or jobs run twice. + backend-rs: + build: + context: ./backend-rs + container_name: holy-rust + user: "app" + restart: unless-stopped + networks: + - holy-net + ports: + - 8001 + env_file: + - .env + environment: + - DOMAIN=${DOMAIN:-localhost} + - BIND_ADDR=0.0.0.0:8001 + - SECRET_KEY=${SECRET_KEY} + - DATABASE_URL=sqlite:///srv/backend/db.sqlite3?mode=rw + - MEDIA_ROOT=/srv/backend/media + - MEDIA_URL=media/ + - BANNERS_DIR=/srv/backend/banners + - LEGACY_DIR=/srv/legacy + - BACKUP_DIR=/srv/backend/backups + - FRONTEND_URL=${FRONTEND_URL:-http://${DOMAIN:-localhost}} + - JWT_AUTH_COOKIE_SECURE=${JWT_AUTH_COOKIE_SECURE:-true} + # Keep false until Django beat is stopped (Phase 3 of cutover). + - ENABLE_SCHEDULER=${RUST_ENABLE_SCHEDULER:-false} + - RUST_LOG=${RUST_LOG:-info,holy_backend_rs=info,sea_orm=warn} + volumes: + - ./backend:/srv/backend + - ./legacy:/srv/legacy + depends_on: + # Wait for Django to apply migrations / create the schema before reading it. + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8001/health/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + frontend: build: context: ./frontend @@ -151,9 +199,16 @@ services: - holy-net env_file: - .env + volumes: + # Serve /media/ straight from the shared mount (read-only), decoupling it + # from either backend so Django can be retired without losing media. + - ./backend/media:/usr/share/nginx/media:ro depends_on: backend: condition: service_healthy + # nginx resolves the rust upstream at startup, so the container must exist. + backend-rs: + condition: service_healthy networks: holy-net: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fff42a3..39419e6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -31,43 +31,10 @@ COPY --from=build-stage /app/dist /usr/share/nginx/html COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -# Custom Nginx config to handle React Router (SPA) and proxy API requests -RUN printf 'server {\n\ - listen 80;\n\ - location / {\n\ - root /usr/share/nginx/html;\n\ - index index.html index.htm;\n\ - try_files $uri $uri/ /index.html;\n\ - }\n\ - location /api/ {\n\ - proxy_pass http://holy-backend:8000/api/;\n\ - proxy_set_header Host $host;\n\ - proxy_set_header X-Real-IP $remote_addr;\n\ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\ - proxy_set_header X-Forwarded-Proto $scheme;\n\ - }\n\ - location /admin/ {\n\ - proxy_pass http://holy-backend:8000/admin/;\n\ - proxy_set_header Host $host;\n\ - proxy_set_header X-Real-IP $remote_addr;\n\ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\ - proxy_set_header X-Forwarded-Proto $scheme;\n\ - }\n\ - location /static/ {\n\ - proxy_pass http://holy-backend:8000/static/;\n\ - proxy_set_header Host $host;\n\ - proxy_set_header X-Real-IP $remote_addr;\n\ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\ - proxy_set_header X-Forwarded-Proto $scheme;\n\ - }\n\ - location /media/ {\n\ - proxy_pass http://holy-backend:8000/media/;\n\ - proxy_set_header Host $host;\n\ - proxy_set_header X-Real-IP $remote_addr;\n\ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\ - proxy_set_header X-Forwarded-Proto $scheme;\n\ - }\n\ -}' > /etc/nginx/conf.d/default.conf +# Nginx config: SPA + strangler API routing (Django/Rust per-group). See +# nginx.conf for the cutover layout. proxy_params.conf holds shared headers. +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY proxy_params.conf /etc/nginx/proxy_params.conf EXPOSE 80 ENTRYPOINT ["/entrypoint.sh"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1d00716 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/proxy_params.conf b/frontend/proxy_params.conf new file mode 100644 index 0000000..4059387 --- /dev/null +++ b/frontend/proxy_params.conf @@ -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; diff --git a/scripts/cutover.sh b/scripts/cutover.sh new file mode 100755 index 0000000..3ef88af --- /dev/null +++ b/scripts/cutover.sh @@ -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 { proxy_pass http://; ... } + # 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 <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