mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
Merge pull request 'Backend rust' (#3) from backend-rust into main
Reviewed-on: nvrl/Holy-Flavor-Rater#3
This commit is contained in:
commit
3b81adee85
76 files changed
+13646
-329
No files matched your search
+11
-29
@@ -2,40 +2,22 @@
|
||||
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
|
||||
DOMAIN="localhost"
|
||||
|
||||
# Set to a long, random string in production (e.g. 50+ chars). REQUIRED in prod.
|
||||
SECRET_KEY="django-insecure-replace-this-in-production"
|
||||
# Signs the SimpleJWT-compatible tokens. Set to a long, random string in
|
||||
# production. Keep it CONSTANT across deploys/restarts or all tokens invalidate.
|
||||
SECRET_KEY="replace-this-in-production"
|
||||
|
||||
# Which settings module to load. Use holy_backend.settings.dev locally,
|
||||
# holy_backend.settings.prod in production (Docker compose sets this).
|
||||
DJANGO_SETTINGS_MODULE=holy_backend.settings.dev
|
||||
# --- Background jobs (Rust in-process scheduler) ---
|
||||
# Lets the backend own api_job scheduling. EXACTLY ONE running instance may set
|
||||
# this true, or scheduled jobs fire twice against the shared DB.
|
||||
RUST_ENABLE_SCHEDULER=true
|
||||
|
||||
# Set to "false" in production for security and performance
|
||||
DEBUG=true
|
||||
# Set `Secure` on auth cookies (true in production).
|
||||
JWT_AUTH_COOKIE_SECURE=true
|
||||
|
||||
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
||||
ADMIN_URL="admin"
|
||||
|
||||
# --- Celery / Redis ---
|
||||
# Broker and result backend for background jobs. Docker compose defaults to the bundled redis service.
|
||||
# DB 0 is used by Celery; DB 1 is used by Django cache (see DJANGO_CACHE_URL below).
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
# Run tasks synchronously (useful for local dev or CI). Unset in production.
|
||||
# CELERY_TASK_ALWAYS_EAGER=true
|
||||
|
||||
# Django cache backend URL. Shares the redis instance on a different DB number.
|
||||
DJANGO_CACHE_URL=redis://redis:6379/1
|
||||
|
||||
# Flower (Celery monitoring UI) — exposed on :5555. Format: "user:password".
|
||||
# CHANGE BEFORE DEPLOY.
|
||||
FLOWER_BASIC_AUTH=admin:change-me
|
||||
|
||||
# Worker/beat containers set this in docker-compose.yml; do not set globally.
|
||||
# SKIP_INIT=true
|
||||
# tracing/log filter for the Rust backend.
|
||||
# RUST_LOG=info,holy_backend_rs=info,sea_orm=warn
|
||||
|
||||
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
|
||||
# ALLOWED_HOSTS=example.com,another-domain.com
|
||||
# CSRF_TRUSTED_ORIGINS=https://example.com
|
||||
# CORS_ALLOWED_ORIGINS=https://example.com
|
||||
# FRONTEND_URL=https://example.com
|
||||
# VITE_API_URL=https://example.com/api/
|
||||
|
||||
+22
-26
@@ -51,39 +51,35 @@ jobs:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
SECRET_KEY: "ci-test-key"
|
||||
DEBUG: "false"
|
||||
ALLOWED_HOSTS: "localhost"
|
||||
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
python-version: '3.14.3'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: backend/requirements-dev.txt
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pip install -r requirements-dev.txt
|
||||
working-directory: backend
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend-rs/target
|
||||
key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }}
|
||||
|
||||
- name: Ruff Lint
|
||||
run: ruff check .
|
||||
working-directory: backend
|
||||
- name: Format Check
|
||||
run: cargo fmt --check
|
||||
working-directory: backend-rs
|
||||
|
||||
- name: Ruff Format Check
|
||||
run: ruff format --check .
|
||||
working-directory: backend
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets
|
||||
working-directory: backend-rs
|
||||
|
||||
- name: Django System Check
|
||||
run: python manage.py check
|
||||
working-directory: backend
|
||||
|
||||
- name: Check Migrations
|
||||
run: python manage.py makemigrations --check --dry-run
|
||||
working-directory: backend
|
||||
- name: Build
|
||||
run: cargo build --locked
|
||||
working-directory: backend-rs
|
||||
|
||||
- name: Run Tests
|
||||
run: pytest
|
||||
working-directory: backend
|
||||
run: cargo test --locked
|
||||
working-directory: backend-rs
|
||||
@@ -9,6 +9,8 @@ ENV/
|
||||
# Databases
|
||||
*.sqlite3
|
||||
db.sqlite3
|
||||
backend/db.sqlite3-shm
|
||||
backend/db.sqlite3-wal
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
|
||||
@@ -18,7 +18,7 @@ A high-density, community-driven platform for "Holy Energy" enthusiasts. Browse
|
||||
## 🛠️ Setup & Installation
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
The easiest way to get the full stack (Backend + Frontend + DB) running behind a production-ready proxy.
|
||||
The easiest way to get the full stack (Rust backend + Frontend) running behind a production-ready proxy.
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
@@ -28,62 +28,48 @@ The easiest way to get the full stack (Backend + Frontend + DB) running behind a
|
||||
2. **Environment Configuration:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your SMTP settings and Domain
|
||||
# Edit .env: set SECRET_KEY, DOMAIN, and SMTP settings
|
||||
```
|
||||
3. **Start Services:**
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
*Note: On first startup, the backend runs `migrate` + `collectstatic`, then seeds the flavor catalog, legacy data, and banner configurations **once** (gated by a `/app/.seeded` marker so subsequent restarts don't repeat the work). Re-run individual seeds later from the admin Jobs tab.*
|
||||
The stack brings up two containers: `backend-rs` (`holy-rust:8001`, the Rust API) and `frontend` (Nginx — serves the SPA, proxies `/api` to the backend, serves `/media` from the shared mount). `docker-compose.override.yml` adds the Traefik production overlay.
|
||||
|
||||
The stack brings up six containers: `redis` (broker + cache), `backend` (Gunicorn), `worker` (Celery worker), `beat` (Celery beat w/ `django_celery_beat.DatabaseScheduler`), `flower` (Celery monitoring UI on `:5555`), and `frontend` (Nginx).
|
||||
The Rust backend reads the existing `backend/db.sqlite3` directly (no migration step). Seeding (flavor catalog, legacy data, banners) runs as background jobs — trigger them from the admin Jobs tab or let the scheduler run them.
|
||||
|
||||
### Option 2: Local Manual Setup (Development)
|
||||
|
||||
#### Backend
|
||||
1. **Navigate and Install:**
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
2. **Initialize DB:**
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py sync_flavors
|
||||
python manage.py seed_legacy_flavors
|
||||
python manage.py seed_banners
|
||||
```
|
||||
3. **Run Server:**
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
*By default `holy_backend.settings.dev` is active, which sets `CELERY_TASK_ALWAYS_EAGER=true` — tasks run inline with no broker required. To exercise the full async path locally, start a Redis server and run `celery -A holy_backend worker -l info` + `celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler` in separate terminals.*
|
||||
```bash
|
||||
cd backend-rs
|
||||
cp .env.example .env # set SECRET_KEY; point DATABASE_URL at ../backend/db.sqlite3
|
||||
cargo run # listens on 0.0.0.0:8001
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
1. **Navigate and Install:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
2. **Run Dev Server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> The Rust backend uses the existing SQLite database under `backend/db.sqlite3`. The `backend/` directory is retained as the data store (db, `media/`, `banners/`) and historical reference for the retired Django implementation — it is no longer run.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The Holy Flavor Rater uses a modern **decoupled architecture**:
|
||||
The Holy Flavor Rater uses a **decoupled architecture**:
|
||||
|
||||
### Backend (Django REST Framework)
|
||||
- **Models:** Custom User model, Flavors (with category constraints), Ratings (with mention parsing), and Dynamic Banners.
|
||||
- **Async jobs:** Celery workers backed by Redis handle Shopify syncs, duplicate cleanup, legacy seeding, banner seeding, database backups, and transactional email. Scheduling is owned by `django_celery_beat` (`PeriodicTask` + `IntervalSchedule`) and editable from the admin UI Jobs tab.
|
||||
- **Caching & rate-limiting:** Django cache uses Redis (`RedisCache` on DB 1). `django_ratelimit` consumes the same cache.
|
||||
- **Security:** JWT authentication (SimpleJWT) with refresh-token rotation, CSRF protection, and email-verified account activation.
|
||||
- **Monitoring:** `/health/` endpoint for the backend; Flower UI on `:5555` for queue + worker state (basic-auth gated).
|
||||
### Backend (Rust — Axum + SeaORM)
|
||||
- **Web/DB:** Axum 0.8 over SeaORM (SQLite), utoipa for OpenAPI + Swagger UI. Lives in `backend-rs/`.
|
||||
- **Models:** Custom User, Flavors (with `UniqueConstraint(name, category)`), Ratings (with `@mention` parsing), Tickets, Notifications, and Dynamic Banners.
|
||||
- **Background jobs:** An in-process tokio scheduler runs Shopify syncs, duplicate cleanup, legacy seeding, banner seeding, and database backups (`src/jobs/`). Status is logged to the `api_job` table; intervals are editable from the admin UI Jobs tab. Replaces the old Celery + beat + Redis stack.
|
||||
- **Security:** SimpleJWT-compatible HS256 auth with refresh-token rotation + blacklist, email-verified activation, an in-process rate limiter, constant-time code comparison, and Django-parity password validators.
|
||||
- **Monitoring:** `GET /health/` (runs `SELECT 1`) for the docker healthcheck.
|
||||
|
||||
See **`backend-rs/README.md`** for the full endpoint matrix, parity notes, and security details.
|
||||
|
||||
### Frontend (React 19 + TypeScript)
|
||||
- **State Management:** React Hooks and local state for high performance.
|
||||
@@ -98,77 +84,62 @@ The Holy Flavor Rater uses a modern **decoupled architecture**:
|
||||
### Integrity Protection
|
||||
This project includes a **Pre-Push Git Hook** to ensure code quality. It automatically:
|
||||
1. Attempts a full Frontend build (`tsc` + `vite build`).
|
||||
2. Runs Backend system checks and validates missing migrations.
|
||||
2. Runs Rust backend checks (`cargo fmt --check` + `cargo check`).
|
||||
|
||||
**To install the hooks:**
|
||||
```bash
|
||||
bash scripts/install-hooks.sh
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
Each is also exposed as a Celery task (`api.<name>`) and triggerable from the admin Jobs tab.
|
||||
- `python manage.py sync_flavors`: Syncs the latest products from the official Holy Energy API.
|
||||
- `python manage.py cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
||||
- `python manage.py seed_legacy_flavors`: Loads retired flavors from `legacy/*.json`.
|
||||
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`.
|
||||
- `python manage.py backup_db` (`--full` for media too): Creates a consistent SQLite snapshot in `backend/backups/`.
|
||||
### Background Jobs
|
||||
Run by the scheduler on their interval, or triggered from the admin Jobs tab (`POST /api/admin-custom/{pk}/trigger_job/`):
|
||||
- `sync_flavors`: Syncs the latest products from the official Holy Energy (Shopify) catalog.
|
||||
- `cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
||||
- `seed_legacy`: Loads retired flavors from `legacy/*.json`.
|
||||
- `seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`.
|
||||
- `backup_db`: Creates a consistent SQLite + media snapshot in `backend/backups/`.
|
||||
|
||||
#### Categories & `recategorize_flavors`
|
||||
#### Categories
|
||||
`sync_flavors` derives a flavor's category from the Shopify `product_type` (e.g. `42 - Syrup Bottle` → **Syrup**), stripping the numeric prefix and form-factor word. Any new drink line Holy ships **auto-creates** its category on the next sync. Packs, shakers, merch, stickers and other non-drink types funnel to **Packs and other**.
|
||||
|
||||
`sync_flavors` derives a flavor's category from the Shopify `product_type` (e.g. `42 - Syrup Bottle` → **Syrup**), stripping the numeric prefix and the form-factor word. Any new drink line Holy ships **auto-creates** its category on the next sync — no code change needed. Packs, shakers, merch, stickers and other non-drink types funnel to **Packs and other**.
|
||||
|
||||
One catch: `sync_flavors` only assigns a category when it **creates** a row. Existing flavors keep their original category, so a categorization change won't retro-apply to rows already in the DB. Use the one-off command below to fix mis-shelved rows. It is run manually (not exposed as a Celery task / Jobs-tab entry) and is **dry-run by default** — nothing is written without `--apply`.
|
||||
|
||||
```bash
|
||||
python manage.py recategorize_flavors # dry-run: report what would move
|
||||
python manage.py recategorize_flavors --apply # persist the moves
|
||||
python manage.py recategorize_flavors --apply --merge-collisions # also fold name-clashes
|
||||
```
|
||||
|
||||
It re-fetches the catalog, matches rows by `external_id`, and re-runs the exact `sync_flavors` categorization logic. Rows not in the catalog (discontinued) are never touched. When a move would collide with an existing flavor of the same name in the target category (`UniqueConstraint(name, category)`), it is skipped and reported unless `--merge-collisions` is passed, which folds the rows via `merge_flavors` (preserving ratings and replies).
|
||||
`sync_flavors` only assigns a category when it **creates** a row — existing flavors keep their original category. To re-shelve already-stored rows, the retired Django app's `recategorize_flavors` management command remains available under `backend/` for occasional manual use (it is not part of the Rust job registry).
|
||||
|
||||
---
|
||||
|
||||
## 💾 Backups & Restoration
|
||||
|
||||
### Create a Backup
|
||||
To create a full snapshot of your database and user-uploaded media:
|
||||
```bash
|
||||
bash scripts/backup.sh
|
||||
```
|
||||
This will create a `.tar.gz` file in `backend/backups/`.
|
||||
Creates a `.tar.gz` (DB + media) in `backend/backups/`. WAL-safe: uses `sqlite3 .backup` when available, otherwise copies the DB plus its `-wal`/`-shm` sidecars. The backend also runs `backup_db` on its own schedule.
|
||||
|
||||
**Automating with Cron:**
|
||||
Add this to your `crontab -e` to backup every night at 2:00 AM:
|
||||
**Automating with Cron** — nightly at 2:00 AM:
|
||||
```bash
|
||||
0 2 * * * /path/to/Holy-Flavor-Rater/scripts/backup.sh
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
1. Extract the archive: `tar -xzf backup_filename.tar.gz`
|
||||
2. Stop the containers: `docker-compose down`
|
||||
2. Stop the containers: `docker compose down`
|
||||
3. Replace the current files:
|
||||
```bash
|
||||
cp extracted_folder/db.sqlite3 backend/db.sqlite3
|
||||
cp -r extracted_folder/media/* backend/media/
|
||||
```
|
||||
4. Restart: `docker-compose up -d`
|
||||
4. Restart: `docker compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Production Configuration
|
||||
|
||||
Ensure your `.env` contains:
|
||||
- `DEBUG=false`
|
||||
- `DJANGO_SETTINGS_MODULE=holy_backend.settings.prod`
|
||||
- `SECRET_KEY`: A long random string.
|
||||
- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`).
|
||||
- `FRONTEND_URL`: For one-click email verification links.
|
||||
- `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND`: Default `redis://redis:6379/0` works inside the compose network.
|
||||
- `DJANGO_CACHE_URL`: Default `redis://redis:6379/1` (DB 1 to avoid colliding with celery).
|
||||
- `FLOWER_BASIC_AUTH`: `user:password` for the Flower UI — **change from the default before exposing**.
|
||||
|
||||
Restrict Flower's port `5555` (or reverse-proxy it behind basic-auth + TLS) since it exposes task payloads and worker internals.
|
||||
Set these in `.env` (see also `backend-rs/.env.example`):
|
||||
- `SECRET_KEY`: long random string that signs JWTs — **keep it constant** or all tokens invalidate.
|
||||
- `DOMAIN`: your domain (e.g. `holy.narl.io`); derives `FRONTEND_URL` / CORS defaults.
|
||||
- `FRONTEND_URL`: base URL for one-click email verification links.
|
||||
- `JWT_AUTH_COOKIE_SECURE=true`: set `Secure` on auth cookies.
|
||||
- `RUST_ENABLE_SCHEDULER=true`: lets the backend own `api_job` scheduling. **Exactly one** running instance may have this enabled.
|
||||
- `EMAIL_*`: SMTP settings for transactional email (unset `EMAIL_HOST` logs to console).
|
||||
|
||||
## 📄 License
|
||||
MIT
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,45 @@
|
||||
# Path to the sqlite database. Relative paths resolve from the process CWD;
|
||||
# absolute is safest. Use mode=rw in prod so a wrong path fails loudly.
|
||||
DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc
|
||||
|
||||
# Signs the SimpleJWT-compatible tokens. Keep this CONSTANT across deploys and
|
||||
# restarts — changing it invalidates every issued access/refresh token. (Use the
|
||||
# value the retired Django app used so tokens already in the wild stay valid.)
|
||||
SECRET_KEY=change-me
|
||||
|
||||
# Where this service binds.
|
||||
BIND_ADDR=0.0.0.0:8001
|
||||
|
||||
# Media URL prefix (matches the historical Django MEDIA_URL = "media/").
|
||||
MEDIA_URL=media/
|
||||
|
||||
# Filesystem root for uploaded media (avatars).
|
||||
MEDIA_ROOT=../backend/media
|
||||
|
||||
# Base URL of the frontend, used to build email links (verify-email, reset).
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# --- Email (lettre) -------------------------------------------------------
|
||||
# Leave EMAIL_HOST unset to log emails to the console (dev). Set it to enable
|
||||
# real SMTP.
|
||||
#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.
|
||||
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).
|
||||
JWT_AUTH_COOKIE_SECURE=false
|
||||
|
||||
# In-process job scheduler (replaces Celery beat). Exactly one running instance
|
||||
# may set this true — it owns scheduling of the api_job table. Additional
|
||||
# instances must leave it false or jobs fire twice.
|
||||
ENABLE_SCHEDULER=false
|
||||
|
||||
# tracing filter
|
||||
RUST_LOG=info,holy_backend_rs=debug,sea_orm=warn
|
||||
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
.env
|
||||
|
||||
# Not vendored: the embedded common-passwords list is fetched at build time
|
||||
# (see Dockerfile). A local copy lives here only for native `cargo build` dev.
|
||||
/assets
|
||||
Generated
+4123
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,93 @@
|
||||
[package]
|
||||
name = "holy_backend_rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Rust port of the Holy Flavor Rater backend (strangler migration from Django)."
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = [
|
||||
"cors",
|
||||
"trace",
|
||||
"fs",
|
||||
"catch-panic",
|
||||
"set-header",
|
||||
"timeout",
|
||||
] }
|
||||
|
||||
sea-orm = { version = "1.1", features = [
|
||||
"sqlx-sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
"macros",
|
||||
"with-chrono",
|
||||
"with-json",
|
||||
] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
jsonwebtoken = "9"
|
||||
|
||||
# Django-compatible password verification (pbkdf2_sha256$...) + token ids.
|
||||
pbkdf2 = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
subtle = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
||||
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
dotenvy = "0.15"
|
||||
|
||||
tokio-cron-scheduler = "0.13"
|
||||
|
||||
# Dynamic dispatch for the Job trait (registry of `Arc<dyn Job>`).
|
||||
async-trait = "0.1"
|
||||
|
||||
# sync_flavors: fetch the Shopify catalog + download product images.
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"json",
|
||||
] }
|
||||
# Bounded-concurrency image downloads in sync_flavors.
|
||||
futures = "0.3"
|
||||
|
||||
# backup_db: gzip tarball of the sqlite snapshot + media tree.
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
|
||||
# Django-accurate slugify (NFKD normalize + ASCII fold) for category/image slugs.
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
# Image filenames are keyed by sha1 of the URL path (parity with Django's
|
||||
# hashlib.sha1) so downloads dedupe against the shared media directory.
|
||||
sha1 = "0.10"
|
||||
|
||||
# SMTP email (parity with Django's send_mail / InsecureEmailBackend).
|
||||
lettre = { version = "0.11", default-features = false, features = [
|
||||
"tokio1-rustls-tls",
|
||||
"smtp-transport",
|
||||
"builder",
|
||||
] }
|
||||
|
||||
# Avatar resize (parity with the PIL thumbnail-to-256-JPEG step in User.save).
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"webp",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,62 @@
|
||||
# --- 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 curl \
|
||||
&& 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
|
||||
|
||||
# Fetch the common-passwords list at build time instead of vendoring it in git.
|
||||
# It's Django 5.2's `common-passwords.txt.gz`, embedded via include_bytes! in
|
||||
# src/password_policy.rs. The checksum is verified, so an upstream change (or a
|
||||
# moved branch ref) fails the build loudly rather than silently shipping a
|
||||
# different list. Bump the tag + DJANGO_PASSWORDS_SHA256 together to update it.
|
||||
ARG DJANGO_PASSWORDS_TAG=5.2
|
||||
ARG DJANGO_PASSWORDS_SHA256=3c1baed62596de36860824eb3f436d5932d37ca8b06e59df78f5a44ec175afe4
|
||||
RUN mkdir -p assets \
|
||||
&& curl -fsSL -o assets/common-passwords.txt.gz \
|
||||
"https://raw.githubusercontent.com/django/django/${DJANGO_PASSWORDS_TAG}/django/contrib/auth/common-passwords.txt.gz" \
|
||||
&& echo "${DJANGO_PASSWORDS_SHA256} assets/common-passwords.txt.gz" | sha256sum -c -
|
||||
|
||||
# Real sources.
|
||||
COPY src ./src
|
||||
# Touch so cargo sees main.rs as newer than the stub build above.
|
||||
RUN touch src/main.rs && cargo build --release \
|
||||
&& strip target/release/holy_backend_rs
|
||||
|
||||
# --- Runtime stage ----------------------------------------------------------
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# curl for the container healthcheck; ca-certificates for outbound HTTPS
|
||||
# (Shopify sync, SMTP over TLS).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Match the Django image's app user (UID/GID 1000) so the shared bind-mounted
|
||||
# db.sqlite3 + media/ stay writable by both backends.
|
||||
ARG APP_UID=1000
|
||||
ARG APP_GID=1000
|
||||
RUN groupadd -g ${APP_GID} app \
|
||||
&& useradd -u ${APP_UID} -g app -m -s /bin/bash app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /build/target/release/holy_backend_rs /usr/local/bin/holy_backend_rs
|
||||
|
||||
USER app
|
||||
EXPOSE 8001
|
||||
|
||||
# Paths point at the shared bind mounts wired in docker-compose. SECRET_KEY,
|
||||
# DATABASE_URL, ENABLE_SCHEDULER, EMAIL_* etc. come from the environment.
|
||||
ENTRYPOINT ["/usr/local/bin/holy_backend_rs"]
|
||||
@@ -0,0 +1,322 @@
|
||||
# holy_backend_rs — Rust backend (strangler migration)
|
||||
|
||||
Incremental Rust rewrite of the Django backend. Both backends run side by side
|
||||
and **share the same SQLite file**; an nginx proxy routes already-ported
|
||||
endpoint groups to Rust and everything else to Django. Endpoints move group by
|
||||
group until Django can be retired.
|
||||
|
||||
- **Web**: Axum 0.8 + utoipa (OpenAPI) + Swagger UI
|
||||
- **DB**: SeaORM over the existing SQLite (`backend/db.sqlite3`), schema unchanged
|
||||
- **Auth**: SimpleJWT-compatible — verifies *and issues* HS256 tokens signed
|
||||
with the same `SECRET_KEY` as Django (`Authorization: Bearer` or `access_token`
|
||||
cookie). Login/refresh/verify/logout are ported; tokens are interchangeable
|
||||
with Django (verified both directions).
|
||||
- **Jobs**: tokio-cron-scheduler in-process (not yet wired; replaces Celery+beat)
|
||||
|
||||
## Ported so far (read slice)
|
||||
|
||||
| Method + path | Notes |
|
||||
|---|---|
|
||||
| `GET /health/` | liveness |
|
||||
| `GET /api/categories/` `…/{id}/` | DRF paginated |
|
||||
| `GET /api/flavors/` `…/{id}/` | paginated, `?category=`, `?category__slug=`, `?search=`, ordered by -average_rating |
|
||||
| `GET /api/flavors/top/` `?category=` | top 10 rated |
|
||||
| `GET /api/flavors/newest/` | 10 newest |
|
||||
| `GET /api/flavors/search/?q=` | ranked hits (≤15) |
|
||||
| `GET /api/flavors/followed_top/` | auth required |
|
||||
| `GET /api/banners/` `…/{id}/` `…/active/` | enabled-only for non-superusers |
|
||||
| `GET /api/schema/` + `/api/schema/swagger-ui` | OpenAPI |
|
||||
|
||||
### Auth slice (issuance)
|
||||
|
||||
| Method + path | Notes |
|
||||
|---|---|
|
||||
| `POST /api/auth/token/` | login; validates Django PBKDF2 password, sets httpOnly cookies, body `{"status":"ok"}` |
|
||||
| `POST /api/auth/token/refresh/` | refresh from body or `refresh_token` cookie; rotates + blacklists old (BLACKLIST_AFTER_ROTATION) |
|
||||
| `POST /api/auth/token/verify/` | validate any token; rejects blacklisted jti |
|
||||
| `POST /api/auth/logout/` | blacklists the refresh cookie, clears both cookies |
|
||||
| `POST /api/token/` | legacy alias of login |
|
||||
|
||||
### Ratings + replies slice (writes)
|
||||
|
||||
| Method + path | Notes |
|
||||
|---|---|
|
||||
| `GET /api/ratings/` | paginated (size 50), `-created_at`, nested replies (read-only OK anon) |
|
||||
| `POST /api/ratings/` | auth; one rating per (user, flavor); parses `@mentions` in comment |
|
||||
| `GET /api/ratings/{id}/` | retrieve |
|
||||
| `PUT/PATCH /api/ratings/{id}/` | owner or superuser; no mention parsing on edit (matches Django) |
|
||||
| `DELETE /api/ratings/{id}/` | owner or superuser; cascades replies + notifications |
|
||||
| `GET /api/ratings/feed/` | auth; ratings by followed users, FeedPagination (size 10) |
|
||||
| `GET /api/ratings/recent/` | AllowAny; 10 newest with a non-empty comment (plain array) |
|
||||
| `POST /api/ratings/{id}/reply/` | auth; notifies the rating owner, parses `@mentions` |
|
||||
| `GET /api/replies/` `…/{id}/` | auth |
|
||||
| `PUT/PATCH /api/replies/{id}/` | owner or superuser; re-parses `@mentions` |
|
||||
| `DELETE /api/replies/{id}/` | owner or superuser; cascades notifications |
|
||||
|
||||
Mirrors `RatingViewSet`/`ReplyViewSet` + `services/mentions.py`. Validation
|
||||
errors reproduce DRF's field-keyed / top-level-list bodies. Django's
|
||||
`on_delete=CASCADE` is emulated in the handlers (collect + delete dependent
|
||||
notifications/replies in a transaction) because SQLite enforces the FKs but has
|
||||
no `ON DELETE CASCADE`. Datetimes are written truncated to microseconds so the
|
||||
on-disk format matches Django's; verified Django reads Rust-written rows back.
|
||||
|
||||
### Users / social slice
|
||||
|
||||
| Method + path | Notes |
|
||||
|---|---|
|
||||
| `POST /api/users/signup/` | AllowAny; creates inactive user, sends verification email; `{"error":...}` on dup/missing |
|
||||
| `POST /api/users/resend_verification/` | AllowAny; new code + email |
|
||||
| `POST /api/users/verify_signup/` | AllowAny; activates on matching code |
|
||||
| `GET /api/users/me/` | auth; also logs client IP (XFF first hop, else peer addr) |
|
||||
| `GET /api/users/profile/{username}/` | AllowAny; public profile aggregate (ratings, comments, followers, following) |
|
||||
| `GET /api/users/following_list/` | auth; users the caller follows |
|
||||
| `GET /api/users/dashboard/` | auth; rated/missing flavors + my ratings, with followed-average |
|
||||
| `PATCH /api/users/update_preferences/` | auth; theme/language/drawer_anchor/selected_banner (validated) |
|
||||
| `POST /api/users/update_avatar/` | auth; multipart; resizes >256px or non-JPEG to 256² JPEG q90 (PIL parity) |
|
||||
| `POST /api/users/change_password/` | auth; checks old password |
|
||||
| `PATCH /api/users/update_profile/` | auth; username (unique) + email change (confirmation code mailed) |
|
||||
| `POST /api/users/confirm_email/` | auth; applies pending email on matching code |
|
||||
| `POST /api/users/request_password_reset/` | AllowAny; always returns generic message |
|
||||
| `POST /api/users/complete_password_reset/` | AllowAny; email+code → new password |
|
||||
| `POST /api/users/request_account_deletion/` | auth; mails deletion code |
|
||||
| `POST /api/users/confirm_account_deletion/` | auth; deletes account + all dependents (cascade) |
|
||||
| `GET /api/users/{id}/` | auth; retrieve |
|
||||
| `POST /api/users/{id}/follow/` `…/unfollow/` | auth; follow notification; self-follow rejected |
|
||||
| `POST /api/users/{id}/add_comment/` | auth; profile comment + notification |
|
||||
| `DELETE /api/users/{id}/delete_comment/{comment_id}/` | author or profile owner |
|
||||
| `GET /api/notifications/` | auth; own, `-created_at`, plain array |
|
||||
| `GET /api/notifications/{id}/` | auth; own only (404 otherwise) |
|
||||
| `POST /api/notifications/{id}/mark_read/` | auth |
|
||||
| `POST /api/notifications/mark_all_read/` | auth |
|
||||
|
||||
### Tickets / support slice
|
||||
|
||||
| Method + path | Notes |
|
||||
|---|---|
|
||||
| `GET /api/tickets/` | auth; superuser sees all, else own; `-updated_at`; paginated |
|
||||
| `POST /api/tickets/` | auth; status defaults `open`; notifies other admins |
|
||||
| `GET/PUT/PATCH /api/tickets/{id}/` | auth; own or superuser |
|
||||
| `DELETE /api/tickets/{id}/` | superuser only; cascades messages + notifications |
|
||||
| `POST /api/tickets/{id}/add_message/` | auth; notifies counterparty; `{"error":"Text is required"}` if empty |
|
||||
| `POST /api/tickets/{id}/update_status/` | staff only (`IsAdminUser`); validates status enum |
|
||||
|
||||
Ports `UserViewSet`/`NotificationViewSet`/`TicketViewSet` + serializers
|
||||
field-for-field. Generic `POST/PUT/PATCH/DELETE /api/users/` and `/api/users/{id}/`
|
||||
are intentionally **not** exposed (Django's `ModelViewSet` lets any authenticated
|
||||
user edit any user via `UserSerializer` — a footgun the frontend never uses; the
|
||||
dedicated actions cover every real flow). They 405.
|
||||
|
||||
**Email is implemented** (lettre): when `EMAIL_HOST` is set, mail is sent over
|
||||
SMTP — STARTTLS (`EMAIL_USE_TLS`) or implicit TLS (`EMAIL_USE_SSL`), with
|
||||
optional cert/hostname skip (`EMAIL_SKIP_CERT_VERIFICATION`, mirrors Django's
|
||||
`InsecureEmailBackend`). With no `EMAIL_HOST` it logs to the console (dev
|
||||
parity). Sends are fire-and-forget (`tokio::spawn`), matching Django's Celery
|
||||
`.delay()`.
|
||||
|
||||
Account/ticket/comment deletion emulates Django's app-level CASCADE in a
|
||||
transaction (SQLite has no `ON DELETE CASCADE`): account deletion removes the
|
||||
user's notifications, replies (theirs + on their ratings), ratings, ticket
|
||||
messages, tickets, profile comments (authored + owned), IP logs, follow rows,
|
||||
and blacklisted tokens before the user row.
|
||||
|
||||
Mirrors `api/views/auth.py`. Tokens are HS256 with the SimpleJWT claim set
|
||||
(`token_type, exp, iat, jti, user_id`), lifetimes 60 min / 14 days. The
|
||||
refresh blacklist shares Django's `token_blacklist_*` tables, so rotation works
|
||||
across both backends. Only the `pbkdf2_sha256` hasher is supported (the
|
||||
project's default); change `password.rs` if the hasher ever changes.
|
||||
|
||||
Responses mirror the DRF serializers field-for-field (names, order, pagination
|
||||
envelope) so the existing frontend is unchanged.
|
||||
|
||||
### Admin + jobs slice
|
||||
|
||||
Ports `AdminViewSet` (registered under the `admin-custom` prefix) and every
|
||||
management command / Celery task. All endpoints require `IsAdminUser` (`is_staff`):
|
||||
401 if anonymous, 403 if authenticated non-staff.
|
||||
|
||||
| Method + path | Notes |
|
||||
|---|---|
|
||||
| `GET /api/admin-custom/stats/` | counts + email/server config snapshot |
|
||||
| `GET /api/admin-custom/config/` | `SystemConfig` pk=1 singleton (auto-created) |
|
||||
| `PATCH /api/admin-custom/config/` | partial update; bumps `updated_at` |
|
||||
| `GET /api/admin-custom/jobs/` | all job rows; `name_display` via registry, `next_run` derived (`last_run + interval_hours`) |
|
||||
| `POST /api/admin-custom/{pk}/trigger_job/` | sets status `pending`, spawns the job; `{"status":"Job queued","task":"api.<name>"}` or 400 if unknown |
|
||||
| `PATCH /api/admin-custom/{pk}/update_job_schedule/` | sets `interval_hours` |
|
||||
| `POST /api/admin-custom/merge_flavors/` | `{keep_id, remove_id}`; 400 missing/diff-category, 404 not found, else merges |
|
||||
| `POST /api/admin-custom/send_test_email/` | synchronous send; 200 or 500 with SMTP error |
|
||||
| `GET /api/admin-custom/users/` | admin user list w/ IPs |
|
||||
| `GET/PATCH/DELETE /api/admin-custom/{pk}/user_detail/` | full detail (IPs + ratings); DELETE cascades via shared `delete_user_cascade` |
|
||||
|
||||
**Job framework** (`src/jobs/`) replaces the Django management-command + Celery
|
||||
layer with a single abstraction:
|
||||
|
||||
- `BackgroundJob` async trait (`name`, `display_name`, `run`) + a `registry()` of
|
||||
5 jobs: `sync_flavors`, `cleanup_duplicates`, `backup_db`, `seed_legacy`,
|
||||
`seed_banners`. `run_job()` is the lifecycle wrapper — it writes status /
|
||||
`last_run` / `last_output` / `error_message` into the `api_job` row (parity
|
||||
with Django's `_run_command_job`) and a per-process concurrency guard
|
||||
(`Arc<Mutex<HashSet>>`) prevents the same job running twice.
|
||||
- **Scheduler** (`scheduler.rs`): an in-process 60 s poller replacing Celery
|
||||
beat. Each tick, any registry job whose row has `interval_hours > 0` and is due
|
||||
(`last_run` null, or `now - last_run >= interval_hours`) and not already running
|
||||
is dispatched. Restart-safe via the persisted `last_run`. Chosen over a cron
|
||||
crate so intervals stay editable at runtime through `update_job_schedule`.
|
||||
|
||||
> **Operational note:** once Rust owns jobs in production, the Python Celery
|
||||
> `beat`/`worker` containers **must** be disabled, or jobs run twice (both
|
||||
> backends share `api_job` and the same SQLite file).
|
||||
|
||||
Django-data parity for the shared DB + media tree:
|
||||
|
||||
- `slugify` reimplemented (NFKD + ASCII-fold) to match Django exactly, so image
|
||||
paths dedupe across both backends.
|
||||
- Image filenames keyed by `sha1(url_path)[:10]`, matching Django's `hashlib.sha1`.
|
||||
- Microsecond (6-frac-digit) timestamps; app-level CASCADE emulation.
|
||||
- `merge_flavors` re-homes ratings (conflict resolution by comment length then
|
||||
`created_at`), re-points replies/notifications, inherits `external_id`, in a txn.
|
||||
- `sync_flavors` is a full port including the **Syrup** special-case (the single
|
||||
bundle product's variants expand into individual `Flavor` rows keyed by variant id).
|
||||
|
||||
Verified live against a copy of the production SQLite: scheduler dispatched the
|
||||
due `sync_flavors` job which fetched `weareholy.com` and reported
|
||||
`Created: 24, Updated: 137, Marked Unavailable: 22`; the full endpoint matrix
|
||||
(incl. 401/403 gates, merge, user_detail GET/PATCH/DELETE) passed.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set SECRET_KEY; point DATABASE_URL at the shared db
|
||||
cargo run # listens on BIND_ADDR (default 0.0.0.0:8001)
|
||||
```
|
||||
|
||||
`SECRET_KEY` signs the SimpleJWT-compatible tokens — **keep it constant** across
|
||||
deploys/restarts, or every issued access/refresh token is invalidated. (It must
|
||||
match the value the retired Django app used, so tokens already in the wild stay
|
||||
valid.)
|
||||
|
||||
## Deployment
|
||||
|
||||
The Rust backend is the production API; Django/Celery/Redis were retired after
|
||||
the strangler migration completed. `docker compose` (`docker-compose.yml` +
|
||||
`docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`)
|
||||
and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend,
|
||||
and serves `/media` from the shared `./backend/media` bind mount; see
|
||||
`frontend/nginx.conf`.
|
||||
|
||||
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
||||
`next`/`previous`) are rebuilt from them, matching DRF's `build_absolute_uri`.
|
||||
`X-Forwarded-For` lets `/api/users/me/` log the real client IP (it falls back to
|
||||
the peer address otherwise).
|
||||
|
||||
**Scheduler invariant:** exactly **one** process may own the `api_job` table.
|
||||
Run a single `backend-rs` instance with `ENABLE_SCHEDULER=true`
|
||||
(`RUST_ENABLE_SCHEDULER` in `.env`); any additional instances must leave it off.
|
||||
|
||||
The canonical data lives in host bind mounts — `backend/db.sqlite3` (WAL on) and
|
||||
`backend/media/` — and survives image rebuilds. Keep them.
|
||||
|
||||
## Caveats / parity TODO
|
||||
|
||||
- `created_at` etc. are emitted as ISO-8601 with 6 fractional digits + `Z`. DRF
|
||||
(Python `isoformat`) omits the fraction when microseconds are zero — verify
|
||||
against live responses if a client parses strictly.
|
||||
- The flavor `search` action approximates DRF's `SearchFilter` with word-level
|
||||
`LIKE` matching + the category-keyword extraction from `services/search.py`.
|
||||
- Average-rating ordering is validated by construction; the current DB has zero
|
||||
ratings, so confirm ordering once rating data exists.
|
||||
- `POST /api/replies/` is intentionally not exposed (Django marks `rating`
|
||||
read-only there; replies are only created via `/ratings/{id}/reply/`). It
|
||||
returns 405 rather than Django's 500.
|
||||
- Generic `POST/PUT/PATCH/DELETE /api/users/` and `/api/users/{id}/` return 405
|
||||
by design (see Users/social slice) — Django allowed them but the frontend
|
||||
never used them.
|
||||
- Admin endpoints + background jobs are ported (see the Admin + jobs slice).
|
||||
The job-trigger and admin write endpoints are **not** rate-limited (those are
|
||||
staff-only; `django_ratelimit` only covered the public flows below).
|
||||
|
||||
## Security & hardening
|
||||
|
||||
Rate limiting is ported in `throttle.rs` — an in-memory sliding-window limiter
|
||||
(per-process; **resets on restart and fails open**, so keep nginx-level limits
|
||||
in front for defence in depth, and run a single Rust instance). Limits mirror
|
||||
`django_ratelimit`, plus a few the Django app lacked:
|
||||
|
||||
| Flow | Limit | Key | Parity |
|
||||
|---|---|---|---|
|
||||
| signup | 5 / h | IP | matches Django |
|
||||
| password-reset request | 3 / h | IP | matches Django |
|
||||
| complete password reset | 5 / h | IP | matches Django |
|
||||
| account-deletion request | 3 / h | user | matches Django |
|
||||
| rating `create` | 10 / min | user | matches Django (`reply` was never throttled, still isn't) |
|
||||
| login | 10 / min | IP | **added** (Django had none) |
|
||||
| resend verification | 5 / h | IP | **added** |
|
||||
| confirmation-code attempts | 10 / h | account | **added** (brute-force lockout) |
|
||||
|
||||
Over-limit returns **429 + `Retry-After`** (DRF returned 403; the 429 body
|
||||
includes both `detail` and `error` so existing frontend error handling still
|
||||
reads a message).
|
||||
|
||||
Confirmation codes (signup / password reset / email change / account deletion)
|
||||
now **expire after 15 minutes** and are cleared on use. Expiry is tracked
|
||||
in-memory because the shared SQLite schema has no expiry column (adding one would
|
||||
need a coordinated Django migration); the check **fails open on an unknown code
|
||||
and only fails closed on a known-expired one**, so a process restart can't lock
|
||||
anyone out — it only forgoes expiry enforcement for codes minted before restart.
|
||||
|
||||
Login runs a dummy PBKDF2 verify on the user-not-found branch so response time
|
||||
can't be used to enumerate usernames. The signup/resend SMTP-failure path logs
|
||||
the detail server-side and returns a generic message (no info disclosure to
|
||||
anonymous callers).
|
||||
|
||||
Transport/process hardening (`main.rs`): static security headers
|
||||
(`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`,
|
||||
each set only if absent so nginx can override / own HSTS), an 8 MB request-body
|
||||
cap, a 30 s whole-request timeout, a panic-catch layer (handler panic → 500
|
||||
instead of a dropped connection), and graceful shutdown on SIGTERM/Ctrl+C so
|
||||
in-flight requests drain. `/health/` now runs `SELECT 1` so the Docker
|
||||
healthcheck fails when SQLite is unreachable.
|
||||
|
||||
SQLite runs in **WAL** journal mode (enabled once at startup, persistent in the
|
||||
file header) so readers don't block behind the writer; `busy_timeout` is sqlx's
|
||||
default 5 s, matching Django's SQLite timeout. The default `DATABASE_URL` uses `mode=rw` (not
|
||||
`rwc`) so a wrong path fails loudly instead of silently creating an empty DB.
|
||||
|
||||
Confirmation codes are compared in **constant time** (`subtle::ConstantTimeEq`)
|
||||
on signup verify / email confirm / account-deletion confirm.
|
||||
|
||||
CORS is **locked to an allow-list** (`config.cors_allowed_origins`, mirroring
|
||||
Django's `CORS_ALLOWED_ORIGINS` derived from `DOMAIN`; override via the
|
||||
`CORS_ALLOWED_ORIGINS` env var) rather than reflecting any origin — credentialed
|
||||
CORS can't use a wildcard. Request *headers* are still mirrored; the origin is
|
||||
the security boundary.
|
||||
|
||||
Password strength is enforced by `password_policy.rs`, a faithful port of
|
||||
Django's four default `AUTH_PASSWORD_VALIDATORS` (minimum length 8, the shipped
|
||||
20k common-password list embedded via `include_bytes!`, all-numeric rejection,
|
||||
and user-attribute similarity using a `difflib.SequenceMatcher.quick_ratio`
|
||||
reimplementation) applied on signup / change-password / complete-reset.
|
||||
**Deliberate divergence:** the live Django app configures these validators but
|
||||
never actually calls `validate_password`, so a weak password Django would accept
|
||||
is rejected here. Verdicts and message ordering were cross-checked against
|
||||
Django's `validate_password` on representative inputs.
|
||||
|
||||
Tested (`cargo test`): PBKDF2 verify against a known Django-produced hash + a
|
||||
hash→verify round-trip; SimpleJWT mint/verify round-trips incl. wrong-secret and
|
||||
tampered-token rejection; the rate limiter and code-TTL logic; and the password
|
||||
validators. **Still open:** an integration test for cascade-delete completeness
|
||||
(needs a seeded test DB harness, a different class of test from these unit
|
||||
tests).
|
||||
|
||||
## Next slices (suggested order)
|
||||
|
||||
1. ~~Auth issuance (login/refresh)~~ — **done** (PBKDF2 verify + SimpleJWT minting
|
||||
+ cookie views + refresh blacklist).
|
||||
2. ~~Ratings/replies write + notifications (mentions parsing)~~ — **done**
|
||||
(CRUD + feed/recent/reply, mention notifications, CASCADE emulation).
|
||||
3. ~~Users/social, tickets/support~~ — **done** (account lifecycle, follow
|
||||
graph, profile comments, notifications, tickets; SMTP email; avatar resize;
|
||||
account/ticket cascade).
|
||||
4. ~~Admin + jobs~~ — **done** (`AdminViewSet` + a `BackgroundJob` trait/registry
|
||||
/runner, an in-process interval scheduler replacing Celery beat, all 5
|
||||
management commands incl. full `sync_flavors` Shopify port).
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
//! SimpleJWT-compatible token verification.
|
||||
//!
|
||||
//! Django's SimpleJWT signs access tokens with HS256 keyed on `SECRET_KEY`.
|
||||
//! The claim set is `{ token_type, exp, iat, jti, user_id }`. We verify the
|
||||
//! signature + `exp` and require `token_type == "access"`. No issuance happens
|
||||
//! here yet — login/refresh stay on Django until that slice is ported.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||
use sea_orm::EntityTrait;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::entities::prelude::User;
|
||||
use crate::entities::user;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub user_id: i32,
|
||||
pub token_type: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
/// The authenticated user id, or `None` for anonymous requests. Used by
|
||||
/// `IsAuthenticatedOrReadOnly`-style endpoints (e.g. `user_rating`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OptionalUser(pub Option<i32>);
|
||||
|
||||
/// A required authenticated user id; rejects with 401 when absent/invalid.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AuthUser(pub i32);
|
||||
|
||||
fn token_from_parts(parts: &Parts) -> Option<String> {
|
||||
if let Some(auth) = parts
|
||||
.headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Some(rest) = auth.strip_prefix("Bearer ") {
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
// httpOnly cookie fallback (JWT_AUTH_COOKIE_ACCESS = "access_token").
|
||||
let cookie = parts
|
||||
.headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())?;
|
||||
for kv in cookie.split(';') {
|
||||
let kv = kv.trim();
|
||||
if let Some(val) = kv.strip_prefix("access_token=") {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn verify(token: &str, secret: &str) -> Option<Claims> {
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.validate_aud = false;
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.ok()?;
|
||||
if data.claims.token_type != "access" {
|
||||
return None;
|
||||
}
|
||||
Some(data.claims)
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for OptionalUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let uid = token_from_parts(parts)
|
||||
.and_then(|t| verify(&t, &state.config.secret_key))
|
||||
.map(|c| c.user_id);
|
||||
Ok(OptionalUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
token_from_parts(parts)
|
||||
.and_then(|t| verify(&t, &state.config.secret_key))
|
||||
.map(|c| AuthUser(c.user_id))
|
||||
.ok_or(ApiError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
/// A staff user (DRF `IsAdminUser` = `is_staff`). 401 if unauthenticated, 403 if
|
||||
/// authenticated but not staff. Carries the loaded user so admin handlers reuse it.
|
||||
pub struct AdminUser(pub user::Model);
|
||||
|
||||
impl FromRequestParts<AppState> for AdminUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let uid = token_from_parts(parts)
|
||||
.and_then(|t| verify(&t, &state.config.secret_key))
|
||||
.map(|c| c.user_id)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let user = User::find_by_id(uid)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
if !user.is_staff {
|
||||
return Err(ApiError::Forbidden(
|
||||
"You do not have permission to perform this action.".into(),
|
||||
));
|
||||
}
|
||||
Ok(AdminUser(user))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Avatar upload handling, mirroring the PIL step in Django's `User.save`:
|
||||
//! images larger than 256px or not already JPEG are thumbnailed to fit 256×256
|
||||
//! and re-encoded as JPEG (quality 90). Smaller JPEGs are stored as-is.
|
||||
//!
|
||||
//! The processed file is written under `<MEDIA_ROOT>/avatars/` and the returned
|
||||
//! value is the DB-stored relative path (e.g. `avatars/<uuid>.jpg`), matching
|
||||
//! Django's `ImageField(upload_to="avatars/")`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
|
||||
const MAX_DIM: u32 = 256;
|
||||
|
||||
/// Process raw upload bytes and persist the avatar. Returns the relative media
|
||||
/// path to store in `User.avatar`.
|
||||
pub fn process_and_save(
|
||||
media_root: &str,
|
||||
original_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let format = image::guess_format(data).ok();
|
||||
let img = image::load_from_memory(data).map_err(|e| format!("invalid image: {e}"))?;
|
||||
|
||||
let oversized = img.width() > MAX_DIM || img.height() > MAX_DIM;
|
||||
let is_jpeg = format == Some(ImageFormat::Jpeg);
|
||||
|
||||
let (bytes, ext): (Vec<u8>, &str) = if oversized || !is_jpeg {
|
||||
// thumbnail() preserves aspect ratio, fitting within MAX_DIM×MAX_DIM.
|
||||
let resized = if oversized {
|
||||
img.thumbnail(MAX_DIM, MAX_DIM)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let rgb = DynamicImage::ImageRgb8(resized.to_rgb8());
|
||||
let mut buf = Vec::new();
|
||||
rgb.write_with_encoder(JpegEncoder::new_with_quality(&mut buf, 90))
|
||||
.map_err(|e| format!("encode jpeg: {e}"))?;
|
||||
(buf, "jpg")
|
||||
} else {
|
||||
let ext = Path::new(original_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("jpg");
|
||||
(data.to_vec(), if ext.is_empty() { "jpg" } else { ext })
|
||||
};
|
||||
|
||||
let dir = Path::new(media_root).join("avatars");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir avatars: {e}"))?;
|
||||
|
||||
let filename = format!("{}.{}", uuid::Uuid::new_v4().simple(), ext);
|
||||
let full = dir.join(&filename);
|
||||
std::fs::write(&full, &bytes).map_err(|e| format!("write avatar: {e}"))?;
|
||||
|
||||
Ok(format!("avatars/{filename}"))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Refresh-token blacklist, sharing SimpleJWT's tables
|
||||
//! (`token_blacklist_outstandingtoken` / `token_blacklist_blacklistedtoken`).
|
||||
//!
|
||||
//! Replicates `rest_framework_simplejwt`'s blacklist app so rotation works
|
||||
//! across both backends:
|
||||
//! - login records an outstanding token (`RefreshToken.for_user`),
|
||||
//! - refresh blacklists the incoming token before issuing a rotated pair
|
||||
//! (`BLACKLIST_AFTER_ROTATION`),
|
||||
//! - logout blacklists the cookie's refresh token,
|
||||
//! - verification rejects blacklisted jtis.
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, Value};
|
||||
|
||||
use crate::datetime::db_datetime;
|
||||
use crate::error::ApiResult;
|
||||
|
||||
/// Has this jti been blacklisted?
|
||||
pub async fn is_blacklisted(db: &DatabaseConnection, jti: &str) -> ApiResult<bool> {
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
"SELECT 1 FROM token_blacklist_blacklistedtoken b \
|
||||
JOIN token_blacklist_outstandingtoken o ON b.token_id = o.id \
|
||||
WHERE o.jti = ? LIMIT 1",
|
||||
[jti.into()],
|
||||
);
|
||||
Ok(db.query_one(stmt).await?.is_some())
|
||||
}
|
||||
|
||||
/// Record an outstanding refresh token (mirrors `RefreshToken.for_user`).
|
||||
/// `jti` is unique, so a re-record is a no-op.
|
||||
pub async fn record_outstanding(
|
||||
db: &DatabaseConnection,
|
||||
user_id: i32,
|
||||
jti: &str,
|
||||
token: &str,
|
||||
created_at: chrono::NaiveDateTime,
|
||||
expires_at: chrono::NaiveDateTime,
|
||||
) -> ApiResult<()> {
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
"INSERT OR IGNORE INTO token_blacklist_outstandingtoken \
|
||||
(token, created_at, expires_at, user_id, jti) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
token.into(),
|
||||
db_datetime(&created_at).into(),
|
||||
db_datetime(&expires_at).into(),
|
||||
user_id.into(),
|
||||
jti.into(),
|
||||
],
|
||||
);
|
||||
db.execute(stmt).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blacklist a refresh token (mirrors `BlacklistMixin.blacklist`): ensure an
|
||||
/// outstanding row exists for the jti, then a blacklist row referencing it.
|
||||
pub async fn blacklist(
|
||||
db: &DatabaseConnection,
|
||||
user_id: i32,
|
||||
jti: &str,
|
||||
token: &str,
|
||||
expires_at: chrono::NaiveDateTime,
|
||||
) -> ApiResult<()> {
|
||||
// `created_at` is unknown for a token we didn't mint here; Django's
|
||||
// get_or_create leaves it NULL in that case, so omit it.
|
||||
let upsert = Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
"INSERT OR IGNORE INTO token_blacklist_outstandingtoken \
|
||||
(token, created_at, expires_at, user_id, jti) VALUES (?, NULL, ?, ?, ?)",
|
||||
[
|
||||
token.into(),
|
||||
db_datetime(&expires_at).into(),
|
||||
user_id.into(),
|
||||
jti.into(),
|
||||
],
|
||||
);
|
||||
db.execute(upsert).await?;
|
||||
|
||||
let now = db_datetime(&Utc::now().naive_utc());
|
||||
let bl = Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
"INSERT OR IGNORE INTO token_blacklist_blacklistedtoken (blacklisted_at, token_id) \
|
||||
SELECT ?, id FROM token_blacklist_outstandingtoken WHERE jti = ?",
|
||||
[Value::from(now), jti.into()],
|
||||
);
|
||||
db.execute(bl).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::env;
|
||||
|
||||
/// Runtime configuration sourced from the environment.
|
||||
///
|
||||
/// `secret_key` MUST match Django's `SECRET_KEY` — SimpleJWT signs access
|
||||
/// tokens with HS256 keyed on it, and this service verifies the same tokens
|
||||
/// during the strangler rollout.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub secret_key: String,
|
||||
pub bind_addr: String,
|
||||
pub media_url: String,
|
||||
/// Filesystem root where uploaded media (avatars) are written. Mirrors
|
||||
/// Django's MEDIA_ROOT; both backends must point at the same directory.
|
||||
pub media_root: String,
|
||||
/// `Secure` flag on auth cookies (JWT_AUTH_COOKIE_SECURE; true in prod).
|
||||
pub jwt_cookie_secure: bool,
|
||||
/// Public site URL used to build verification/reset links (Django FRONTEND_URL).
|
||||
pub frontend_url: String,
|
||||
/// Exact origins allowed to send credentialed CORS requests. Mirrors Django's
|
||||
/// `CORS_ALLOWED_ORIGINS` (which derives from `DOMAIN`); credentialed CORS
|
||||
/// can't use a wildcard, so this is an explicit allow-list.
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
pub email: EmailConfig,
|
||||
/// Directory of banner JSON configs (Django's `backend/banners/`).
|
||||
pub banners_dir: String,
|
||||
/// Directory of legacy flavor JSON dumps (repo-root `legacy/`).
|
||||
pub legacy_dir: String,
|
||||
/// Output directory for database/media backups (Django's `backend/backups/`).
|
||||
pub backup_dir: String,
|
||||
/// Whether the in-process job scheduler runs. MUST be false while Django's
|
||||
/// Celery `beat` still owns scheduling — otherwise jobs fire twice against
|
||||
/// the shared `api_job` table. Flip to true only after disabling Django beat.
|
||||
pub enable_scheduler: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Filesystem path of the SQLite database file, parsed out of `database_url`
|
||||
/// (`sqlite://<path>?<params>`). Used by the backup job.
|
||||
pub fn sqlite_path(&self) -> String {
|
||||
self.database_url
|
||||
.strip_prefix("sqlite://")
|
||||
.unwrap_or(&self.database_url)
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// SMTP settings mirroring Django's `EMAIL_*` env vars. When `host` is `None`,
|
||||
/// email is logged to the console instead of sent (Django's console backend).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmailConfig {
|
||||
pub host: Option<String>,
|
||||
pub port: u16,
|
||||
pub use_tls: bool,
|
||||
pub use_ssl: bool,
|
||||
pub host_user: Option<String>,
|
||||
pub host_password: Option<String>,
|
||||
pub skip_cert_verification: bool,
|
||||
pub default_from: String,
|
||||
}
|
||||
|
||||
fn env_bool(key: &str, default: bool) -> bool {
|
||||
env::var(key)
|
||||
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
database_url: env::var("DATABASE_URL")
|
||||
// `mode=rw` (not `rwc`): the shared DB is created/owned by Django
|
||||
// and must already exist. `rwc` would silently create an empty DB
|
||||
// if the path were wrong, masking a misconfiguration.
|
||||
.unwrap_or_else(|_| "sqlite://../backend/db.sqlite3?mode=rw".to_string()),
|
||||
secret_key: env::var("SECRET_KEY")
|
||||
.map_err(|_| anyhow::anyhow!("SECRET_KEY must be set (match Django's)"))?,
|
||||
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8001".to_string()),
|
||||
media_url: env::var("MEDIA_URL").unwrap_or_else(|_| "media/".to_string()),
|
||||
media_root: env::var("MEDIA_ROOT").unwrap_or_else(|_| "../backend/media".to_string()),
|
||||
jwt_cookie_secure: env_bool("JWT_AUTH_COOKIE_SECURE", false),
|
||||
frontend_url: env::var("FRONTEND_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:5173".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
cors_allowed_origins: {
|
||||
let domain = env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string());
|
||||
match env::var("CORS_ALLOWED_ORIGINS") {
|
||||
Ok(v) if !v.trim().is_empty() => v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
_ => vec![
|
||||
format!("https://{domain}"),
|
||||
"http://localhost:5173".to_string(),
|
||||
"http://127.0.0.1:5173".to_string(),
|
||||
],
|
||||
}
|
||||
},
|
||||
email: EmailConfig {
|
||||
host: env::var("EMAIL_HOST").ok().filter(|s| !s.is_empty()),
|
||||
port: env::var("EMAIL_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(587),
|
||||
use_tls: env_bool("EMAIL_USE_TLS", false),
|
||||
use_ssl: env_bool("EMAIL_USE_SSL", false),
|
||||
host_user: env::var("EMAIL_HOST_USER").ok().filter(|s| !s.is_empty()),
|
||||
host_password: env::var("EMAIL_HOST_PASSWORD")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
skip_cert_verification: env_bool("EMAIL_SKIP_CERT_VERIFICATION", false),
|
||||
default_from: env::var("DEFAULT_FROM_EMAIL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| env::var("EMAIL_HOST_USER").ok().filter(|s| !s.is_empty()))
|
||||
.unwrap_or_else(|| "noreply@localhost".to_string()),
|
||||
},
|
||||
banners_dir: env::var("BANNERS_DIR")
|
||||
.unwrap_or_else(|_| "../backend/banners".to_string()),
|
||||
legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".to_string()),
|
||||
backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../backend/backups".to_string()),
|
||||
// Default OFF so ad-hoc `cargo run` instances don't run jobs.
|
||||
// Production (docker compose) sets ENABLE_SCHEDULER=true on the one
|
||||
// instance that should own `api_job` scheduling.
|
||||
enable_scheduler: env_bool("ENABLE_SCHEDULER", false),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! httpOnly JWT cookie strings, matching Django's `set_jwt_cookies` /
|
||||
//! `clear_jwt_cookies`. Names and attributes mirror `settings.JWT_AUTH_COOKIE_*`
|
||||
//! so the same cookies are interchangeable between the Django and Rust backends.
|
||||
|
||||
pub const COOKIE_ACCESS: &str = "access_token";
|
||||
pub const COOKIE_REFRESH: &str = "refresh_token";
|
||||
const COOKIE_PATH: &str = "/";
|
||||
const SAMESITE: &str = "Lax"; // JWT_AUTH_COOKIE_SAMESITE
|
||||
|
||||
/// Build a `Set-Cookie` value for an auth cookie with the given max-age (secs).
|
||||
pub fn set_cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String {
|
||||
let mut s = format!(
|
||||
"{name}={value}; Max-Age={max_age}; Path={COOKIE_PATH}; HttpOnly; SameSite={SAMESITE}"
|
||||
);
|
||||
if secure {
|
||||
s.push_str("; Secure");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a `Set-Cookie` value that clears the cookie (empty value, expired).
|
||||
pub fn clear_cookie(name: &str, secure: bool) -> String {
|
||||
let mut s = format!(
|
||||
"{name}=; Max-Age=0; Path={COOKIE_PATH}; HttpOnly; SameSite={SAMESITE}; \
|
||||
expires=Thu, 01 Jan 1970 00:00:00 GMT"
|
||||
);
|
||||
if secure {
|
||||
s.push_str("; Secure");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Read a cookie value out of a `Cookie:` header string.
|
||||
pub fn cookie_value(cookie_header: &str, name: &str) -> Option<String> {
|
||||
let prefix = format!("{name}=");
|
||||
cookie_header
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|kv| kv.strip_prefix(&prefix).map(str::to_string))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use chrono::{NaiveDateTime, Timelike, Utc};
|
||||
|
||||
/// Current UTC time truncated to microsecond precision. Django stores datetimes
|
||||
/// with 6 fractional digits; SeaORM/sqlx would otherwise persist nanoseconds,
|
||||
/// so truncate before inserting to keep the on-disk format identical.
|
||||
pub fn now_micros() -> NaiveDateTime {
|
||||
let now = Utc::now().naive_utc();
|
||||
now.with_nanosecond(now.nanosecond() / 1000 * 1000)
|
||||
.unwrap_or(now)
|
||||
}
|
||||
|
||||
/// Format a naive-UTC datetime exactly as DRF serializes an aware UTC
|
||||
/// `DateTimeField`: ISO-8601 with microseconds and a `Z` suffix, e.g.
|
||||
/// `2026-04-11T00:37:59.496766Z`.
|
||||
///
|
||||
/// Caveat: DRF (Python `isoformat`) omits the fractional part when microseconds
|
||||
/// are zero. We always emit 6 digits; verify against live responses if any
|
||||
/// client parses strictly.
|
||||
pub fn drf_iso(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
|
||||
}
|
||||
|
||||
/// Format a naive-UTC datetime the way Django writes datetimes into SQLite:
|
||||
/// `2026-04-11 00:37:59.496766` (space separator, 6 fractional digits, no `Z`).
|
||||
/// Used when inserting token-blacklist rows so they match Django's ORM writes.
|
||||
pub fn db_datetime(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use sea_orm::{
|
||||
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn connect(database_url: &str) -> anyhow::Result<DatabaseConnection> {
|
||||
let mut opts = ConnectOptions::new(database_url.to_owned());
|
||||
opts.max_connections(10)
|
||||
.min_connections(1)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.sqlx_logging(false);
|
||||
// sqlx already applies `busy_timeout=5s` and `foreign_keys=ON` per connection
|
||||
// by default (matching Django's 5s SQLite timeout), so SQLITE_BUSY waits up to
|
||||
// 5s before erroring rather than failing instantly.
|
||||
let conn = Database::connect(opts).await?;
|
||||
|
||||
// Enable WAL so the Rust and Django processes that share this SQLite file
|
||||
// during the strangler don't serialize readers behind the writer. In the
|
||||
// default rollback-journal mode a writer takes an exclusive lock that blocks
|
||||
// all readers, which surfaces as SQLITE_BUSY → 500s under concurrent load.
|
||||
// WAL lets readers proceed during a write. It is a persistent, database-level
|
||||
// setting stored in the file header, so this single statement covers every
|
||||
// pooled connection and the Django side too. Best-effort: switching journal
|
||||
// mode needs a brief exclusive lock that can't wait on busy_timeout, so log
|
||||
// and continue (the 5s busy_timeout still applies) if a writer holds it now.
|
||||
match conn
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"PRAGMA journal_mode=WAL;",
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => tracing::info!("sqlite journal_mode=WAL enabled"),
|
||||
Err(e) => {
|
||||
tracing::warn!("could not enable WAL (continuing in default journal mode): {e}")
|
||||
}
|
||||
}
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Output DTOs mirroring the DRF serializers field-for-field (names + order).
|
||||
//! `image` is write-only in DRF, so it is intentionally absent here.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CategoryOut {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReplyOut {
|
||||
pub id: i32,
|
||||
/// StringRelatedField → username.
|
||||
pub user: String,
|
||||
pub rating: i32,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RatingOut {
|
||||
pub id: i32,
|
||||
pub user: String,
|
||||
pub user_id: i32,
|
||||
pub user_avatar: Option<String>,
|
||||
pub flavor: i32,
|
||||
pub flavor_name: String,
|
||||
pub flavor_image: Option<String>,
|
||||
pub category_name: String,
|
||||
pub category_slug: String,
|
||||
pub is_available: bool,
|
||||
pub is_legacy: bool,
|
||||
pub score: i32,
|
||||
pub comment: Option<String>,
|
||||
pub created_at: String,
|
||||
pub replies: Vec<ReplyOut>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FlavorOut {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub category: i32,
|
||||
pub category_name: String,
|
||||
pub category_slug: String,
|
||||
pub description: String,
|
||||
pub average_rating: Option<f64>,
|
||||
pub followed_average_rating: Option<f64>,
|
||||
pub user_rating: Option<i32>,
|
||||
pub ratings: Vec<RatingOut>,
|
||||
pub image_url: Option<String>,
|
||||
pub image_urls: Vec<String>,
|
||||
pub is_available: bool,
|
||||
pub is_legacy: bool,
|
||||
pub shop_url: Option<String>,
|
||||
/// Keys "1".."10" → count. BTreeMap keeps stable ordering.
|
||||
pub rating_distribution: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
/// Search-action result item (FlavorViewSet.search).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SearchHit {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub subtitle: String,
|
||||
pub image_url: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserOut {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub drawer_anchor: String,
|
||||
pub avatar: Option<String>,
|
||||
pub following_count: i64,
|
||||
pub followers_count: i64,
|
||||
pub is_following: bool,
|
||||
pub unread_notifications_count: i64,
|
||||
pub is_superuser: bool,
|
||||
pub selected_banner: Option<i32>,
|
||||
pub selected_banner_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ProfileCommentOut {
|
||||
pub id: i32,
|
||||
pub author_username: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct NotificationOut {
|
||||
pub id: i32,
|
||||
pub actor_username: String,
|
||||
pub actor_avatar: Option<String>,
|
||||
pub notification_type: String,
|
||||
pub rating: Option<i32>,
|
||||
pub reply: Option<i32>,
|
||||
pub ticket: Option<i32>,
|
||||
pub profile_comment: Option<i32>,
|
||||
pub profile_owner_username: Option<String>,
|
||||
pub is_read: bool,
|
||||
pub created_at: String,
|
||||
pub flavor_name: Option<String>,
|
||||
pub flavor_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TicketMessageOut {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TicketOut {
|
||||
pub id: i32,
|
||||
pub user: i32,
|
||||
pub username: String,
|
||||
pub user_email: String,
|
||||
pub user_avatar: Option<String>,
|
||||
pub subject: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub messages: Vec<TicketMessageOut>,
|
||||
}
|
||||
|
||||
/// Custom payload of the `users/profile/<username>` action (not a serializer).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct PublicProfileOut {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub theme: String,
|
||||
pub avatar: Option<String>,
|
||||
pub following_count: i64,
|
||||
pub followers_count: i64,
|
||||
pub is_following: bool,
|
||||
pub ratings: Vec<RatingOut>,
|
||||
pub comments: Vec<ProfileCommentOut>,
|
||||
pub followers: Vec<UserOut>,
|
||||
pub following: Vec<UserOut>,
|
||||
}
|
||||
|
||||
/// Custom payload of the `users/dashboard` action.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DashboardOut {
|
||||
pub user: UserOut,
|
||||
pub rated_count: i64,
|
||||
pub missing_count: i64,
|
||||
pub missing_flavors: Vec<FlavorOut>,
|
||||
pub my_ratings: Vec<RatingOut>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BannerOut {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub is_active: bool,
|
||||
pub is_enabled: bool,
|
||||
pub settings: Value,
|
||||
pub schema: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! SMTP email, mirroring Django's `send_mail` + `InsecureEmailBackend`.
|
||||
//!
|
||||
//! Config maps the `EMAIL_*` env vars (see `config::EmailConfig`):
|
||||
//! - `EMAIL_USE_SSL` → implicit TLS (SMTPS, usually port 465)
|
||||
//! - `EMAIL_USE_TLS` → STARTTLS
|
||||
//! - neither → plaintext
|
||||
//! - `EMAIL_SKIP_CERT_VERIFICATION` → accept self-signed certs (the whole point
|
||||
//! of Django's `InsecureEmailBackend`).
|
||||
//!
|
||||
//! When `EMAIL_HOST` is unset the message is logged instead of sent — the
|
||||
//! equivalent of Django's console email backend in dev.
|
||||
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
use crate::config::EmailConfig;
|
||||
|
||||
/// Send a plain-text email to one or more recipients. Returns an error if the
|
||||
/// message can't be built or the transport fails (parity with `fail_silently=False`).
|
||||
pub async fn send_mail(
|
||||
cfg: &EmailConfig,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
recipients: &[String],
|
||||
) -> Result<(), String> {
|
||||
let Some(host) = cfg.host.as_deref() else {
|
||||
// Console backend equivalent.
|
||||
tracing::info!(
|
||||
to = ?recipients,
|
||||
subject = %subject,
|
||||
"email (console backend, not sent):\n{body}"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let from: Mailbox = cfg
|
||||
.default_from
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid DEFAULT_FROM_EMAIL: {e}"))?;
|
||||
|
||||
let mut builder = Message::builder().from(from).subject(subject);
|
||||
for r in recipients {
|
||||
let mbox: Mailbox = r
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid recipient {r}: {e}"))?;
|
||||
builder = builder.to(mbox);
|
||||
}
|
||||
let message = builder
|
||||
.body(body.to_string())
|
||||
.map_err(|e| format!("build email: {e}"))?;
|
||||
|
||||
let mut transport =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(cfg.port);
|
||||
|
||||
if cfg.use_ssl || cfg.use_tls {
|
||||
let tls = TlsParameters::builder(host.to_string())
|
||||
.dangerous_accept_invalid_certs(cfg.skip_cert_verification)
|
||||
.dangerous_accept_invalid_hostnames(cfg.skip_cert_verification)
|
||||
.build()
|
||||
.map_err(|e| format!("tls config: {e}"))?;
|
||||
transport = transport.tls(if cfg.use_ssl {
|
||||
Tls::Wrapper(tls)
|
||||
} else {
|
||||
Tls::Required(tls)
|
||||
});
|
||||
}
|
||||
|
||||
if let (Some(u), Some(p)) = (cfg.host_user.as_ref(), cfg.host_password.as_ref()) {
|
||||
transport = transport.credentials(Credentials::new(u.clone(), p.clone()));
|
||||
}
|
||||
|
||||
transport
|
||||
.build()
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|e| format!("smtp send: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fire-and-forget send, mirroring Django's `send_email_task.delay(...)` where
|
||||
/// the view does not await delivery. Logs on failure.
|
||||
pub fn spawn_mail(cfg: EmailConfig, subject: String, body: String, recipients: Vec<String>) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = send_mail(&cfg, &subject, &body, &recipients).await {
|
||||
tracing::error!(error = %e, subject = %subject, "background email send failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_banner")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub description: String,
|
||||
pub is_active: bool,
|
||||
pub is_enabled: bool,
|
||||
#[sea_orm(column_type = "Json")]
|
||||
pub settings: Json,
|
||||
#[sea_orm(column_type = "Json")]
|
||||
pub schema: Json,
|
||||
pub created_at: ChronoDateTime,
|
||||
pub updated_at: ChronoDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,24 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_category")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::flavor::Entity")]
|
||||
Flavor,
|
||||
}
|
||||
|
||||
impl Related<super::flavor::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Flavor.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,54 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_flavor")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub category_id: i32,
|
||||
pub name: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub description: String,
|
||||
pub image_url: Option<String>,
|
||||
/// Django JSONField (default list), stored as TEXT.
|
||||
#[sea_orm(column_type = "Json")]
|
||||
pub image_urls: Option<Json>,
|
||||
pub image: Option<String>,
|
||||
/// Django JSONField (default list), stored as TEXT.
|
||||
#[sea_orm(column_type = "Json")]
|
||||
pub local_image_paths: Option<Json>,
|
||||
pub main_image_path: Option<String>,
|
||||
pub shop_url: Option<String>,
|
||||
pub is_available: bool,
|
||||
pub is_legacy: bool,
|
||||
pub external_id: Option<i64>,
|
||||
/// Django stores naive UTC text ("YYYY-MM-DD HH:MM:SS.ffffff"); we treat it
|
||||
/// as UTC when formatting to DRF's ISO-8601 `...Z` output.
|
||||
pub created_at: ChronoDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::category::Entity",
|
||||
from = "Column::CategoryId",
|
||||
to = "super::category::Column::Id"
|
||||
)]
|
||||
Category,
|
||||
#[sea_orm(has_many = "super::rating::Entity")]
|
||||
Rating,
|
||||
}
|
||||
|
||||
impl Related<super::category::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Category.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::rating::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Rating.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,26 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// `api_job` — an execution log + schedule for the background jobs (formerly
|
||||
/// Celery tasks driven by management commands). `next_run` is a stored column
|
||||
/// in Django but derived in the serializer; we leave it mapped yet unused and
|
||||
/// compute the served value from `last_run + interval_hours` (see routes/admin).
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_job")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub last_run: Option<ChronoDateTime>,
|
||||
pub next_run: Option<ChronoDateTime>,
|
||||
pub interval_hours: i32,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub last_output: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! SeaORM entities mapped 1:1 onto Django's existing `api_*` tables.
|
||||
//!
|
||||
//! Column names and types mirror Django's schema exactly — both backends read
|
||||
//! and write the same SQLite file during the strangler migration, so drift here
|
||||
//! corrupts data. When adding a field, confirm it against the live table with
|
||||
//! `PRAGMA table_info(api_<name>)`.
|
||||
|
||||
pub mod banner;
|
||||
pub mod category;
|
||||
pub mod flavor;
|
||||
pub mod job;
|
||||
pub mod notification;
|
||||
pub mod profile_comment;
|
||||
pub mod rating;
|
||||
pub mod reply;
|
||||
pub mod system_config;
|
||||
pub mod ticket;
|
||||
pub mod ticket_message;
|
||||
pub mod user;
|
||||
pub mod user_ip;
|
||||
|
||||
pub mod prelude {
|
||||
pub use super::banner::Entity as Banner;
|
||||
pub use super::category::Entity as Category;
|
||||
pub use super::flavor::Entity as Flavor;
|
||||
pub use super::job::Entity as Job;
|
||||
pub use super::notification::Entity as Notification;
|
||||
pub use super::profile_comment::Entity as ProfileComment;
|
||||
pub use super::rating::Entity as Rating;
|
||||
pub use super::reply::Entity as Reply;
|
||||
pub use super::system_config::Entity as SystemConfig;
|
||||
pub use super::ticket::Entity as Ticket;
|
||||
pub use super::ticket_message::Entity as TicketMessage;
|
||||
pub use super::user::Entity as User;
|
||||
pub use super::user_ip::Entity as UserIp;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// Maps Django's `api_notification` (see `api/models/social.py`). Only `mention`
|
||||
/// and `reply` rows are written by the ported endpoints; `recipient`/`actor`
|
||||
/// are required, the per-object foreign keys are all nullable.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_notification")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub is_read: bool,
|
||||
pub created_at: ChronoDateTime,
|
||||
pub actor_id: i32,
|
||||
pub rating_id: Option<i32>,
|
||||
pub recipient_id: i32,
|
||||
pub reply_id: Option<i32>,
|
||||
pub ticket_id: Option<i32>,
|
||||
#[sea_orm(column_type = "String(StringLen::N(20))")]
|
||||
pub notification_type: String,
|
||||
pub profile_comment_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,18 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// Maps Django's `api_profilecomment` (comments left on a user's profile).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_profilecomment")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub text: String,
|
||||
pub created_at: ChronoDateTime,
|
||||
pub author_id: i32,
|
||||
pub profile_owner_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,52 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_rating")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
pub flavor_id: i32,
|
||||
pub score: i32,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub comment: Option<String>,
|
||||
pub created_at: ChronoDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::flavor::Entity",
|
||||
from = "Column::FlavorId",
|
||||
to = "super::flavor::Column::Id"
|
||||
)]
|
||||
Flavor,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
#[sea_orm(has_many = "super::reply::Entity")]
|
||||
Reply,
|
||||
}
|
||||
|
||||
impl Related<super::flavor::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Flavor.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::reply::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Reply.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,43 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_reply")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
pub rating_id: i32,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub text: String,
|
||||
pub created_at: ChronoDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::rating::Entity",
|
||||
from = "Column::RatingId",
|
||||
to = "super::rating::Column::Id"
|
||||
)]
|
||||
Rating,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::rating::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Rating.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,19 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// `api_systemconfig` — singleton (pk always 1) of UI-editable app settings.
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_systemconfig")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub site_name: String,
|
||||
pub maintenance_mode: bool,
|
||||
pub allow_new_signups: bool,
|
||||
pub require_email_verification: bool,
|
||||
pub updated_at: ChronoDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,22 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// Maps Django's `api_ticket` (support tickets).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_ticket")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(column_type = "String(StringLen::N(200))")]
|
||||
pub subject: String,
|
||||
pub description: String,
|
||||
#[sea_orm(column_type = "String(StringLen::N(20))")]
|
||||
pub status: String,
|
||||
pub created_at: ChronoDateTime,
|
||||
pub updated_at: ChronoDateTime,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,18 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// Maps Django's `api_ticketmessage` (messages within a support ticket).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_ticketmessage")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub text: String,
|
||||
pub created_at: ChronoDateTime,
|
||||
pub ticket_id: i32,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,43 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// Subset of Django's `api_user` (AbstractUser + custom fields). Only the
|
||||
/// columns the Rust endpoints currently read are mapped; add more as endpoints
|
||||
/// are ported. `password` is intentionally included so auth can be ported later
|
||||
/// (Django PBKDF2 verification), but is never serialized.
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub password: String,
|
||||
pub last_login: Option<ChronoDateTime>,
|
||||
pub is_superuser: bool,
|
||||
pub username: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub email: String,
|
||||
pub is_staff: bool,
|
||||
pub is_active: bool,
|
||||
pub date_joined: ChronoDateTime,
|
||||
pub theme: String,
|
||||
pub email_confirmation_code: Option<String>,
|
||||
pub pending_email: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub language: String,
|
||||
pub selected_banner_id: Option<i32>,
|
||||
pub drawer_anchor: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::rating::Entity")]
|
||||
Rating,
|
||||
}
|
||||
|
||||
impl Related<super::rating::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Rating.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,18 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// Maps Django's `api_userip` — per-user login IP log (unique on user+ip).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_userip")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(column_type = "String(StringLen::N(39))")]
|
||||
pub ip_address: String,
|
||||
pub last_login: ChronoDateTime,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,91 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
/// Unified API error. Serializes to a DRF-ish `{ "detail": "..." }` body so the
|
||||
/// existing frontend's error handling keeps working unchanged.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("authentication credentials were not provided")]
|
||||
Unauthorized,
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sea_orm::DbErr),
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
/// 403 with a DRF `{ "detail": ... }` body (e.g. object-permission denial).
|
||||
#[error("{0}")]
|
||||
Forbidden(String),
|
||||
/// 400 whose body is an arbitrary JSON value, used to mirror DRF serializer
|
||||
/// validation errors verbatim (field-keyed maps or top-level lists).
|
||||
#[error("validation error")]
|
||||
Validation(serde_json::Value),
|
||||
/// Arbitrary status + JSON body, for DRF actions that return custom
|
||||
/// `{ "error": ... }` / `{ "status": ... }` payloads with a non-400 status
|
||||
/// (e.g. 404 or 500 from the user account actions).
|
||||
#[error("error")]
|
||||
JsonStatus(StatusCode, serde_json::Value),
|
||||
/// 429 Too Many Requests. Replaces django_ratelimit's block (which DRF
|
||||
/// rendered as a 403); a real `429` + `Retry-After` is the professional
|
||||
/// behaviour and the frontend's generic error fallbacks handle it.
|
||||
#[error("too many requests")]
|
||||
RateLimited { retry_after: u64 },
|
||||
#[error("internal error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
// Validation carries its own JSON body shape; handle before the
|
||||
// `{ "detail": ... }` path below.
|
||||
if let ApiError::Validation(body) = &self {
|
||||
return (StatusCode::BAD_REQUEST, Json(body.clone())).into_response();
|
||||
}
|
||||
if let ApiError::JsonStatus(status, body) = &self {
|
||||
return (*status, Json(body.clone())).into_response();
|
||||
}
|
||||
if let ApiError::RateLimited { retry_after } = &self {
|
||||
let msg = "Too many requests. Please try again later.";
|
||||
let mut resp = (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
// `error` for the account flows' `data.error` handling, `detail`
|
||||
// for DRF-style consumers.
|
||||
Json(json!({ "detail": msg, "error": msg })),
|
||||
)
|
||||
.into_response();
|
||||
if let Ok(hv) = axum::http::HeaderValue::from_str(&retry_after.to_string()) {
|
||||
resp.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, hv);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
let (status, detail) = match &self {
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, "Not found.".to_string()),
|
||||
ApiError::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Authentication credentials were not provided.".to_string(),
|
||||
),
|
||||
ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
|
||||
ApiError::Validation(_) | ApiError::JsonStatus(..) | ApiError::RateLimited { .. } => {
|
||||
unreachable!("handled above")
|
||||
}
|
||||
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||
ApiError::Db(e) => {
|
||||
tracing::error!(error = %e, "database error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Server error.".to_string(),
|
||||
)
|
||||
}
|
||||
ApiError::Internal => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Server error.".to_string(),
|
||||
),
|
||||
};
|
||||
(status, Json(json!({ "detail": detail }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Port of `backup_db --full` — snapshot the SQLite database with `VACUUM INTO`
|
||||
//! (a consistent online backup), bundle it with the media tree into a gzip
|
||||
//! tarball, drop the raw snapshot, and prune backups older than the retention
|
||||
//! window. The Celery task always ran `--full` with the default 7-day retention.
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use sea_orm::{ConnectionTrait, DbBackend, Statement};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::BackgroundJob;
|
||||
|
||||
const RETENTION_DAYS: u64 = 7;
|
||||
|
||||
pub struct BackupDb;
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundJob for BackupDb {
|
||||
fn name(&self) -> &'static str {
|
||||
"backup_db"
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Database Backup"
|
||||
}
|
||||
|
||||
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
|
||||
let db_path = state.config.sqlite_path();
|
||||
if db_path.is_empty() {
|
||||
anyhow::bail!("could not determine sqlite path from DATABASE_URL");
|
||||
}
|
||||
let backup_dir = state.config.backup_dir.clone();
|
||||
let media_root = state.config.media_root.clone();
|
||||
fs::create_dir_all(&backup_dir)?;
|
||||
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let db_backup_path = format!("{backup_dir}/db_backup_{timestamp}.sqlite3");
|
||||
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
log.push(format!("Cloning database {db_path}..."));
|
||||
|
||||
// VACUUM INTO produces a consistent snapshot even under concurrent
|
||||
// access — the SQLite-native equivalent of Python's `sqlite3 .backup`.
|
||||
let escaped = db_backup_path.replace('\'', "''");
|
||||
state
|
||||
.db
|
||||
.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
format!("VACUUM INTO '{escaped}'"),
|
||||
))
|
||||
.await?;
|
||||
log.push(format!("DB backup created: {db_backup_path}"));
|
||||
|
||||
// Archive + retention is blocking IO; keep it off the async runtime.
|
||||
let archive_path = format!("{backup_dir}/full_backup_{timestamp}.tar.gz");
|
||||
log.push(format!("Creating full archive at {archive_path}..."));
|
||||
let archive_log = tokio::task::spawn_blocking(move || {
|
||||
build_archive(&db_backup_path, &media_root, &archive_path)?;
|
||||
// Drop the raw snapshot now that it is bundled.
|
||||
let _ = fs::remove_file(&db_backup_path);
|
||||
prune_old(&backup_dir)
|
||||
})
|
||||
.await??;
|
||||
log.push("Full archive created successfully.".to_string());
|
||||
log.push(format!(
|
||||
"Cleaning up backups older than {RETENTION_DAYS} days..."
|
||||
));
|
||||
log.extend(archive_log);
|
||||
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the gzip tarball: the snapshot as `db.sqlite3` + the media tree as `media`.
|
||||
fn build_archive(db_backup_path: &str, media_root: &str, archive_path: &str) -> anyhow::Result<()> {
|
||||
let tar_gz = File::create(archive_path)?;
|
||||
let enc = GzEncoder::new(tar_gz, Compression::default());
|
||||
let mut tar = tar::Builder::new(enc);
|
||||
tar.append_path_with_name(db_backup_path, "db.sqlite3")?;
|
||||
if Path::new(media_root).exists() {
|
||||
tar.append_dir_all("media", media_root)?;
|
||||
}
|
||||
tar.into_inner()?.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete files in `backup_dir` older than the retention window. Returns log lines.
|
||||
fn prune_old(backup_dir: &str) -> anyhow::Result<Vec<String>> {
|
||||
let mut log = Vec::new();
|
||||
let cutoff = SystemTime::now() - Duration::from_secs(RETENTION_DAYS * 24 * 60 * 60);
|
||||
for entry in fs::read_dir(backup_dir)?.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let modified = entry.metadata().and_then(|m| m.modified()).ok();
|
||||
if let Some(mtime) = modified {
|
||||
if mtime < cutoff {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match fs::remove_file(&path) {
|
||||
Ok(_) => log.push(format!(" -> Deleted old backup: {name}")),
|
||||
Err(e) => log.push(format!(" -> Failed to delete {name}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(log)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Port of `cleanup_duplicates` — merge flavors with the same normalized name
|
||||
//! within a category. Always runs in "apply" mode with suffix normalization
|
||||
//! (the Celery task passed no flags).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
use crate::entities::flavor;
|
||||
use crate::entities::prelude::{Category, Flavor};
|
||||
use crate::merge::merge_flavors;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::BackgroundJob;
|
||||
|
||||
/// Trailing form-factor phrases stripped during name normalization. Each is a
|
||||
/// sequence of whitespace-separated tokens; the match requires a preceding
|
||||
/// token (Django's leading `\s+`), so a name that is *only* the phrase is kept.
|
||||
const SUFFIXES: &[&[&str]] = &[&["energy"], &["hydration"], &["iced", "tea"], &["(legacy)"]];
|
||||
|
||||
/// Lowercase, trim, and strip the known trailing suffixes (Django's
|
||||
/// `normalize_name`). The result is a grouping key, not a display value.
|
||||
fn normalize_name(name: &str) -> String {
|
||||
let mut tokens: Vec<String> = name.split_whitespace().map(|t| t.to_lowercase()).collect();
|
||||
for suffix in SUFFIXES {
|
||||
if tokens.len() > suffix.len() && tokens[tokens.len() - suffix.len()..] == suffix[..] {
|
||||
tokens.truncate(tokens.len() - suffix.len());
|
||||
}
|
||||
}
|
||||
tokens.join(" ")
|
||||
}
|
||||
|
||||
pub struct CleanupDuplicates;
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundJob for CleanupDuplicates {
|
||||
fn name(&self) -> &'static str {
|
||||
"cleanup_duplicates"
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Cleanup Duplicate Flavors"
|
||||
}
|
||||
|
||||
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
|
||||
let db = &state.db;
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut total_merged = 0usize;
|
||||
|
||||
let categories = Category::find().all(db).await?;
|
||||
for cat in categories {
|
||||
let flavors = Flavor::find()
|
||||
.filter(flavor::Column::CategoryId.eq(cat.id))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// Group by normalized name (preserve insertion order is not needed;
|
||||
// each group is sorted before merging).
|
||||
let mut groups: std::collections::HashMap<String, Vec<flavor::Model>> =
|
||||
std::collections::HashMap::new();
|
||||
for f in flavors {
|
||||
groups.entry(normalize_name(&f.name)).or_default().push(f);
|
||||
}
|
||||
|
||||
for (_, mut group) in groups {
|
||||
if group.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
// Keep the catalog row (has external_id), oldest on ties.
|
||||
group.sort_by(|a, b| {
|
||||
(a.external_id.is_none(), a.created_at)
|
||||
.cmp(&(b.external_id.is_none(), b.created_at))
|
||||
});
|
||||
let mut keep = group.remove(0);
|
||||
let names: Vec<String> = group
|
||||
.iter()
|
||||
.map(|o| format!("'{}' (ID {})", o.name, o.id))
|
||||
.collect();
|
||||
log.push(format!(
|
||||
"Merging {} into '{}' (Category: {}, ID: {})",
|
||||
names.join(", "),
|
||||
keep.name,
|
||||
cat.name,
|
||||
keep.id
|
||||
));
|
||||
|
||||
for other in group {
|
||||
merge_flavors(db, &keep, &other).await?;
|
||||
// Re-fetch: merge may have inherited `external_id` onto keep,
|
||||
// which the next iteration must see (external_id is unique).
|
||||
keep = Flavor::find_by_id(keep.id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("kept flavor vanished mid-merge"))?;
|
||||
total_merged += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.push(format!("Finished cleaning up {total_merged} duplicates."));
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Shared image-download helpers for the catalog jobs. Filenames are keyed by a
|
||||
//! sha1 of the URL path and a whitelisted extension, matching Django so both
|
||||
//! backends dedupe against the same `media/` files.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Whitelisted image extension from a URL path, defaulting to `png` (Django's
|
||||
/// `_ext_for`).
|
||||
pub fn ext_for(url: &str) -> String {
|
||||
let path = reqwest::Url::parse(url)
|
||||
.map(|u| u.path().to_string())
|
||||
.unwrap_or_else(|_| url.to_string());
|
||||
let ext = path
|
||||
.rsplit_once('.')
|
||||
.map(|(_, e)| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" | "png" | "webp" | "gif" => ext,
|
||||
_ => "png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// First 10 hex chars of sha1(url path) (Django's `_hash_for`).
|
||||
pub fn hash_for(url: &str) -> String {
|
||||
let path = reqwest::Url::parse(url)
|
||||
.map(|u| u.path().to_string())
|
||||
.unwrap_or_else(|_| url.to_string());
|
||||
let digest = Sha1::digest(path.as_bytes());
|
||||
let hex = hex_lower(&digest);
|
||||
hex[..10].to_string()
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Download `url` to `abs_path`, skipping if a non-empty file already exists.
|
||||
/// Returns `true` if the file is present afterwards (Django's
|
||||
/// `download_image_to_path` returning the rel path vs None).
|
||||
pub async fn download_image(client: &reqwest::Client, url: &str, abs_path: &Path) -> bool {
|
||||
if let Ok(meta) = std::fs::metadata(abs_path) {
|
||||
if meta.len() > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = abs_path.parent() {
|
||||
if std::fs::create_dir_all(parent).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let resp = match client.get(url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(url, error = %e, "image download failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!(url, status = %resp.status(), "image download non-200");
|
||||
return false;
|
||||
}
|
||||
match resp.bytes().await {
|
||||
Ok(bytes) => std::fs::write(abs_path, &bytes).is_ok(),
|
||||
Err(e) => {
|
||||
tracing::warn!(url, error = %e, "image body read failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client with the same `User-Agent` Django sent (some CDNs gate on it).
|
||||
pub fn http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 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::job;
|
||||
use crate::entities::prelude::Job as JobEntity;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub use scheduler::spawn_scheduler;
|
||||
|
||||
/// A runnable background job. Implementors are pure work units; the `api_job`
|
||||
/// row bookkeeping lives in [`run_job`].
|
||||
#[async_trait]
|
||||
pub trait BackgroundJob: Send + Sync {
|
||||
/// Stable identifier, matching a `Job.JOB_TYPES` key (e.g. `"sync_flavors"`).
|
||||
fn name(&self) -> &'static str;
|
||||
/// Human label, matching the Django `get_name_display()` value.
|
||||
fn display_name(&self) -> &'static str;
|
||||
/// Do the work, returning a human-readable output log on success.
|
||||
async fn run(&self, state: &AppState) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
/// All registered jobs. Order is irrelevant (the admin list re-sorts by name).
|
||||
pub fn registry() -> Vec<Arc<dyn BackgroundJob>> {
|
||||
vec![
|
||||
Arc::new(sync_flavors::SyncFlavors),
|
||||
Arc::new(cleanup_duplicates::CleanupDuplicates),
|
||||
Arc::new(backup_db::BackupDb),
|
||||
Arc::new(seed_legacy::SeedLegacy),
|
||||
Arc::new(seed_banners::SeedBanners),
|
||||
]
|
||||
}
|
||||
|
||||
/// Look up a job by its `name`.
|
||||
pub fn lookup(name: &str) -> Option<Arc<dyn BackgroundJob>> {
|
||||
registry().into_iter().find(|j| j.name() == name)
|
||||
}
|
||||
|
||||
/// Fetch the `api_job` row for `name`, creating a default one if absent
|
||||
/// (mirrors `Job.objects.get_or_create(name=...)`).
|
||||
pub async fn get_or_create_row(db: &DatabaseConnection, name: &str) -> Result<job::Model, DbErr> {
|
||||
if let Some(j) = JobEntity::find()
|
||||
.filter(job::Column::Name.eq(name))
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
return Ok(j);
|
||||
}
|
||||
job::ActiveModel {
|
||||
name: Set(name.to_string()),
|
||||
status: Set("pending".into()),
|
||||
interval_hours: Set(0),
|
||||
last_output: Set(String::new()),
|
||||
error_message: Set(String::new()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run a job end-to-end, recording lifecycle state in its `api_job` row. Skips
|
||||
/// if the same job is already running (concurrency guard in [`AppState`]). Safe
|
||||
/// to `tokio::spawn` as fire-and-forget — errors are logged + persisted.
|
||||
pub async fn run_job(state: &AppState, job: Arc<dyn BackgroundJob>) {
|
||||
let name = job.name();
|
||||
// Reserve the slot; bail if another run holds it.
|
||||
if !state.running_jobs.lock().await.insert(name.to_string()) {
|
||||
tracing::warn!(job = name, "job already running; skipping");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = run_inner(state, job.as_ref()).await {
|
||||
tracing::error!(job = name, error = %e, "job lifecycle bookkeeping failed");
|
||||
}
|
||||
state.running_jobs.lock().await.remove(name);
|
||||
}
|
||||
|
||||
async fn run_inner(state: &AppState, job: &dyn BackgroundJob) -> anyhow::Result<()> {
|
||||
let db = &state.db;
|
||||
let row = get_or_create_row(db, job.name()).await?;
|
||||
|
||||
let mut am: job::ActiveModel = row.into();
|
||||
am.status = Set("running".into());
|
||||
am.last_run = Set(Some(crate::datetime::now_micros()));
|
||||
am.last_output = Set(String::new());
|
||||
am.error_message = Set(String::new());
|
||||
let row = am.update(db).await?;
|
||||
|
||||
tracing::info!(job = job.name(), "job started");
|
||||
match job.run(state).await {
|
||||
Ok(output) => {
|
||||
let mut am: job::ActiveModel = row.into();
|
||||
am.status = Set("completed".into());
|
||||
am.last_output = Set(output);
|
||||
am.error_message = Set(String::new());
|
||||
am.update(db).await?;
|
||||
tracing::info!(job = job.name(), "job completed");
|
||||
}
|
||||
Err(e) => {
|
||||
let mut am: job::ActiveModel = row.into();
|
||||
am.status = Set("failed".into());
|
||||
am.error_message = Set(e.to_string());
|
||||
am.update(db).await?;
|
||||
tracing::error!(job = job.name(), error = %e, "job failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Django's `slugify`: NFKD-normalize, drop non-ASCII, lowercase, keep only
|
||||
/// `[a-z0-9_-]` plus whitespace, collapse runs of spaces/hyphens to a single
|
||||
/// `-`, and strip leading/trailing `-`/`_`. Matching this exactly keeps the
|
||||
/// media image paths (and category slugs) identical to Django's so files and
|
||||
/// rows are reused, not duplicated.
|
||||
pub fn slugify(value: &str) -> String {
|
||||
let ascii: String = value
|
||||
.nfkd()
|
||||
.filter(|c| c.is_ascii())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
// Keep word chars, hyphen and whitespace; drop the rest.
|
||||
let kept: String = ascii
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || c.is_whitespace())
|
||||
.collect();
|
||||
// Collapse [-\s]+ into a single '-'.
|
||||
let mut out = String::with_capacity(kept.len());
|
||||
let mut prev_dash = false;
|
||||
for c in kept.chars() {
|
||||
if c == '-' || c.is_whitespace() {
|
||||
if !prev_dash {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
prev_dash = false;
|
||||
}
|
||||
}
|
||||
out.trim_matches(|c| c == '-' || c == '_').to_string()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! 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::job;
|
||||
use crate::entities::prelude::Job as JobEntity;
|
||||
use crate::state::AppState;
|
||||
|
||||
const TICK: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Spawn the scheduler loop. Returns immediately; the loop runs for the life of
|
||||
/// the process.
|
||||
pub fn spawn_scheduler(state: AppState) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(TICK);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Err(e) = tick_once(&state).await {
|
||||
tracing::error!(error = %e, "scheduler tick failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn tick_once(state: &AppState) -> anyhow::Result<()> {
|
||||
for job in super::registry() {
|
||||
let Some(row) = JobEntity::find()
|
||||
.filter(job::Column::Name.eq(job.name()))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
else {
|
||||
// No row yet → never configured via the admin UI → not scheduled.
|
||||
continue;
|
||||
};
|
||||
if row.interval_hours <= 0 {
|
||||
continue;
|
||||
}
|
||||
let due = match row.last_run {
|
||||
None => true,
|
||||
Some(last) => {
|
||||
crate::datetime::now_micros() - last
|
||||
>= chrono::Duration::hours(row.interval_hours as i64)
|
||||
}
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
if state.running_jobs.lock().await.contains(job.name()) {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(
|
||||
job = job.name(),
|
||||
interval_hours = row.interval_hours,
|
||||
"scheduling due job"
|
||||
);
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move { super::run_job(&st, job).await });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Port of `seed_banners` — load/sync banner configs from `banners/*.json`.
|
||||
//!
|
||||
//! Parity with Django's `Banner.save()` invariant: at most one active banner,
|
||||
//! and if none is active the saved banner becomes active. New banners take their
|
||||
//! `settings` from JSON but schema stays empty on first create (Django only
|
||||
//! syncs `schema` on the update path); subsequent runs backfill it.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::entities::banner;
|
||||
use crate::entities::prelude::Banner;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::BackgroundJob;
|
||||
|
||||
pub struct SeedBanners;
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundJob for SeedBanners {
|
||||
fn name(&self) -> &'static str {
|
||||
"seed_banners"
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Seed Banner Configurations"
|
||||
}
|
||||
|
||||
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
|
||||
let db = &state.db;
|
||||
let dir = &state.config.banners_dir;
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
log.push(format!("Banners directory not found at {dir}"));
|
||||
return Ok(log.join("\n"));
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if let Err(e) = sync_one(db, &path, &mut log).await {
|
||||
log.push(format!("Failed to sync banner from {filename}: {e}"));
|
||||
}
|
||||
}
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_one(
|
||||
db: &DatabaseConnection,
|
||||
path: &std::path::Path,
|
||||
log: &mut Vec<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data: Value = serde_json::from_str(&content)?;
|
||||
|
||||
let Some(slug) = data.get("slug").and_then(|v| v.as_str()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let existing = Banner::find()
|
||||
.filter(banner::Column::Slug.eq(slug))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match existing {
|
||||
None => {
|
||||
let name = data
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(slug)
|
||||
.to_string();
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let settings = data.get("settings").cloned().unwrap_or_else(|| json!({}));
|
||||
// Mirror Banner.save(): become active when no other banner is active.
|
||||
let no_active = active_count(db, None).await? == 0;
|
||||
banner::ActiveModel {
|
||||
name: Set(name),
|
||||
slug: Set(slug.to_string()),
|
||||
description: Set(description),
|
||||
settings: Set(settings),
|
||||
schema: Set(json!([])),
|
||||
is_enabled: Set(true),
|
||||
is_active: Set(no_active),
|
||||
created_at: Set(crate::datetime::now_micros()),
|
||||
updated_at: Set(crate::datetime::now_micros()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db)
|
||||
.await?;
|
||||
log.push(format!("Created new banner '{slug}' from {filename}"));
|
||||
}
|
||||
Some(banner) => {
|
||||
let json_settings = data.get("settings").cloned().unwrap_or_else(|| json!({}));
|
||||
let json_schema = data.get("schema").cloned().unwrap_or_else(|| json!([]));
|
||||
|
||||
let mut db_settings = banner.settings.as_object().cloned().unwrap_or_default();
|
||||
let mut updated = false;
|
||||
|
||||
if let Some(obj) = json_settings.as_object() {
|
||||
for (k, v) in obj {
|
||||
if !db_settings.contains_key(k) {
|
||||
db_settings.insert(k.clone(), v.clone());
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let schema_changed = banner.schema != json_schema;
|
||||
if schema_changed {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
let pk = banner.id;
|
||||
let was_active = banner.is_active;
|
||||
let mut am: banner::ActiveModel = banner.into();
|
||||
am.settings = Set(Value::Object(db_settings));
|
||||
am.schema = Set(json_schema);
|
||||
am.updated_at = Set(crate::datetime::now_micros());
|
||||
// Banner.save() invariant on every save.
|
||||
if was_active {
|
||||
Banner::update_many()
|
||||
.col_expr(
|
||||
banner::Column::IsActive,
|
||||
sea_orm::sea_query::Expr::value(false),
|
||||
)
|
||||
.filter(banner::Column::IsActive.eq(true))
|
||||
.filter(banner::Column::Id.ne(pk))
|
||||
.exec(db)
|
||||
.await?;
|
||||
} else if active_count(db, Some(pk)).await? == 0 {
|
||||
am.is_active = Set(true);
|
||||
}
|
||||
am.update(db).await?;
|
||||
log.push(format!(
|
||||
"Synced new settings for banner '{slug}' from {filename}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count active banners, optionally excluding one pk.
|
||||
async fn active_count(db: &DatabaseConnection, exclude: Option<i32>) -> anyhow::Result<u64> {
|
||||
let mut q = Banner::find().filter(banner::Column::IsActive.eq(true));
|
||||
if let Some(pk) = exclude {
|
||||
q = q.filter(banner::Column::Id.ne(pk));
|
||||
}
|
||||
Ok(q.count(db).await?)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Port of `seed_legacy_flavors` — upsert retired ("legacy") flavors from the
|
||||
//! JSON dumps in the `legacy/` directory. Existing *active* (non-legacy) flavors
|
||||
//! are left untouched; legacy rows are created or refreshed and always marked
|
||||
//! unavailable.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::entities::prelude::{Category, Flavor};
|
||||
use crate::entities::{category, flavor};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::download::{download_image, ext_for, hash_for, http_client};
|
||||
use super::{slugify, BackgroundJob};
|
||||
|
||||
const PACK_KEYWORDS: &[&str] = &[
|
||||
"bundle", "set", "box", "probe", "sample", "taster", "shaker", "starter",
|
||||
];
|
||||
|
||||
pub struct SeedLegacy;
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundJob for SeedLegacy {
|
||||
fn name(&self) -> &'static str {
|
||||
"seed_legacy"
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Seed Legacy Data"
|
||||
}
|
||||
|
||||
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
|
||||
let db = &state.db;
|
||||
let media_root = &state.config.media_root;
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
|
||||
// Categories, matched by name (Django get_or_create(name=...)).
|
||||
let energy = goc_category(db, "Energy", "energy").await?;
|
||||
let hydration = goc_category(db, "Hydration", "hydration").await?;
|
||||
let iced_tea = goc_category(db, "Iced Tea", "iced-tea").await?;
|
||||
let milkshake = goc_category(db, "Milkshake", "milkshake").await?;
|
||||
let packs = goc_category(db, "Packs and other", "packs-and-other").await?;
|
||||
|
||||
let legacy_dir = &state.config.legacy_dir;
|
||||
if !Path::new(legacy_dir).exists() {
|
||||
log.push("Legacy directory not found.".to_string());
|
||||
return Ok(log.join("\n"));
|
||||
}
|
||||
|
||||
let client = http_client();
|
||||
let mut processed = 0usize;
|
||||
|
||||
for entry in fs::read_dir(legacy_dir)?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let fname = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
// Category from filename.
|
||||
let file_cat: Option<&category::Model> = if fname.contains("energy") {
|
||||
Some(&energy)
|
||||
} else if fname.contains("iced_tea") || fname.contains("iced-tea") {
|
||||
Some(&iced_tea)
|
||||
} else if fname.contains("hydration") {
|
||||
Some(&hydration)
|
||||
} else if fname.contains("milkshake") {
|
||||
Some(&milkshake)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let items: Value = serde_json::from_str(&content)?;
|
||||
let Some(arr) = items.as_array() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for data in arr {
|
||||
let Some(name) = data.get("Name").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let taste = pystr(data.get("Geschmack"));
|
||||
let desc = pystr(data.get("Beschreibung"));
|
||||
let status_text = pystr(data.get("Status"));
|
||||
let image_url = data.get("Bild_URL").and_then(|v| v.as_str());
|
||||
|
||||
let full_desc =
|
||||
format!("**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}");
|
||||
|
||||
// Final category: filename, else name/desc heuristics, else energy.
|
||||
let cat: &category::Model = file_cat.unwrap_or_else(|| {
|
||||
let nl = name.to_lowercase();
|
||||
let dl = desc.to_lowercase();
|
||||
if nl.contains("hydration") || dl.contains("hydration") {
|
||||
&hydration
|
||||
} else if nl.contains("iced tea") || nl.contains("eistee") {
|
||||
&iced_tea
|
||||
} else if nl.contains("milkshake") {
|
||||
&milkshake
|
||||
} else if PACK_KEYWORDS.iter().any(|k| nl.contains(k)) {
|
||||
&packs
|
||||
} else {
|
||||
&energy
|
||||
}
|
||||
});
|
||||
|
||||
// Existing flavor by case-insensitive name within the category.
|
||||
let existing = Flavor::find()
|
||||
.filter(
|
||||
Expr::expr(Func::lower(Expr::col(flavor::Column::Name)))
|
||||
.eq(name.to_lowercase()),
|
||||
)
|
||||
.filter(flavor::Column::CategoryId.eq(cat.id))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if let Some(ref f) = existing {
|
||||
if !f.is_legacy {
|
||||
log.push(format!("Skipping active flavor: {name}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve image first (so we can set local paths on insert).
|
||||
let mut local_paths: Option<Vec<String>> = None;
|
||||
if let Some(url) = image_url.filter(|s| !s.is_empty()) {
|
||||
let rel = format!(
|
||||
"flavors/legacy_{}/00_{}.{}",
|
||||
slugify(name),
|
||||
hash_for(url),
|
||||
ext_for(url)
|
||||
);
|
||||
let abs = Path::new(media_root).join(&rel);
|
||||
if download_image(&client, url, &abs).await {
|
||||
local_paths = Some(vec![rel]);
|
||||
log.push(format!(" -> Image for: {name}"));
|
||||
}
|
||||
}
|
||||
let image_urls = match image_url.filter(|s| !s.is_empty()) {
|
||||
Some(u) => json!([u]),
|
||||
None => json!([]),
|
||||
};
|
||||
|
||||
match existing {
|
||||
Some(f) => {
|
||||
log.push(format!("Updating legacy flavor: {name}"));
|
||||
let main_in_local = match (&f.main_image_path, &local_paths) {
|
||||
(Some(m), Some(lp)) => lp.contains(m),
|
||||
_ => true,
|
||||
};
|
||||
let mut am: flavor::ActiveModel = f.into();
|
||||
am.description = Set(full_desc);
|
||||
am.is_available = Set(false);
|
||||
if let Some(lp) = local_paths {
|
||||
am.local_image_paths = Set(Some(json!(lp)));
|
||||
if !main_in_local {
|
||||
am.main_image_path = Set(None);
|
||||
}
|
||||
}
|
||||
am.image_url = Set(image_url.map(String::from));
|
||||
am.image_urls = Set(Some(image_urls));
|
||||
am.update(db).await?;
|
||||
}
|
||||
None => {
|
||||
log.push(format!("Adding new legacy flavor: {name}"));
|
||||
flavor::ActiveModel {
|
||||
name: Set(name.to_string()),
|
||||
category_id: Set(cat.id),
|
||||
is_legacy: Set(true),
|
||||
external_id: Set(None),
|
||||
description: Set(full_desc),
|
||||
is_available: Set(false),
|
||||
local_image_paths: Set(local_paths.map(|lp| json!(lp))),
|
||||
image_url: Set(image_url.map(String::from)),
|
||||
image_urls: Set(Some(image_urls)),
|
||||
created_at: Set(crate::datetime::now_micros()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
log.push(format!("Finished! Processed {processed} legacy flavors."));
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Python `str(data.get(key))` semantics: missing/null becomes the literal
|
||||
/// "None" (the f-string interpolation the Django command relied on).
|
||||
fn pystr(v: Option<&Value>) -> String {
|
||||
match v.and_then(|x| x.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => "None".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn goc_category(
|
||||
db: &DatabaseConnection,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
) -> anyhow::Result<category::Model> {
|
||||
if let Some(c) = Category::find()
|
||||
.filter(category::Column::Name.eq(name))
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
return Ok(c);
|
||||
}
|
||||
Ok(category::ActiveModel {
|
||||
name: Set(name.to_string()),
|
||||
slug: Set(slug.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db)
|
||||
.await?)
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Port of `sync_flavors` — pull the Holy Energy Shopify catalog and upsert
|
||||
//! flavors, categories and product images. Mirrors the Django command's
|
||||
//! category resolution, the Syrup-bundle variant expansion special case, and the
|
||||
//! "mark missing products unavailable" pass. Data-format parity matters: both
|
||||
//! backends write the same shared SQLite + media files.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::entities::prelude::{Category, Flavor};
|
||||
use crate::entities::{category, flavor};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::download::{download_image, ext_for, hash_for, http_client};
|
||||
use super::{slugify, BackgroundJob};
|
||||
|
||||
const PRODUCTS_URL: &str = "https://weareholy.com/products.json?limit=250";
|
||||
const DOWNLOAD_WORKERS: usize = 8;
|
||||
const PACKS_SLUG: &str = "packs-and-other";
|
||||
const PACKS_NAME: &str = "Packs and other";
|
||||
|
||||
const FORM_WORDS: &[&str] = &[
|
||||
"bundle",
|
||||
"sachet",
|
||||
"sachetbox",
|
||||
"bottle",
|
||||
"box",
|
||||
"pack",
|
||||
"set",
|
||||
"sachets",
|
||||
];
|
||||
const NON_FLAVOR_TOKENS: &[&str] = &[
|
||||
"value",
|
||||
"shaker",
|
||||
"merch",
|
||||
"sticker",
|
||||
"packaging",
|
||||
"material",
|
||||
"other",
|
||||
"items",
|
||||
"mixed",
|
||||
"collector",
|
||||
"collector's",
|
||||
"free",
|
||||
"products",
|
||||
"naturalrabatt",
|
||||
"sample",
|
||||
"samplebox",
|
||||
];
|
||||
const PACK_TITLE_KEYWORDS: &[&str] = &[
|
||||
"bundle",
|
||||
"set",
|
||||
"box",
|
||||
"probe",
|
||||
"sample",
|
||||
"taster",
|
||||
"starter",
|
||||
"collection",
|
||||
"probier",
|
||||
];
|
||||
const ACCESSORY_TITLE_KEYWORDS: &[&str] = &["bürste", "reinigung", "ersatzteil", "zubehör"];
|
||||
const TAG_FAMILY: &[(&[&str], &str)] = &[
|
||||
(&["holy energy", "energy"], "Energy"),
|
||||
(&["holy hydration", "hydration"], "Hydration"),
|
||||
(&["holy iced tea", "iced tea"], "Iced Tea"),
|
||||
(&["milkshake"], "Milkshake"),
|
||||
];
|
||||
|
||||
pub struct SyncFlavors;
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundJob for SyncFlavors {
|
||||
fn name(&self) -> &'static str {
|
||||
"sync_flavors"
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Sync Holy Flavors"
|
||||
}
|
||||
|
||||
async fn run(&self, state: &AppState) -> anyhow::Result<String> {
|
||||
let db = &state.db;
|
||||
let media_root = &state.config.media_root;
|
||||
let client = http_client();
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
log.push(format!("Fetching from {PRODUCTS_URL}..."));
|
||||
|
||||
// Fetch failures are reported in the output but do not fail the job
|
||||
// (Django's command caught the error and returned).
|
||||
let data: Value = match client.get(PRODUCTS_URL).send().await {
|
||||
Ok(resp) => match resp.error_for_status() {
|
||||
Ok(resp) => match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log.push(format!("Failed to fetch data: {e}"));
|
||||
return Ok(log.join("\n"));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log.push(format!("Failed to fetch data: {e}"));
|
||||
return Ok(log.join("\n"));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log.push(format!("Failed to fetch data: {e}"));
|
||||
return Ok(log.join("\n"));
|
||||
}
|
||||
};
|
||||
|
||||
let products = data
|
||||
.get("products")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
log.push(format!("Found {} products.", products.len()));
|
||||
|
||||
let packs = goc_category_by_slug_named(db, PACKS_SLUG, PACKS_NAME).await?;
|
||||
|
||||
let mut created = 0usize;
|
||||
let mut updated = 0usize;
|
||||
let mut synced_ids: Vec<i64> = Vec::new();
|
||||
|
||||
for p in &products {
|
||||
let title = p
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let tags = parse_tags(p);
|
||||
let tags_lower: Vec<String> = tags.iter().map(|t| t.to_lowercase()).collect();
|
||||
let title_lower = title.to_lowercase();
|
||||
|
||||
// Syrup ships as one article whose variants are the flavors.
|
||||
if is_syrup_variant_product(p) {
|
||||
let (c, u) =
|
||||
sync_syrup_variants(db, &client, media_root, p, &mut synced_ids).await?;
|
||||
created += c;
|
||||
updated += u;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(cat) = resolve_category(db, p, &title_lower, &tags_lower, &packs).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let variants = p
|
||||
.get("variants")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let is_available = variants.iter().any(|v| {
|
||||
v.get("available")
|
||||
.and_then(|a| a.as_bool())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let images = p
|
||||
.get("images")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let image_url = images
|
||||
.first()
|
||||
.and_then(|i| i.get("src"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
let image_urls: Vec<String> = images
|
||||
.iter()
|
||||
.filter_map(|i| i.get("src").and_then(|s| s.as_str()).map(String::from))
|
||||
.collect();
|
||||
let description = clean_html(p.get("body_html").and_then(|v| v.as_str()).unwrap_or(""));
|
||||
let shop_url = p.get("handle").and_then(|v| v.as_str()).map(shop_url_for);
|
||||
let Some(pid) = p.get("id").and_then(|v| v.as_i64()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let was_created = upsert_flavor(
|
||||
db,
|
||||
&client,
|
||||
media_root,
|
||||
pid,
|
||||
&title,
|
||||
cat.id,
|
||||
&description,
|
||||
shop_url,
|
||||
is_available,
|
||||
image_url,
|
||||
&image_urls,
|
||||
&pid.to_string(),
|
||||
)
|
||||
.await?;
|
||||
if was_created {
|
||||
created += 1;
|
||||
} else {
|
||||
updated += 1;
|
||||
}
|
||||
synced_ids.push(pid);
|
||||
}
|
||||
|
||||
// Mark catalog flavors no longer present as unavailable.
|
||||
let discontinued = if synced_ids.is_empty() {
|
||||
0
|
||||
} else {
|
||||
Flavor::update_many()
|
||||
.col_expr(flavor::Column::IsAvailable, Expr::value(false))
|
||||
.filter(flavor::Column::ExternalId.is_not_null())
|
||||
.filter(flavor::Column::ExternalId.is_not_in(synced_ids.clone()))
|
||||
.exec(db)
|
||||
.await?
|
||||
.rows_affected
|
||||
};
|
||||
|
||||
log.push(format!(
|
||||
"Finished! Created: {created}, Updated: {updated}, Marked Unavailable: {discontinued}"
|
||||
));
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn shop_url_for(handle: &str) -> String {
|
||||
format!("https://weareholy.com/products/{handle}")
|
||||
}
|
||||
|
||||
/// Tags as a lowercase-able list; Shopify returns either an array or a CSV string.
|
||||
fn parse_tags(p: &Value) -> Vec<String> {
|
||||
match p.get("tags") {
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str().map(String::from))
|
||||
.collect(),
|
||||
Some(Value::String(s)) => s.split(',').map(|t| t.trim().to_string()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip HTML tags (Django's `clean_html`, regex `<.*?>`).
|
||||
fn clean_html(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut in_tag = false;
|
||||
for c in raw.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// Drink family from a Shopify `product_type` ("42 - Syrup Bottle" -> "Syrup"),
|
||||
/// or `None` for non-flavor types. Mirrors `_family_from_product_type`.
|
||||
fn family_from_product_type(product_type: &str) -> Option<String> {
|
||||
if product_type.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stripped = strip_type_prefix(product_type);
|
||||
let stripped = stripped.trim();
|
||||
if stripped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut tokens: Vec<&str> = stripped.split_whitespace().collect();
|
||||
if tokens
|
||||
.iter()
|
||||
.any(|t| NON_FLAVOR_TOKENS.contains(&t.to_lowercase().as_str()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
while tokens
|
||||
.last()
|
||||
.is_some_and(|t| FORM_WORDS.contains(&t.to_lowercase().as_str()))
|
||||
{
|
||||
tokens.pop();
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(tokens.join(" "))
|
||||
}
|
||||
|
||||
/// Remove a leading `\d+\s*[-–—]\s*` numeric prefix; unchanged if it doesn't match.
|
||||
fn strip_type_prefix(s: &str) -> String {
|
||||
let t = s.trim_start();
|
||||
let after_digits = t.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
if after_digits.len() == t.len() {
|
||||
return s.to_string(); // no leading digits → no match
|
||||
}
|
||||
let after_ws = after_digits.trim_start();
|
||||
let mut chars = after_ws.chars();
|
||||
match chars.next() {
|
||||
Some('-') | Some('–') | Some('—') => chars.as_str().trim_start().to_string(),
|
||||
_ => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pack(title_lower: &str, tags_lower: &[String]) -> bool {
|
||||
if PACK_TITLE_KEYWORDS.iter().any(|k| title_lower.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
title_lower.contains("shaker") || tags_lower.iter().any(|t| t == "merch")
|
||||
}
|
||||
|
||||
fn is_syrup_variant_product(p: &Value) -> bool {
|
||||
if family_from_product_type(p.get("product_type").and_then(|v| v.as_str()).unwrap_or(""))
|
||||
.as_deref()
|
||||
!= Some("Syrup")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let has_flavor_option = p
|
||||
.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|opts| {
|
||||
opts.iter().any(|o| {
|
||||
o.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
== "flavor"
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let variant_count = p
|
||||
.get("variants")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
has_flavor_option && variant_count > 1
|
||||
}
|
||||
|
||||
/// Pick a category for a product, or `None` to skip. Mirrors `resolve_category`.
|
||||
async fn resolve_category(
|
||||
db: &DatabaseConnection,
|
||||
p: &Value,
|
||||
title_lower: &str,
|
||||
tags_lower: &[String],
|
||||
packs: &category::Model,
|
||||
) -> anyhow::Result<Option<category::Model>> {
|
||||
if is_pack(title_lower, tags_lower) {
|
||||
return Ok(Some(packs.clone()));
|
||||
}
|
||||
if ACCESSORY_TITLE_KEYWORDS
|
||||
.iter()
|
||||
.any(|k| title_lower.contains(k))
|
||||
{
|
||||
return Ok(Some(packs.clone()));
|
||||
}
|
||||
if let Some(family) =
|
||||
family_from_product_type(p.get("product_type").and_then(|v| v.as_str()).unwrap_or(""))
|
||||
{
|
||||
return Ok(Some(category_for_family(db, &family).await?));
|
||||
}
|
||||
for (keywords, name) in TAG_FAMILY {
|
||||
if keywords.iter().any(|k| tags_lower.iter().any(|t| t == k)) {
|
||||
return Ok(Some(category_for_family(db, name).await?));
|
||||
}
|
||||
}
|
||||
if tags_lower.iter().any(|t| t == "shaker") {
|
||||
return Ok(Some(packs.clone()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// get_or_create a category from a family name, keyed by `slugify(name)`.
|
||||
async fn category_for_family(
|
||||
db: &DatabaseConnection,
|
||||
name: &str,
|
||||
) -> anyhow::Result<category::Model> {
|
||||
goc_category_by_slug_named(db, &slugify(name), name).await
|
||||
}
|
||||
|
||||
async fn goc_category_by_slug_named(
|
||||
db: &DatabaseConnection,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
) -> anyhow::Result<category::Model> {
|
||||
if let Some(c) = Category::find()
|
||||
.filter(category::Column::Slug.eq(slug))
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
return Ok(c);
|
||||
}
|
||||
Ok(category::ActiveModel {
|
||||
name: Set(name.to_string()),
|
||||
slug: Set(slug.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Expand the syrup bundle's variants into individual Syrup flavors keyed by
|
||||
/// variant id. Mirrors `_sync_syrup_variants`.
|
||||
async fn sync_syrup_variants(
|
||||
db: &DatabaseConnection,
|
||||
client: &reqwest::Client,
|
||||
media_root: &str,
|
||||
p: &Value,
|
||||
synced_ids: &mut Vec<i64>,
|
||||
) -> anyhow::Result<(usize, usize)> {
|
||||
let cat = category_for_family(db, "Syrup").await?;
|
||||
let description = clean_html(p.get("body_html").and_then(|v| v.as_str()).unwrap_or(""));
|
||||
let shop_url = p.get("handle").and_then(|v| v.as_str()).map(shop_url_for);
|
||||
|
||||
let mut created = 0usize;
|
||||
let mut updated = 0usize;
|
||||
let variants = p
|
||||
.get("variants")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for v in &variants {
|
||||
let name = v
|
||||
.get("option1")
|
||||
.and_then(|x| x.as_str())
|
||||
.or_else(|| v.get("title").and_then(|x| x.as_str()))
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let Some(variant_id) = v.get("id").and_then(|x| x.as_i64()) else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let image_url = v
|
||||
.get("featured_image")
|
||||
.and_then(|f| f.get("src"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
let image_urls: Vec<String> = image_url.clone().into_iter().collect();
|
||||
let available = v
|
||||
.get("available")
|
||||
.and_then(|a| a.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let was_created = upsert_flavor(
|
||||
db,
|
||||
client,
|
||||
media_root,
|
||||
variant_id,
|
||||
&name,
|
||||
cat.id,
|
||||
&description,
|
||||
shop_url.clone(),
|
||||
available,
|
||||
image_url,
|
||||
&image_urls,
|
||||
&variant_id.to_string(),
|
||||
)
|
||||
.await?;
|
||||
if was_created {
|
||||
created += 1;
|
||||
} else {
|
||||
updated += 1;
|
||||
}
|
||||
synced_ids.push(variant_id);
|
||||
}
|
||||
Ok((created, updated))
|
||||
}
|
||||
|
||||
/// Find-or-create one flavor row and refresh its fields. Returns `true` if
|
||||
/// created. Match order: external_id → exact name in category → case-insensitive
|
||||
/// name in category. Mirrors `_upsert_flavor`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upsert_flavor(
|
||||
db: &DatabaseConnection,
|
||||
client: &reqwest::Client,
|
||||
media_root: &str,
|
||||
external_id: i64,
|
||||
name: &str,
|
||||
category_id: i32,
|
||||
description: &str,
|
||||
shop_url: Option<String>,
|
||||
is_available: bool,
|
||||
image_url: Option<String>,
|
||||
image_urls: &[String],
|
||||
dir_slug: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut existing = Flavor::find()
|
||||
.filter(flavor::Column::ExternalId.eq(external_id))
|
||||
.one(db)
|
||||
.await?;
|
||||
if existing.is_none() {
|
||||
existing = Flavor::find()
|
||||
.filter(flavor::Column::Name.eq(name))
|
||||
.filter(flavor::Column::CategoryId.eq(category_id))
|
||||
.one(db)
|
||||
.await?;
|
||||
}
|
||||
if existing.is_none() {
|
||||
existing = Flavor::find()
|
||||
.filter(
|
||||
Expr::expr(Func::lower(Expr::col(flavor::Column::Name))).eq(name.to_lowercase()),
|
||||
)
|
||||
.filter(flavor::Column::CategoryId.eq(category_id))
|
||||
.one(db)
|
||||
.await?;
|
||||
}
|
||||
let created = existing.is_none();
|
||||
|
||||
let local_paths = download_gallery(client, media_root, image_urls, dir_slug).await;
|
||||
|
||||
let (mut am, cur_main, cur_local_empty): (flavor::ActiveModel, Option<String>, bool) =
|
||||
match existing {
|
||||
Some(f) => {
|
||||
let cur_main = f.main_image_path.clone();
|
||||
let cur_local_empty = json_str_list(&f.local_image_paths).is_empty();
|
||||
let f_id = f.id;
|
||||
let f_name = f.name.clone();
|
||||
let f_ext_none = f.external_id.is_none();
|
||||
let f_is_legacy = f.is_legacy;
|
||||
let mut am: flavor::ActiveModel = f.into();
|
||||
if f_ext_none {
|
||||
am.external_id = Set(Some(external_id));
|
||||
}
|
||||
if f_is_legacy {
|
||||
am.is_legacy = Set(false);
|
||||
}
|
||||
if f_name != name {
|
||||
let dup = Flavor::find()
|
||||
.filter(flavor::Column::Name.eq(name))
|
||||
.filter(flavor::Column::CategoryId.eq(category_id))
|
||||
.filter(flavor::Column::Id.ne(f_id))
|
||||
.count(db)
|
||||
.await?
|
||||
> 0;
|
||||
if !dup {
|
||||
am.name = Set(name.to_string());
|
||||
}
|
||||
}
|
||||
(am, cur_main, cur_local_empty)
|
||||
}
|
||||
None => {
|
||||
let am = flavor::ActiveModel {
|
||||
external_id: Set(Some(external_id)),
|
||||
name: Set(name.to_string()),
|
||||
category_id: Set(category_id),
|
||||
is_legacy: Set(false),
|
||||
created_at: Set(crate::datetime::now_micros()),
|
||||
..Default::default()
|
||||
};
|
||||
(am, None, true)
|
||||
}
|
||||
};
|
||||
|
||||
am.description = Set(description.to_string());
|
||||
am.shop_url = Set(shop_url);
|
||||
am.is_available = Set(is_available);
|
||||
|
||||
if !local_paths.is_empty() {
|
||||
am.local_image_paths = Set(Some(json!(local_paths)));
|
||||
if let Some(m) = cur_main {
|
||||
if !local_paths.contains(&m) {
|
||||
am.main_image_path = Set(None);
|
||||
}
|
||||
}
|
||||
} else if cur_local_empty {
|
||||
am.main_image_path = Set(None);
|
||||
}
|
||||
am.image_url = Set(image_url);
|
||||
am.image_urls = Set(Some(json!(image_urls)));
|
||||
|
||||
if created {
|
||||
am.insert(db).await?;
|
||||
} else {
|
||||
am.update(db).await?;
|
||||
}
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// Download every URL into `flavors/<dir_slug>/<NN>_<hash>.<ext>`, in parallel,
|
||||
/// returning the saved rel paths in input order. Mirrors `download_gallery`.
|
||||
async fn download_gallery(
|
||||
client: &reqwest::Client,
|
||||
media_root: &str,
|
||||
image_urls: &[String],
|
||||
dir_slug: &str,
|
||||
) -> Vec<String> {
|
||||
let targets: Vec<(usize, String, String)> = image_urls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, u)| !u.is_empty())
|
||||
.map(|(i, u)| {
|
||||
let rel = format!("flavors/{dir_slug}/{i:02}_{}.{}", hash_for(u), ext_for(u));
|
||||
(i, u.clone(), rel)
|
||||
})
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let succeeded: HashMap<usize, String> =
|
||||
futures::stream::iter(targets.clone().into_iter().map(|(i, u, rel)| {
|
||||
let client = client.clone();
|
||||
let media_root = media_root.to_string();
|
||||
async move {
|
||||
let abs = Path::new(&media_root).join(&rel);
|
||||
let ok = download_image(&client, &u, &abs).await;
|
||||
(i, ok, rel)
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(DOWNLOAD_WORKERS)
|
||||
.filter_map(|(i, ok, rel)| async move {
|
||||
if ok {
|
||||
Some((i, rel))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.filter_map(|(i, _, _)| succeeded.get(i).cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Django JSONField (list) stored as TEXT → Vec<String>.
|
||||
fn json_str_list(v: &Option<Value>) -> Vec<String> {
|
||||
match v {
|
||||
Some(Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
mod auth;
|
||||
mod avatar;
|
||||
mod blacklist;
|
||||
mod config;
|
||||
mod cookies;
|
||||
mod datetime;
|
||||
mod db;
|
||||
mod dto;
|
||||
mod email;
|
||||
mod entities;
|
||||
mod error;
|
||||
mod jobs;
|
||||
mod media;
|
||||
mod mentions;
|
||||
mod merge;
|
||||
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 by ENABLE_SCHEDULER — exactly one process may own `api_job`, so
|
||||
// enable the scheduler on at most one running instance.
|
||||
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)");
|
||||
}
|
||||
|
||||
// Periodically sweep aged-out rate-limit / code buckets so the in-memory
|
||||
// maps stay bounded.
|
||||
{
|
||||
let security = state.security.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
security.gc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lock CORS to the configured frontend origin(s) (mirrors Django's
|
||||
// CORS_ALLOWED_ORIGINS) rather than reflecting any origin — credentialed CORS
|
||||
// can't use a wildcard, and reflecting arbitrary origins is a foot-gun if the
|
||||
// SameSite cookie policy ever loosens. Behind the strangler proxy the app is
|
||||
// same-origin anyway; this matters for direct browser access.
|
||||
let allowed_origins: Vec<axum::http::HeaderValue> = state
|
||||
.config
|
||||
.cors_allowed_origins
|
||||
.iter()
|
||||
.filter_map(|o| match o.parse() {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
tracing::warn!("ignoring invalid CORS origin: {o}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(allowed_origins))
|
||||
.allow_credentials(true)
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
// Request headers (Content-Type, Authorization, …) are still mirrored;
|
||||
// only the origin is restricted. Mirroring headers under credentials is
|
||||
// safe — the origin check is the security boundary.
|
||||
.allow_headers(AllowHeaders::mirror_request());
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health/", get(health))
|
||||
.nest("/api", routes::api_router())
|
||||
.merge(SwaggerUi::new("/api/schema/swagger-ui").url("/api/schema/", ApiDoc::openapi()))
|
||||
.layer(cors)
|
||||
// Static security headers DRF/Django set via middleware. `if_not_present`
|
||||
// so the nginx front (which may own HSTS in prod) can still override.
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
X_FRAME_OPTIONS,
|
||||
HeaderValue::from_static("DENY"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
REFERRER_POLICY,
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
))
|
||||
// Cap request bodies (avatar upload self-limits to 2 MB; this guards every
|
||||
// other endpoint against oversized/slow-loris bodies). Set above the
|
||||
// avatar limit so the handler's own 2 MB check stays authoritative.
|
||||
.layer(DefaultBodyLimit::max(8 * 1024 * 1024))
|
||||
// Whole-request deadline so a stalled client can't pin a worker forever.
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(30),
|
||||
))
|
||||
// Turn a handler panic into a 500 instead of dropping the connection.
|
||||
.layer(CatchPanicLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
|
||||
tracing::info!("listening on http://{bind_addr}");
|
||||
// ConnectInfo gives handlers the peer address as a REMOTE_ADDR fallback for
|
||||
// IP logging when no X-Forwarded-For header is present.
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liveness + readiness: the Docker healthcheck gates worker/beat/frontend on
|
||||
/// this, so it must touch the DB — a backend that can't reach SQLite is not
|
||||
/// healthy even if the process is up.
|
||||
async fn health(State(state): State<AppState>) -> Response {
|
||||
match state
|
||||
.db
|
||||
.execute(Statement::from_string(DatabaseBackend::Sqlite, "SELECT 1"))
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(json!({ "status": "ok" })).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("health check failed: database unreachable: {e}");
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "status": "error", "detail": "database unavailable" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve when the process receives Ctrl+C or (on Unix) SIGTERM — Docker sends
|
||||
/// SIGTERM on `stop`. Lets axum drain in-flight requests before exiting so a
|
||||
/// running job's lifecycle row isn't abandoned mid-write.
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("shutdown signal received; draining in-flight requests");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Image-URL resolution mirroring `api/serializers/_helpers.py` +
|
||||
//! `FlavorSerializer.get_image_url/get_image_urls`.
|
||||
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
/// `absolute_media_url`: prefix a stored relative media path with MEDIA_URL and
|
||||
/// make it absolute against the request.
|
||||
pub fn media_url(ctx: &RequestCtx, media_prefix: &str, rel: &str) -> String {
|
||||
let prefix = format!("/{}/", media_prefix.trim_matches('/'));
|
||||
let path = format!("{}{}", prefix, rel.trim_start_matches('/'));
|
||||
ctx.absolute(&path)
|
||||
}
|
||||
|
||||
/// `absolute_image_url`: an ImageField stored as a relative path under MEDIA_URL.
|
||||
pub fn image_field_url(ctx: &RequestCtx, media_prefix: &str, image: &str) -> String {
|
||||
media_url(ctx, media_prefix, image)
|
||||
}
|
||||
|
||||
/// Replicates `FlavorSerializer.get_image_url`.
|
||||
pub fn flavor_primary_image(
|
||||
ctx: &RequestCtx,
|
||||
media_prefix: &str,
|
||||
main_image_path: Option<&str>,
|
||||
local_image_paths: &[String],
|
||||
image: Option<&str>,
|
||||
image_url: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if let Some(p) = main_image_path.filter(|s| !s.is_empty()) {
|
||||
return Some(media_url(ctx, media_prefix, p));
|
||||
}
|
||||
if let Some(first) = local_image_paths.first() {
|
||||
return Some(media_url(ctx, media_prefix, first));
|
||||
}
|
||||
if let Some(img) = image.filter(|s| !s.is_empty()) {
|
||||
return Some(image_field_url(ctx, media_prefix, img));
|
||||
}
|
||||
image_url.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Replicates `FlavorSerializer.get_image_urls`.
|
||||
pub fn flavor_image_list(
|
||||
ctx: &RequestCtx,
|
||||
media_prefix: &str,
|
||||
local_image_paths: &[String],
|
||||
image: Option<&str>,
|
||||
image_urls: &[String],
|
||||
) -> Vec<String> {
|
||||
if !local_image_paths.is_empty() {
|
||||
return local_image_paths
|
||||
.iter()
|
||||
.map(|p| media_url(ctx, media_prefix, p))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Some(img) = image.filter(|s| !s.is_empty()) {
|
||||
out.push(image_field_url(ctx, media_prefix, img));
|
||||
}
|
||||
out.extend(image_urls.iter().cloned());
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Port of `api/services/mentions.py`: scan text for `@username`, resolve each
|
||||
//! to a user (case-insensitive), and create a `mention` notification with
|
||||
//! get_or_create semantics (no duplicate rows for the same target).
|
||||
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::{
|
||||
ActiveValue::{NotSet, Set},
|
||||
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
};
|
||||
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{notification, user};
|
||||
use crate::error::ApiResult;
|
||||
|
||||
/// Extract distinct `@word` handles. `\w` in Django = `[A-Za-z0-9_]`.
|
||||
fn extract_handles(text: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'@' {
|
||||
let start = i + 1;
|
||||
let mut j = start;
|
||||
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
|
||||
j += 1;
|
||||
}
|
||||
if j > start {
|
||||
let handle = &text[start..j];
|
||||
if !out.iter().any(|h| h == handle) {
|
||||
out.push(handle.to_string());
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `col IS value` — equality for `Some`, IS NULL for `None` (Django's lookup
|
||||
/// translation for nullable FKs in get_or_create).
|
||||
fn eq_or_null(col: notification::Column, value: Option<i32>) -> Condition {
|
||||
match value {
|
||||
Some(v) => Condition::all().add(col.eq(v)),
|
||||
None => Condition::all().add(col.is_null()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_mentions(
|
||||
db: &DatabaseConnection,
|
||||
text: &str,
|
||||
actor_id: i32,
|
||||
rating_id: Option<i32>,
|
||||
reply_id: Option<i32>,
|
||||
) -> ApiResult<()> {
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for handle in extract_handles(text) {
|
||||
// username__iexact
|
||||
let recipient = User::find()
|
||||
.filter(
|
||||
Expr::expr(Func::lower(Expr::col(user::Column::Username)))
|
||||
.eq(handle.to_lowercase()),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
let Some(recipient) = recipient else { continue };
|
||||
if recipient.id == actor_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// get_or_create(recipient, actor, type="mention", rating, reply)
|
||||
let exists = Notification::find()
|
||||
.filter(notification::Column::RecipientId.eq(recipient.id))
|
||||
.filter(notification::Column::ActorId.eq(actor_id))
|
||||
.filter(notification::Column::NotificationType.eq("mention"))
|
||||
.filter(eq_or_null(notification::Column::RatingId, rating_id))
|
||||
.filter(eq_or_null(notification::Column::ReplyId, reply_id))
|
||||
.one(db)
|
||||
.await?;
|
||||
if exists.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = crate::datetime::now_micros();
|
||||
let row = notification::ActiveModel {
|
||||
id: NotSet,
|
||||
is_read: Set(false),
|
||||
created_at: Set(now),
|
||||
actor_id: Set(actor_id),
|
||||
rating_id: Set(rating_id),
|
||||
recipient_id: Set(recipient.id),
|
||||
reply_id: Set(reply_id),
|
||||
ticket_id: Set(None),
|
||||
notification_type: Set("mention".to_string()),
|
||||
profile_comment_id: Set(None),
|
||||
};
|
||||
Notification::insert(row).exec(db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Port of `api/services/flavor.py::merge_flavors`. Merges one flavor row into
|
||||
//! another, re-homing ratings and preserving rating integrity. Used by both the
|
||||
//! admin `merge_flavors` endpoint and the `cleanup_duplicates` job.
|
||||
|
||||
use sea_orm::sea_query::Expr;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter,
|
||||
TransactionTrait,
|
||||
};
|
||||
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{flavor, notification, rating, reply};
|
||||
|
||||
/// Rank a rating for conflict resolution: longest comment wins, newer breaks
|
||||
/// ties (mirrors Python's tuple comparison `(len(comment), created_at)`).
|
||||
fn quality(r: &rating::Model) -> (usize, chrono::NaiveDateTime) {
|
||||
(
|
||||
r.comment.as_deref().unwrap_or("").chars().count(),
|
||||
r.created_at,
|
||||
)
|
||||
}
|
||||
|
||||
/// Merge `remove` into `keep`. Re-assigns all of `remove`'s ratings; when a user
|
||||
/// rated both flavors the higher-quality review survives and its replies +
|
||||
/// notifications are re-pointed onto the surviving row. Runs in one transaction.
|
||||
pub async fn merge_flavors(
|
||||
db: &DatabaseConnection,
|
||||
keep: &flavor::Model,
|
||||
remove: &flavor::Model,
|
||||
) -> Result<(), DbErr> {
|
||||
if keep.id == remove.id {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let incomings = Rating::find()
|
||||
.filter(rating::Column::FlavorId.eq(remove.id))
|
||||
.all(&txn)
|
||||
.await?;
|
||||
|
||||
for incoming in incomings {
|
||||
let existing = Rating::find()
|
||||
.filter(rating::Column::UserId.eq(incoming.user_id))
|
||||
.filter(rating::Column::FlavorId.eq(keep.id))
|
||||
.one(&txn)
|
||||
.await?;
|
||||
|
||||
let Some(existing) = existing else {
|
||||
// No conflict — move the rating wholesale.
|
||||
let mut am: rating::ActiveModel = incoming.into();
|
||||
am.flavor_id = Set(keep.id);
|
||||
am.update(&txn).await?;
|
||||
continue;
|
||||
};
|
||||
|
||||
// Conflict: keep one row (unique user+flavor). If the incoming review is
|
||||
// better, copy its content + timestamp onto the surviving row.
|
||||
if quality(&incoming) > quality(&existing) {
|
||||
let mut am: rating::ActiveModel = existing.clone().into();
|
||||
am.score = Set(incoming.score);
|
||||
am.comment = Set(incoming.comment.clone());
|
||||
am.created_at = Set(incoming.created_at);
|
||||
am.update(&txn).await?;
|
||||
}
|
||||
|
||||
// Preserve community feedback attached to the discarded rating.
|
||||
Reply::update_many()
|
||||
.col_expr(reply::Column::RatingId, Expr::value(existing.id))
|
||||
.filter(reply::Column::RatingId.eq(incoming.id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
Notification::update_many()
|
||||
.col_expr(notification::Column::RatingId, Expr::value(existing.id))
|
||||
.filter(notification::Column::RatingId.eq(incoming.id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
Rating::delete_by_id(incoming.id).exec(&txn).await?;
|
||||
}
|
||||
|
||||
// Carry over the Shopify link if the kept flavor lacks one. external_id is
|
||||
// unique, so the donor row must be gone before we assign it.
|
||||
let inherited = if keep.external_id.is_none() {
|
||||
remove.external_id
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Flavor::delete_by_id(remove.id).exec(&txn).await?;
|
||||
|
||||
if let Some(eid) = inherited {
|
||||
let mut am: flavor::ActiveModel = keep.clone().into();
|
||||
am.external_id = Set(Some(eid));
|
||||
am.update(&txn).await?;
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::dto::{BannerOut, CategoryOut, FlavorOut, RatingOut, ReplyOut, SearchHit};
|
||||
|
||||
/// OpenAPI document. Component schemas are registered; per-path operation specs
|
||||
/// will be added with `#[utoipa::path]` as handlers stabilize.
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Holy Flavor Rater (Rust)",
|
||||
description = "Strangler-migration Rust backend. Read endpoints ported first; the rest remain on Django.",
|
||||
version = "0.1.0"
|
||||
),
|
||||
components(schemas(CategoryOut, FlavorOut, RatingOut, ReplyOut, BannerOut, SearchHit))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
@@ -0,0 +1,69 @@
|
||||
//! DRF `PageNumberPagination` compatibility: `{ count, next, previous, results }`
|
||||
//! with `page` query param and a fixed page size (DRF default 50; the flavor
|
||||
//! list uses that default).
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub const DEFAULT_PAGE_SIZE: u64 = 50;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Paginated<T: Serialize> {
|
||||
pub count: u64,
|
||||
pub next: Option<String>,
|
||||
pub previous: Option<String>,
|
||||
pub results: Vec<T>,
|
||||
}
|
||||
|
||||
/// Parse the `page` query value (DRF is 1-indexed; defaults to 1).
|
||||
pub fn parse_page(raw: Option<&str>) -> u64 {
|
||||
raw.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn with_page(ctx: &RequestCtx, page: u64) -> String {
|
||||
// Split off existing query, drop any prior `page=`, then re-add.
|
||||
let (path, query) = match ctx.full_path.split_once('?') {
|
||||
Some((p, q)) => (p, q),
|
||||
None => (ctx.full_path.as_str(), ""),
|
||||
};
|
||||
let mut pairs: Vec<String> = query
|
||||
.split('&')
|
||||
.filter(|kv| !kv.is_empty() && !kv.starts_with("page="))
|
||||
.map(|kv| kv.to_string())
|
||||
.collect();
|
||||
if page > 1 {
|
||||
pairs.push(format!("page={page}"));
|
||||
}
|
||||
let q = pairs.join("&");
|
||||
let rel = if q.is_empty() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{path}?{q}")
|
||||
};
|
||||
ctx.absolute(&rel)
|
||||
}
|
||||
|
||||
impl<T: Serialize> Paginated<T> {
|
||||
pub fn build(ctx: &RequestCtx, results: Vec<T>, count: u64, page: u64, page_size: u64) -> Self {
|
||||
let total_pages = count.div_ceil(page_size.max(1));
|
||||
let next = if page < total_pages {
|
||||
Some(with_page(ctx, page + 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let previous = if page > 1 {
|
||||
Some(with_page(ctx, page - 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Paginated {
|
||||
count,
|
||||
next,
|
||||
previous,
|
||||
results,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Django-compatible password verification.
|
||||
//!
|
||||
//! Django's default hasher stores `pbkdf2_sha256$<iterations>$<salt>$<b64hash>`,
|
||||
//! where the hash is `base64(pbkdf2_hmac_sha256(password, salt, iterations))`
|
||||
//! with the derived key length equal to the SHA-256 digest size (32 bytes).
|
||||
//! Salt is used as raw UTF-8 bytes. We re-derive and compare in constant time,
|
||||
//! matching Django's `check_password`.
|
||||
//!
|
||||
//! Only `pbkdf2_sha256` is supported (the project's hasher). Other algorithms
|
||||
//! (argon2/bcrypt) would need their own branch — login would fall through to a
|
||||
//! rejection for those, so keep issuance on Django if the hasher ever changes.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
const ALGORITHM: &str = "pbkdf2_sha256";
|
||||
const DK_LEN: usize = 32; // SHA-256 digest size
|
||||
/// Matches Django's current `PBKDF2PasswordHasher.iterations`. Django reads the
|
||||
/// iteration count back out of the encoded hash, so any value it supports
|
||||
/// verifies — we use its default so it never needs to silently re-hash on login.
|
||||
const ITERATIONS: u32 = 1_200_000;
|
||||
|
||||
/// Verify a plaintext password against a Django password hash string.
|
||||
pub fn verify_password(password: &str, encoded: &str) -> bool {
|
||||
let mut parts = encoded.splitn(4, '$');
|
||||
let (Some(algo), Some(iter_s), Some(salt), Some(b64hash)) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if algo != ALGORITHM {
|
||||
return false;
|
||||
}
|
||||
let Ok(iterations) = iter_s.parse::<u32>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expected) = B64.decode(b64hash) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut derived = [0u8; DK_LEN];
|
||||
// pbkdf2_hmac writes the derived key; rounds == 0 is invalid for Django hashes.
|
||||
if iterations == 0 {
|
||||
return false;
|
||||
}
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt.as_bytes(),
|
||||
iterations,
|
||||
&mut derived,
|
||||
);
|
||||
|
||||
// Constant-time compare, length-checked first.
|
||||
if expected.len() != derived.len() {
|
||||
return false;
|
||||
}
|
||||
derived.ct_eq(expected.as_slice()).into()
|
||||
}
|
||||
|
||||
/// Hash a plaintext password into Django's `pbkdf2_sha256$iter$salt$b64` format,
|
||||
/// matching `make_password`. The salt is a random hex string (cryptographically
|
||||
/// random via uuid v4); Django accepts any salt that contains no `$`.
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let salt = uuid::Uuid::new_v4().simple().to_string();
|
||||
let mut derived = [0u8; DK_LEN];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt.as_bytes(),
|
||||
ITERATIONS,
|
||||
&mut derived,
|
||||
);
|
||||
let b64 = B64.encode(derived);
|
||||
format!("{ALGORITHM}${ITERATIONS}${salt}${b64}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Generated by Python `hashlib.pbkdf2_hmac` (the exact routine Django's
|
||||
// PBKDF2PasswordHasher calls) and confirmed to verify under Django's
|
||||
// `check_password`. Proves cross-backend hash compatibility.
|
||||
const DJANGO_HASH: &str =
|
||||
"pbkdf2_sha256$100000$abcdefghijklmnop$M4PTvkbfZw/Dmbx4RsTgMD5Oo8Mh7BdYlHcR+JM6rT0=";
|
||||
const DJANGO_PLAINTEXT: &str = "correct horse battery staple";
|
||||
|
||||
#[test]
|
||||
fn verifies_known_django_hash() {
|
||||
assert!(verify_password(DJANGO_PLAINTEXT, DJANGO_HASH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_password_for_django_hash() {
|
||||
assert!(!verify_password("wrong password", DJANGO_HASH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_then_verify_round_trips() {
|
||||
let encoded = hash_password("hunter2-correct");
|
||||
assert!(encoded.starts_with("pbkdf2_sha256$1200000$"));
|
||||
assert!(verify_password("hunter2-correct", &encoded));
|
||||
assert!(!verify_password("hunter3", &encoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_and_unsupported() {
|
||||
assert!(!verify_password("x", "not-a-hash"));
|
||||
assert!(!verify_password("x", "bcrypt$12$whatever"));
|
||||
assert!(!verify_password("x", "pbkdf2_sha256$0$salt$AAAA"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Django `AUTH_PASSWORD_VALIDATORS` ported faithfully.
|
||||
//!
|
||||
//! Django *configures* the four default validators
|
||||
//! (`UserAttributeSimilarityValidator`, `MinimumLengthValidator`,
|
||||
//! `CommonPasswordValidator`, `NumericPasswordValidator`) with default options,
|
||||
//! but this project's DRF views never actually call `validate_password`, so the
|
||||
//! live Django backend accepts any non-empty password at signup and anything at
|
||||
//! all on change/reset. Enforcing the validators here is therefore a deliberate
|
||||
//! hardening step that *diverges* from the current Django behaviour (chosen by
|
||||
//! the maintainer): a password Django would accept may be rejected by Rust.
|
||||
//!
|
||||
//! [`validate`] aggregates every failing validator's message in the same order
|
||||
//! Django's settings list them, mirroring `validate_password`'s behaviour of
|
||||
//! raising a `ValidationError` containing all errors.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const MIN_LENGTH: usize = 8;
|
||||
const MAX_SIMILARITY: f64 = 0.7;
|
||||
|
||||
/// User attributes the similarity validator compares against. Empty values are
|
||||
/// skipped (mirrors Django ignoring falsy attributes). The verbose names match
|
||||
/// Django's `AbstractUser` field `verbose_name`s so error text is identical.
|
||||
pub struct UserAttrs<'a> {
|
||||
pub username: &'a str,
|
||||
pub first_name: &'a str,
|
||||
pub last_name: &'a str,
|
||||
pub email: &'a str,
|
||||
}
|
||||
|
||||
/// Validate `password` against all four validators. Returns the list of
|
||||
/// human-readable error messages; empty means the password is acceptable.
|
||||
pub fn validate(password: &str, attrs: &UserAttrs) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
// Order matches settings.AUTH_PASSWORD_VALIDATORS.
|
||||
if let Err(e) = too_similar(password, attrs) {
|
||||
errors.push(e);
|
||||
}
|
||||
if let Err(e) = min_length(password) {
|
||||
errors.push(e);
|
||||
}
|
||||
if let Err(e) = too_common(password) {
|
||||
errors.push(e);
|
||||
}
|
||||
if let Err(e) = entirely_numeric(password) {
|
||||
errors.push(e);
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// `MinimumLengthValidator(min_length=8)`. Length is counted in characters, like
|
||||
/// Python's `len()` over a `str`.
|
||||
fn min_length(password: &str) -> Result<(), String> {
|
||||
if password.chars().count() < MIN_LENGTH {
|
||||
Err(format!(
|
||||
"This password is too short. It must contain at least {MIN_LENGTH} characters."
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `NumericPasswordValidator`. Mirrors Python `str.isdigit()`, which is false for
|
||||
/// the empty string and true only when every character is a digit.
|
||||
fn entirely_numeric(password: &str) -> Result<(), String> {
|
||||
if !password.is_empty() && password.chars().all(|c| c.is_ascii_digit()) {
|
||||
Err("This password is entirely numeric.".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommonPasswordValidator`. Django compares `password.lower().strip()` against
|
||||
/// the shipped 20k-entry list.
|
||||
fn too_common(password: &str) -> Result<(), String> {
|
||||
let normalized = password.to_lowercase();
|
||||
if common_passwords().contains(normalized.trim()) {
|
||||
Err("This password is too common.".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Django's `common-passwords.txt.gz`, decompressed once and cached. Embedded in
|
||||
/// the binary so there's no runtime file dependency.
|
||||
fn common_passwords() -> &'static HashSet<String> {
|
||||
static SET: OnceLock<HashSet<String>> = OnceLock::new();
|
||||
SET.get_or_init(|| {
|
||||
let gz: &[u8] = include_bytes!("../assets/common-passwords.txt.gz");
|
||||
let mut decoder = flate2::read::GzDecoder::new(gz);
|
||||
let mut contents = String::new();
|
||||
decoder
|
||||
.read_to_string(&mut contents)
|
||||
.expect("embedded common-passwords list must decompress");
|
||||
contents.lines().map(|l| l.trim().to_string()).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `UserAttributeSimilarityValidator(max_similarity=0.7)`. Compares the password
|
||||
/// against each attribute and its `\W+`-split components.
|
||||
fn too_similar(password: &str, attrs: &UserAttrs) -> Result<(), String> {
|
||||
let password = password.to_lowercase();
|
||||
let candidates = [
|
||||
(attrs.username, "username"),
|
||||
(attrs.first_name, "first name"),
|
||||
(attrs.last_name, "last name"),
|
||||
(attrs.email, "email address"),
|
||||
];
|
||||
|
||||
for (value, verbose_name) in candidates {
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value_lower = value.to_lowercase();
|
||||
let mut value_parts = split_non_word(&value_lower);
|
||||
value_parts.push(value_lower);
|
||||
for part in &value_parts {
|
||||
if exceeds_maximum_length_ratio(&password, MAX_SIMILARITY, part) {
|
||||
continue;
|
||||
}
|
||||
if quick_ratio(&password, part) >= MAX_SIMILARITY {
|
||||
return Err(format!(
|
||||
"The password is too similar to the {verbose_name}."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Port of Django's `exceeds_maximum_length_ratio`: a cheap pre-filter that lets
|
||||
/// the validator skip the ratio computation when the attribute is far shorter
|
||||
/// than the password (in which case the similarity can't reach the threshold).
|
||||
fn exceeds_maximum_length_ratio(password: &str, max_similarity: f64, value: &str) -> bool {
|
||||
let pwd_len = password.chars().count();
|
||||
let length_bound_similarity = max_similarity / 2.0 * pwd_len as f64;
|
||||
let value_len = value.chars().count();
|
||||
pwd_len >= 10 * value_len && (value_len as f64) < length_bound_similarity
|
||||
}
|
||||
|
||||
/// Port of `difflib.SequenceMatcher.quick_ratio`: an upper bound on the real
|
||||
/// ratio, computed as `2 * |multiset intersection| / (len(a) + len(b))`.
|
||||
fn quick_ratio(a: &str, b: &str) -> f64 {
|
||||
let mut fullbcount: HashMap<char, i32> = HashMap::new();
|
||||
for c in b.chars() {
|
||||
*fullbcount.entry(c).or_insert(0) += 1;
|
||||
}
|
||||
let mut avail: HashMap<char, i32> = HashMap::new();
|
||||
let mut matches = 0i32;
|
||||
for c in a.chars() {
|
||||
let numb = match avail.get(&c) {
|
||||
Some(&n) => n,
|
||||
None => *fullbcount.get(&c).unwrap_or(&0),
|
||||
};
|
||||
avail.insert(c, numb - 1);
|
||||
if numb > 0 {
|
||||
matches += 1;
|
||||
}
|
||||
}
|
||||
let total = a.chars().count() + b.chars().count();
|
||||
if total == 0 {
|
||||
1.0
|
||||
} else {
|
||||
2.0 * matches as f64 / total as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Equivalent of Python `re.split(r"\W+", s)`: split on maximal runs of
|
||||
/// non-word characters (`\w` = alphanumeric or underscore), preserving the empty
|
||||
/// strings that appear when `s` begins or ends with a separator.
|
||||
fn split_non_word(s: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
let mut token = String::new();
|
||||
let mut in_sep = false;
|
||||
for c in s.chars() {
|
||||
if c.is_alphanumeric() || c == '_' {
|
||||
in_sep = false;
|
||||
token.push(c);
|
||||
} else if !in_sep {
|
||||
result.push(std::mem::take(&mut token));
|
||||
in_sep = true;
|
||||
}
|
||||
}
|
||||
result.push(token);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attrs<'a>(username: &'a str, email: &'a str) -> UserAttrs<'a> {
|
||||
UserAttrs {
|
||||
username,
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_and_short() {
|
||||
let a = attrs("alice", "alice@example.com");
|
||||
assert!(validate("", &a).iter().any(|e| e.contains("too short")));
|
||||
assert!(validate("abc", &a).iter().any(|e| e.contains("too short")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_entirely_numeric() {
|
||||
let a = attrs("alice", "alice@example.com");
|
||||
let errs = validate("12345678", &a);
|
||||
assert!(errs.iter().any(|e| e.contains("entirely numeric")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_common_password() {
|
||||
let a = attrs("alice", "alice@example.com");
|
||||
// "password" is in Django's list.
|
||||
assert!(validate("password", &a)
|
||||
.iter()
|
||||
.any(|e| e.contains("too common")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_similar_to_username() {
|
||||
let a = attrs("alexander", "alex@example.com");
|
||||
assert!(validate("alexander1", &a)
|
||||
.iter()
|
||||
.any(|e| e.contains("too similar")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_similar_to_email_local_part() {
|
||||
let a = attrs("zzz", "supersecret@example.com");
|
||||
// Matches the email local-part component after \W+ split.
|
||||
assert!(validate("supersecret", &a)
|
||||
.iter()
|
||||
.any(|e| e.contains("too similar")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_strong_password() {
|
||||
let a = attrs("alice", "alice@example.com");
|
||||
assert!(validate("Tr0ub4dour-x9Qz", &a).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_ratio_matches_difflib_examples() {
|
||||
// SequenceMatcher(a='', b='').quick_ratio() == 1.0
|
||||
assert_eq!(quick_ratio("", ""), 1.0);
|
||||
// Identical strings → 1.0
|
||||
assert_eq!(quick_ratio("abc", "abc"), 1.0);
|
||||
// Disjoint → 0.0
|
||||
assert_eq!(quick_ratio("abc", "xyz"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_non_word_matches_re_split() {
|
||||
assert_eq!(split_non_word("john"), vec!["john"]);
|
||||
assert_eq!(split_non_word("a.b"), vec!["a", "b"]);
|
||||
assert_eq!(split_non_word(".a"), vec!["", "a"]);
|
||||
assert_eq!(split_non_word("a."), vec!["a", ""]);
|
||||
assert_eq!(
|
||||
split_non_word("john.doe@example.com"),
|
||||
vec!["john", "doe", "example", "com"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Port of `AdminViewSet` (`api/views/admin.py`), registered by DRF under
|
||||
//! `admin-custom`. Every action requires staff (`IsAdminUser` = `is_staff`),
|
||||
//! enforced by the [`AdminUser`] extractor. Covers dashboard stats, the system
|
||||
//! config singleton, the jobs panel (list / trigger / schedule), flavor merge,
|
||||
//! a test-email sender, and the admin user list/detail.
|
||||
//!
|
||||
//! Scheduling parity note: Django wrote `django_celery_beat.PeriodicTask` rows;
|
||||
//! here the in-process scheduler reads `interval_hours` straight off the job row,
|
||||
//! so `update_job_schedule` only persists that field. `next_run` is derived as
|
||||
//! `last_run + interval_hours`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AdminUser;
|
||||
use crate::datetime::drf_iso;
|
||||
use crate::entities::prelude::{Flavor, Job, Rating, Reply, SystemConfig, Ticket, User, UserIp};
|
||||
use crate::entities::{job, rating, system_config, ticket, user, user_ip};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::service::build_ratings;
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
use crate::{email, jobs, merge};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin-custom/stats/", get(stats))
|
||||
.route("/admin-custom/config/", get(config_get).patch(config_patch))
|
||||
.route("/admin-custom/jobs/", get(jobs_list))
|
||||
.route("/admin-custom/merge_flavors/", post(merge_flavors))
|
||||
.route("/admin-custom/send_test_email/", post(send_test_email))
|
||||
.route("/admin-custom/users/", get(users_list))
|
||||
.route("/admin-custom/{pk}/trigger_job/", post(trigger_job))
|
||||
.route(
|
||||
"/admin-custom/{pk}/update_job_schedule/",
|
||||
patch(update_job_schedule),
|
||||
)
|
||||
.route(
|
||||
"/admin-custom/{pk}/user_detail/",
|
||||
get(user_detail_get)
|
||||
.patch(user_detail_patch)
|
||||
.delete(user_detail_delete),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_body(body: &Bytes) -> Value {
|
||||
serde_json::from_slice(body).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stats
|
||||
|
||||
async fn stats(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
|
||||
let db = &state.db;
|
||||
let total_users = User::find().count(db).await?;
|
||||
let total_ratings = Rating::find().count(db).await?;
|
||||
let total_replies = Reply::find().count(db).await?;
|
||||
let open_tickets = Ticket::find()
|
||||
.filter(ticket::Column::Status.eq("open"))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let email = &state.config.email;
|
||||
let allowed_hosts: Vec<String> = std::env::var("ALLOWED_HOSTS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|x| x.trim().to_string())
|
||||
.filter(|x| !x.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(json!({
|
||||
"total_users": total_users,
|
||||
"total_ratings": total_ratings,
|
||||
"total_replies": total_replies,
|
||||
"open_tickets": open_tickets,
|
||||
"email_config": {
|
||||
"host": email.host.clone().unwrap_or_else(|| "None".to_string()),
|
||||
"port": email.port,
|
||||
"use_tls": email.use_tls,
|
||||
"use_ssl": email.use_ssl,
|
||||
"skip_verify": email.skip_cert_verification,
|
||||
},
|
||||
"server_info": {
|
||||
"debug": cfg!(debug_assertions),
|
||||
"allowed_hosts": allowed_hosts,
|
||||
"frontend_url": state.config.frontend_url,
|
||||
"media_root": state.config.media_root,
|
||||
"static_root": std::env::var("STATIC_ROOT").unwrap_or_default(),
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- config
|
||||
|
||||
fn config_json(c: &system_config::Model) -> Value {
|
||||
json!({
|
||||
"site_name": c.site_name,
|
||||
"maintenance_mode": c.maintenance_mode,
|
||||
"allow_new_signups": c.allow_new_signups,
|
||||
"require_email_verification": c.require_email_verification,
|
||||
"updated_at": drf_iso(&c.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
/// `SystemConfig.get_solo()` — the pk=1 singleton, created on first read.
|
||||
async fn get_solo(state: &AppState) -> ApiResult<system_config::Model> {
|
||||
if let Some(c) = SystemConfig::find_by_id(1).one(&state.db).await? {
|
||||
return Ok(c);
|
||||
}
|
||||
Ok(system_config::ActiveModel {
|
||||
id: Set(1),
|
||||
site_name: Set("Holy Flavors Archive".into()),
|
||||
maintenance_mode: Set(false),
|
||||
allow_new_signups: Set(true),
|
||||
require_email_verification: Set(true),
|
||||
updated_at: Set(crate::datetime::now_micros()),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn config_get(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
|
||||
let c = get_solo(&state).await?;
|
||||
Ok(Json(config_json(&c)))
|
||||
}
|
||||
|
||||
fn opt_bool(data: &Value, field: &str) -> ApiResult<Option<bool>> {
|
||||
match data.get(field) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
_ => Err(ApiError::Validation(
|
||||
json!({ field: ["Must be a valid boolean."] }),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn config_patch(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let data = parse_body(&body);
|
||||
let c = get_solo(&state).await?;
|
||||
let mut am: system_config::ActiveModel = c.into();
|
||||
|
||||
match data.get("site_name") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(s)) if s.chars().count() <= 100 => am.site_name = Set(s.clone()),
|
||||
Some(Value::String(_)) => {
|
||||
return Err(ApiError::Validation(json!({
|
||||
"site_name": ["Ensure this field has no more than 100 characters."]
|
||||
})))
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(ApiError::Validation(
|
||||
json!({ "site_name": ["Not a valid string."] }),
|
||||
))
|
||||
}
|
||||
}
|
||||
if let Some(b) = opt_bool(&data, "maintenance_mode")? {
|
||||
am.maintenance_mode = Set(b);
|
||||
}
|
||||
if let Some(b) = opt_bool(&data, "allow_new_signups")? {
|
||||
am.allow_new_signups = Set(b);
|
||||
}
|
||||
if let Some(b) = opt_bool(&data, "require_email_verification")? {
|
||||
am.require_email_verification = Set(b);
|
||||
}
|
||||
am.updated_at = Set(crate::datetime::now_micros());
|
||||
let c = am.update(&state.db).await?;
|
||||
Ok(Json(config_json(&c)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- jobs
|
||||
|
||||
fn job_json(j: &job::Model) -> Value {
|
||||
let name_display = jobs::lookup(&j.name)
|
||||
.map(|b| b.display_name().to_string())
|
||||
.unwrap_or_else(|| j.name.clone());
|
||||
let next_run = if j.interval_hours > 0 {
|
||||
j.last_run
|
||||
.map(|lr| drf_iso(&(lr + chrono::Duration::hours(j.interval_hours as i64))))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!({
|
||||
"id": j.id,
|
||||
"name": j.name,
|
||||
"name_display": name_display,
|
||||
"status": j.status,
|
||||
"last_run": j.last_run.map(|d| drf_iso(&d)),
|
||||
"next_run": next_run,
|
||||
"interval_hours": j.interval_hours,
|
||||
"last_output": j.last_output,
|
||||
"error_message": j.error_message,
|
||||
})
|
||||
}
|
||||
|
||||
/// GET jobs — ensures a row exists for every known job, then lists by name.
|
||||
async fn jobs_list(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
|
||||
for b in jobs::registry() {
|
||||
jobs::get_or_create_row(&state.db, b.name()).await?;
|
||||
}
|
||||
let rows = Job::find()
|
||||
.order_by_asc(job::Column::Name)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let out: Vec<Value> = rows.iter().map(job_json).collect();
|
||||
Ok(Json(json!(out)))
|
||||
}
|
||||
|
||||
/// POST {pk}/trigger_job — dispatch the job asynchronously (fire-and-forget).
|
||||
async fn trigger_job(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let row = Job::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let Some(job_impl) = jobs::lookup(&row.name) else {
|
||||
return Err(ApiError::JsonStatus(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({ "error": format!("No job registered for {}", row.name) }),
|
||||
));
|
||||
};
|
||||
|
||||
let task_name = format!("api.{}", row.name);
|
||||
let mut am: job::ActiveModel = row.into();
|
||||
am.status = Set("pending".into());
|
||||
am.error_message = Set(String::new());
|
||||
am.update(&state.db).await?;
|
||||
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move { jobs::run_job(&st, job_impl).await });
|
||||
|
||||
Ok(Json(json!({ "status": "Job queued", "task": task_name })))
|
||||
}
|
||||
|
||||
/// PATCH {pk}/update_job_schedule — set `interval_hours`. The scheduler reads it.
|
||||
async fn update_job_schedule(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let row = Job::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let data = parse_body(&body);
|
||||
|
||||
let interval = match data.get("interval_hours") {
|
||||
Some(Value::Number(n)) => n.as_i64(),
|
||||
Some(Value::String(s)) => s.parse::<i64>().ok(),
|
||||
_ => None,
|
||||
};
|
||||
let row = if let Some(i) = interval {
|
||||
let mut am: job::ActiveModel = row.into();
|
||||
am.interval_hours = Set(i as i32);
|
||||
am.update(&state.db).await?
|
||||
} else {
|
||||
row
|
||||
};
|
||||
Ok(Json(job_json(&row)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- merge_flavors
|
||||
|
||||
fn parse_id(data: &Value, field: &str) -> Option<i32> {
|
||||
match data.get(field) {
|
||||
Some(Value::Number(n)) => n.as_i64().map(|v| v as i32),
|
||||
Some(Value::String(s)) => s.parse::<i32>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| *v != 0)
|
||||
}
|
||||
|
||||
async fn merge_flavors(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let data = parse_body(&body);
|
||||
let (keep_id, remove_id) = match (parse_id(&data, "keep_id"), parse_id(&data, "remove_id")) {
|
||||
(Some(k), Some(r)) => (k, r),
|
||||
_ => {
|
||||
return Err(ApiError::JsonStatus(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({ "error": "Both keep_id and remove_id are required" }),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let keep = Flavor::find_by_id(keep_id).one(&state.db).await?;
|
||||
let remove = Flavor::find_by_id(remove_id).one(&state.db).await?;
|
||||
let (keep, remove) = match (keep, remove) {
|
||||
(Some(k), Some(r)) => (k, r),
|
||||
_ => {
|
||||
return Err(ApiError::JsonStatus(
|
||||
StatusCode::NOT_FOUND,
|
||||
json!({ "error": "One or both flavors not found" }),
|
||||
))
|
||||
}
|
||||
};
|
||||
if keep.category_id != remove.category_id {
|
||||
return Err(ApiError::JsonStatus(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({ "error": "Flavors must belong to the same category" }),
|
||||
));
|
||||
}
|
||||
|
||||
merge::merge_flavors(&state.db, &keep, &remove).await?;
|
||||
Ok(Json(json!({ "status": "Flavors merged successfully" })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- send_test_email
|
||||
|
||||
async fn send_test_email(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(me): AdminUser,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let body = format!(
|
||||
"This is a test email sent to {} from the Holy Flavors Admin Interface.",
|
||||
me.email
|
||||
);
|
||||
match email::send_mail(
|
||||
&state.config.email,
|
||||
"Holy Flavors Admin Test Email",
|
||||
&body,
|
||||
&[me.email.clone()],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(Json(json!({ "status": "Test email sent!" }))),
|
||||
Err(e) => Err(ApiError::JsonStatus(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": e }),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- users
|
||||
|
||||
/// IP addresses for a set of users, ordered newest-login first (UserIP ordering).
|
||||
async fn ips_by_user(state: &AppState, ids: &[i32]) -> ApiResult<HashMap<i32, Vec<String>>> {
|
||||
let mut map: HashMap<i32, Vec<String>> = HashMap::new();
|
||||
if ids.is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
let rows = UserIp::find()
|
||||
.filter(user_ip::Column::UserId.is_in(ids.to_vec()))
|
||||
.order_by_desc(user_ip::Column::LastLogin)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
for r in rows {
|
||||
map.entry(r.user_id).or_default().push(r.ip_address);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn admin_user_json(u: &user::Model, ips: &[String]) -> Value {
|
||||
json!({
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"is_active": u.is_active,
|
||||
"is_superuser": u.is_superuser,
|
||||
"date_joined": drf_iso(&u.date_joined),
|
||||
"last_login": u.last_login.map(|d| drf_iso(&d)),
|
||||
"ips": ips,
|
||||
})
|
||||
}
|
||||
|
||||
/// GET users — admin list (AdminUserListSerializer), first 3 IPs each, -date_joined.
|
||||
async fn users_list(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
|
||||
let users = User::find()
|
||||
.order_by_desc(user::Column::DateJoined)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
|
||||
let ip_map = ips_by_user(&state, &ids).await?;
|
||||
let out: Vec<Value> = users
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let ips: Vec<String> = ip_map
|
||||
.get(&u.id)
|
||||
.map(|v| v.iter().take(3).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
admin_user_json(u, &ips)
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!(out)))
|
||||
}
|
||||
|
||||
/// AdminUserDetailSerializer: full IPs + the user's ratings.
|
||||
async fn build_user_detail(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
u: &user::Model,
|
||||
) -> ApiResult<Value> {
|
||||
let ips = ips_by_user(state, &[u.id])
|
||||
.await?
|
||||
.remove(&u.id)
|
||||
.unwrap_or_default();
|
||||
let ratings = Rating::find()
|
||||
.filter(rating::Column::UserId.eq(u.id))
|
||||
.order_by_desc(rating::Column::CreatedAt)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let ratings = build_ratings(state, ctx, ratings).await?;
|
||||
let mut v = admin_user_json(u, &ips);
|
||||
v["ratings"] = serde_json::to_value(ratings).unwrap_or(Value::Null);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
async fn user_detail_get(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let u = User::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(build_user_detail(&state, &ctx, &u).await?))
|
||||
}
|
||||
|
||||
async fn user_detail_patch(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let u = User::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let data = parse_body(&body);
|
||||
let u = match data.get("is_active") {
|
||||
Some(Value::Bool(b)) => {
|
||||
let mut am: user::ActiveModel = u.into();
|
||||
am.is_active = Set(*b);
|
||||
am.update(&state.db).await?
|
||||
}
|
||||
_ => u,
|
||||
};
|
||||
Ok(Json(build_user_detail(&state, &ctx, &u).await?))
|
||||
}
|
||||
|
||||
async fn user_detail_delete(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
User::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
crate::routes::users::delete_user_cascade(&state, pk).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! SimpleJWT-compatible auth issuance (login / refresh / verify / logout).
|
||||
//!
|
||||
//! Ports Django's cookie-backed JWT views (`api/views/auth.py`): tokens are set
|
||||
//! as httpOnly cookies and the JSON body is just `{"status": "ok"}`. Refresh
|
||||
//! rotates with blacklist-after-rotation. Tokens are HS256-signed with the same
|
||||
//! `SECRET_KEY` as Django, so they remain valid on whichever backend serves a
|
||||
//! given request during the strangler rollout.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::header::{HeaderMap, HeaderValue, SET_COOKIE};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::blacklist::{blacklist, is_blacklisted, record_outstanding};
|
||||
use crate::cookies::{clear_cookie, cookie_value, set_cookie, COOKIE_ACCESS, COOKIE_REFRESH};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::user;
|
||||
use crate::error::ApiResult;
|
||||
use crate::password::verify_password;
|
||||
use crate::state::AppState;
|
||||
use crate::tokens::{
|
||||
mint_access, mint_refresh, verify_any, verify_refresh, Minted, ACCESS_LIFETIME_SECS,
|
||||
REFRESH_LIFETIME_SECS,
|
||||
};
|
||||
|
||||
/// A well-formed pbkdf2_sha256 hash (iteration count matching `password.rs`)
|
||||
/// used only to spend the same CPU on the user-not-found branch of login, so the
|
||||
/// response time can't be used to enumerate usernames. Never matches any input.
|
||||
const DUMMY_PASSWORD_HASH: &str =
|
||||
"pbkdf2_sha256$1200000$dummytimingsalt$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/auth/token/", post(login))
|
||||
.route("/auth/token/refresh/", post(refresh))
|
||||
.route("/auth/token/verify/", post(verify))
|
||||
.route("/auth/logout/", post(logout))
|
||||
// Legacy alias kept for clients still calling POST /api/token/.
|
||||
.route("/token/", post(login))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LoginReq {
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/token/ — validate credentials, set cookies.
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Response> {
|
||||
// Brute-force guard, keyed by client IP (Django had no login throttle).
|
||||
let ip = crate::web::client_ip(&headers, peer);
|
||||
state
|
||||
.security
|
||||
.check_rate(&format!("login:ip:{ip}"), crate::throttle::LOGIN)?;
|
||||
|
||||
let req: LoginReq = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let secret = &state.config.secret_key;
|
||||
|
||||
let user = User::find()
|
||||
.filter(user::Column::Username.eq(&req.username))
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
// Mirror SimpleJWT: a single generic 401 for missing user, bad password, or
|
||||
// inactive account. Run a dummy verify on the not-found branch so response
|
||||
// time doesn't reveal whether the username exists (timing enumeration).
|
||||
let Some(user) = user else {
|
||||
let _ = verify_password(&req.password, DUMMY_PASSWORD_HASH);
|
||||
return Ok(invalid_credentials());
|
||||
};
|
||||
if !verify_password(&req.password, &user.password) || !user.is_active {
|
||||
return Ok(invalid_credentials());
|
||||
}
|
||||
|
||||
let refresh = mint_refresh(secret, user.id);
|
||||
let access = mint_access(secret, user.id);
|
||||
// RefreshToken.for_user records the outstanding token.
|
||||
record_outstanding(
|
||||
&state.db,
|
||||
user.id,
|
||||
&refresh.jti,
|
||||
&refresh.token,
|
||||
refresh.created_at(),
|
||||
refresh.expires_at(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ok_with_cookies(
|
||||
state.config.jwt_cookie_secure,
|
||||
&access,
|
||||
&refresh,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct RefreshReq {
|
||||
refresh: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /api/auth/token/refresh/ — rotate using the body or cookie refresh token.
|
||||
async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Response> {
|
||||
let secret = &state.config.secret_key;
|
||||
|
||||
let from_body = (!body.is_empty())
|
||||
.then(|| serde_json::from_slice::<RefreshReq>(&body).ok())
|
||||
.flatten()
|
||||
.and_then(|r| r.refresh);
|
||||
let token = from_body.or_else(|| read_cookie(&headers, COOKIE_REFRESH));
|
||||
|
||||
let Some(token) = token else {
|
||||
return Ok(token_not_valid());
|
||||
};
|
||||
let Some(claims) = verify_refresh(&token, secret) else {
|
||||
return Ok(token_not_valid());
|
||||
};
|
||||
if is_blacklisted(&state.db, &claims.jti).await? {
|
||||
return Ok(token_not_valid());
|
||||
}
|
||||
|
||||
// BLACKLIST_AFTER_ROTATION: blacklist the incoming token, then issue a new pair.
|
||||
blacklist(
|
||||
&state.db,
|
||||
claims.user_id,
|
||||
&claims.jti,
|
||||
&token,
|
||||
crate::tokens::TokenClaims::expires_naive(claims.exp),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let new_refresh = mint_refresh(secret, claims.user_id);
|
||||
let new_access = mint_access(secret, claims.user_id);
|
||||
Ok(ok_with_cookies(
|
||||
state.config.jwt_cookie_secure,
|
||||
&new_access,
|
||||
&new_refresh,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct VerifyReq {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /api/auth/token/verify/ — validate any token (and blacklist for refresh).
|
||||
async fn verify(State(state): State<AppState>, body: Bytes) -> ApiResult<Response> {
|
||||
let req: VerifyReq = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let Some(token) = req.token else {
|
||||
// DRF serializer: missing required field → 400.
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "token": ["This field is required."] })),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
let Some(claims) = verify_any(&token, &state.config.secret_key) else {
|
||||
return Ok(token_not_valid());
|
||||
};
|
||||
if is_blacklisted(&state.db, &claims.jti).await? {
|
||||
return Ok(token_not_valid());
|
||||
}
|
||||
Ok(Json(json!({})).into_response())
|
||||
}
|
||||
|
||||
/// POST /api/auth/logout/ — blacklist the refresh cookie and clear both cookies.
|
||||
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> ApiResult<Response> {
|
||||
let secret = &state.config.secret_key;
|
||||
if let Some(token) = read_cookie(&headers, COOKIE_REFRESH) {
|
||||
if let Some(claims) = verify_refresh(&token, secret) {
|
||||
// Best-effort; a TokenError is ignored on the Django side too.
|
||||
let _ = blacklist(
|
||||
&state.db,
|
||||
claims.user_id,
|
||||
&claims.jti,
|
||||
&token,
|
||||
crate::tokens::TokenClaims::expires_naive(claims.exp),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let secure = state.config.jwt_cookie_secure;
|
||||
let mut resp = Json(json!({ "status": "ok" })).into_response();
|
||||
append_cookie(&mut resp, &clear_cookie(COOKIE_ACCESS, secure));
|
||||
append_cookie(&mut resp, &clear_cookie(COOKIE_REFRESH, secure));
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
fn read_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|h| cookie_value(h, name))
|
||||
}
|
||||
|
||||
fn append_cookie(resp: &mut Response, value: &str) {
|
||||
if let Ok(hv) = HeaderValue::from_str(value) {
|
||||
resp.headers_mut().append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_with_cookies(secure: bool, access: &Minted, refresh: &Minted) -> Response {
|
||||
let mut resp = Json(json!({ "status": "ok" })).into_response();
|
||||
append_cookie(
|
||||
&mut resp,
|
||||
&set_cookie(COOKIE_ACCESS, &access.token, ACCESS_LIFETIME_SECS, secure),
|
||||
);
|
||||
append_cookie(
|
||||
&mut resp,
|
||||
&set_cookie(
|
||||
COOKIE_REFRESH,
|
||||
&refresh.token,
|
||||
REFRESH_LIFETIME_SECS,
|
||||
secure,
|
||||
),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
fn invalid_credentials() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"detail": "No active account found with the given credentials",
|
||||
"code": "no_active_account",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn token_not_valid() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"detail": "Token is invalid or expired",
|
||||
"code": "token_not_valid",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::dto::BannerOut;
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{banner, user};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/banners/", get(list))
|
||||
.route("/banners/active/", get(active))
|
||||
.route("/banners/{id}/", get(retrieve))
|
||||
}
|
||||
|
||||
fn to_dto(m: banner::Model) -> BannerOut {
|
||||
BannerOut {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
slug: m.slug,
|
||||
description: m.description,
|
||||
is_active: m.is_active,
|
||||
is_enabled: m.is_enabled,
|
||||
settings: m.settings,
|
||||
schema: m.schema,
|
||||
created_at: crate::datetime::drf_iso(&m.created_at),
|
||||
updated_at: crate::datetime::drf_iso(&m.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_superuser(state: &AppState, viewer: Option<i32>) -> ApiResult<bool> {
|
||||
if let Some(uid) = viewer {
|
||||
if let Some(u) = User::find_by_id(uid).one(&state.db).await? {
|
||||
return Ok(u.is_superuser);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// GET /api/banners/ — unpaginated. Non-superusers see only enabled banners.
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
) -> ApiResult<Json<Vec<BannerOut>>> {
|
||||
let q = Banner::find();
|
||||
let models = if is_superuser(&state, viewer).await? {
|
||||
q.all(&state.db).await?
|
||||
} else {
|
||||
q.filter(banner::Column::IsEnabled.eq(true))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
};
|
||||
Ok(Json(models.into_iter().map(to_dto).collect()))
|
||||
}
|
||||
|
||||
/// GET /api/banners/{id}/
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<BannerOut>> {
|
||||
Banner::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.map(|m| Json(to_dto(m)))
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ActiveParam {
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/banners/active/ — the viewer's (or named user's) selected banner if
|
||||
/// enabled, else the global default, else null.
|
||||
async fn active(
|
||||
State(state): State<AppState>,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
Query(params): Query<ActiveParam>,
|
||||
) -> ApiResult<Json<Option<BannerOut>>> {
|
||||
let mut target: Option<user::Model> = None;
|
||||
if let Some(username) = params.username {
|
||||
target = User::find()
|
||||
.filter(user::Column::Username.eq(username))
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
}
|
||||
if target.is_none() {
|
||||
if let Some(uid) = viewer {
|
||||
target = User::find_by_id(uid).one(&state.db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(u) = target {
|
||||
if let Some(bid) = u.selected_banner_id {
|
||||
if let Some(b) = Banner::find_by_id(bid).one(&state.db).await? {
|
||||
if b.is_enabled {
|
||||
return Ok(Json(Some(to_dto(b))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let active = Banner::find()
|
||||
.filter(banner::Column::IsActive.eq(true))
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
Ok(Json(active.map(to_dto)))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::{EntityTrait, PaginatorTrait, QueryOrder, QuerySelect};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::dto::CategoryOut;
|
||||
use crate::entities::category;
|
||||
use crate::entities::prelude::*;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/categories/", get(list))
|
||||
.route("/categories/{id}/", get(retrieve))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PageParam {
|
||||
page: Option<String>,
|
||||
}
|
||||
|
||||
fn to_dto(m: category::Model) -> CategoryOut {
|
||||
CategoryOut {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
slug: m.slug,
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/categories/ — paginated (DRF default), ordered by name.
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
Query(params): Query<PageParam>,
|
||||
) -> ApiResult<Json<Paginated<CategoryOut>>> {
|
||||
let page = parse_page(params.page.as_deref());
|
||||
let size = DEFAULT_PAGE_SIZE;
|
||||
let count = Category::find().count(&state.db).await?;
|
||||
let models = Category::find()
|
||||
.order_by_asc(category::Column::Name)
|
||||
.limit(size)
|
||||
.offset((page - 1) * size)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let results = models.into_iter().map(to_dto).collect();
|
||||
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
|
||||
}
|
||||
|
||||
/// GET /api/categories/{id}/
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<CategoryOut>> {
|
||||
Category::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.map(|m| Json(to_dto(m)))
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::sea_query::{Expr, Func, Order};
|
||||
use sea_orm::{
|
||||
ColumnTrait, Condition, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, RelationTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::{AuthUser, OptionalUser};
|
||||
use crate::dto::{FlavorOut, SearchHit};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{category, flavor, rating};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::media::image_field_url;
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::search::{extract_category_slug, query_words, score_relevance};
|
||||
use crate::service::build_flavors;
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/flavors/", get(list))
|
||||
.route("/flavors/search/", get(search))
|
||||
.route("/flavors/top/", get(top))
|
||||
.route("/flavors/newest/", get(newest))
|
||||
.route("/flavors/followed_top/", get(followed_top))
|
||||
.route("/flavors/{id}/", get(retrieve))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListParams {
|
||||
page: Option<String>,
|
||||
category: Option<i32>,
|
||||
#[serde(rename = "category__slug")]
|
||||
category_slug: Option<String>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
/// `AVG(api_rating.score)` expression for ordering by average rating.
|
||||
fn avg_score_expr() -> sea_orm::sea_query::SimpleExpr {
|
||||
Func::avg(Expr::col((rating::Entity, rating::Column::Score))).into()
|
||||
}
|
||||
|
||||
/// GET /api/flavors/ — paginated, ordered by -average_rating.
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> ApiResult<Json<Paginated<FlavorOut>>> {
|
||||
let page = parse_page(params.page.as_deref());
|
||||
let size = DEFAULT_PAGE_SIZE;
|
||||
|
||||
// Category slug can also come embedded in the search query (get_queryset).
|
||||
let (slug_from_search, _remaining) = params
|
||||
.search
|
||||
.as_deref()
|
||||
.map(extract_category_slug)
|
||||
.unwrap_or((None, String::new()));
|
||||
|
||||
let mut q = Flavor::find();
|
||||
|
||||
if let Some(cat) = params.category {
|
||||
q = q.filter(flavor::Column::CategoryId.eq(cat));
|
||||
}
|
||||
let slug = params.category_slug.clone().or(slug_from_search);
|
||||
if let Some(slug) = slug {
|
||||
q = q
|
||||
.join(JoinType::InnerJoin, flavor::Relation::Category.def())
|
||||
.filter(category::Column::Slug.eq(slug));
|
||||
}
|
||||
if let Some(search) = params.search.as_deref() {
|
||||
let (_, remaining) = extract_category_slug(search);
|
||||
let mut cond = Condition::any();
|
||||
let mut has = false;
|
||||
for w in query_words(&remaining) {
|
||||
let like = format!("%{w}%");
|
||||
cond = cond
|
||||
.add(flavor::Column::Name.like(&like))
|
||||
.add(flavor::Column::Description.like(&like));
|
||||
has = true;
|
||||
}
|
||||
if has {
|
||||
q = q.filter(cond);
|
||||
}
|
||||
}
|
||||
|
||||
let count = q.clone().count(&state.db).await?;
|
||||
|
||||
let models = q
|
||||
.join(JoinType::LeftJoin, flavor::Relation::Rating.def())
|
||||
.group_by(flavor::Column::Id)
|
||||
.order_by(avg_score_expr(), Order::Desc)
|
||||
.limit(size)
|
||||
.offset((page - 1) * size)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let results = build_flavors(&state, &ctx, models, viewer, &[]).await?;
|
||||
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
|
||||
}
|
||||
|
||||
/// GET /api/flavors/{id}/
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<FlavorOut>> {
|
||||
let model = Flavor::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let mut dtos = build_flavors(&state, &ctx, vec![model], viewer, &[]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CategoryParam {
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/flavors/top/ — top 10 flavors that have ratings.
|
||||
async fn top(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
Query(params): Query<CategoryParam>,
|
||||
) -> ApiResult<Json<Vec<FlavorOut>>> {
|
||||
let mut q = Flavor::find().join(JoinType::InnerJoin, flavor::Relation::Rating.def());
|
||||
if let Some(slug) = params.category {
|
||||
q = q
|
||||
.join(JoinType::InnerJoin, flavor::Relation::Category.def())
|
||||
.filter(category::Column::Slug.eq(slug));
|
||||
}
|
||||
let models = q
|
||||
.group_by(flavor::Column::Id)
|
||||
.order_by(avg_score_expr(), Order::Desc)
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
build_flavors(&state, &ctx, models, viewer, &[]).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/flavors/newest/ — 10 most recently created flavors.
|
||||
async fn newest(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
) -> ApiResult<Json<Vec<FlavorOut>>> {
|
||||
let models = Flavor::find()
|
||||
.order_by_desc(flavor::Column::CreatedAt)
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
build_flavors(&state, &ctx, models, viewer, &[]).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the
|
||||
/// caller follows. Requires authentication.
|
||||
async fn followed_top(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<Vec<FlavorOut>>> {
|
||||
// Followed user ids from the m2m table api_user_following(from_user_id, to_user_id).
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
state.db.get_database_backend(),
|
||||
"SELECT to_user_id FROM api_user_following WHERE from_user_id = ?",
|
||||
[uid.into()],
|
||||
);
|
||||
let rows = state.db.query_all(stmt).await?;
|
||||
let followed: Vec<i32> = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<i32>("", "to_user_id").ok())
|
||||
.collect();
|
||||
if followed.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let models = Flavor::find()
|
||||
.join(JoinType::InnerJoin, flavor::Relation::Rating.def())
|
||||
.filter(rating::Column::UserId.is_in(followed))
|
||||
.group_by(flavor::Column::Id)
|
||||
.order_by(avg_score_expr(), Order::Desc)
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
build_flavors(&state, &ctx, models, Some(uid), &[]).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchParam {
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/flavors/search/?q= — up to 15 ranked lightweight hits.
|
||||
async fn search(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
Query(params): Query<SearchParam>,
|
||||
) -> ApiResult<Json<Vec<SearchHit>>> {
|
||||
let media = &state.config.media_url;
|
||||
let query = params.q.unwrap_or_default().to_lowercase();
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let (slug, remaining) = extract_category_slug(query);
|
||||
|
||||
// find_also_related(Category) adds the join; don't add a second one.
|
||||
let mut q = Flavor::find();
|
||||
if let Some(slug) = slug {
|
||||
q = q.filter(category::Column::Slug.eq(slug));
|
||||
}
|
||||
let words = query_words(&remaining);
|
||||
if !words.is_empty() {
|
||||
let mut cond = Condition::any();
|
||||
for w in &words {
|
||||
let like = format!("%{w}%");
|
||||
cond = cond
|
||||
.add(flavor::Column::Name.like(&like))
|
||||
.add(flavor::Column::Description.like(&like));
|
||||
}
|
||||
q = q.filter(cond);
|
||||
}
|
||||
|
||||
// (flavor, category) joined load, ordered by name.
|
||||
let rows = q
|
||||
.order_by_asc(flavor::Column::Name)
|
||||
.find_also_related(Category)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut hits: Vec<(i32, SearchHit)> = Vec::new();
|
||||
for (f, cat) in rows {
|
||||
let cat = match cat {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let key = format!("{}|{}", f.name.trim().to_lowercase(), cat.slug);
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let image_url = if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(image_field_url(&ctx, media, img))
|
||||
} else {
|
||||
f.image_url.clone()
|
||||
};
|
||||
let relevance = score_relevance(&f.name, &remaining);
|
||||
hits.push((
|
||||
relevance,
|
||||
SearchHit {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
kind: "flavor".to_string(),
|
||||
subtitle: cat.name,
|
||||
image_url,
|
||||
slug: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
// Sort by -relevance, then name.
|
||||
hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.name.cmp(&b.1.name)));
|
||||
let out: Vec<SearchHit> = hits.into_iter().take(15).map(|(_, h)| h).collect();
|
||||
Ok(Json(out))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod banners;
|
||||
pub mod categories;
|
||||
pub mod flavors;
|
||||
pub mod ratings;
|
||||
pub mod replies;
|
||||
pub mod social;
|
||||
pub mod tickets;
|
||||
pub mod users;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// All ported endpoints, mounted under `/api`. Paths carry DRF's trailing
|
||||
/// slash; axum 0.8 does not auto-redirect, and the frontend calls with the
|
||||
/// slash, so routes are declared with it explicitly.
|
||||
pub fn api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(auth::router())
|
||||
.merge(categories::router())
|
||||
.merge(flavors::router())
|
||||
.merge(ratings::router())
|
||||
.merge(replies::router())
|
||||
.merge(users::router())
|
||||
.merge(social::router())
|
||||
.merge(tickets::router())
|
||||
.merge(banners::router())
|
||||
.merge(admin::router())
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Port of `RatingViewSet` (`api/views/rating.py`). Writes go through the shared
|
||||
//! SQLite file, so inserts/updates here are visible to Django and vice versa.
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::ActiveValue::{NotSet, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait,
|
||||
QueryFilter, QueryOrder, QuerySelect, Statement, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::dto::{RatingOut, ReplyOut};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{notification, rating, reply, user};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::mentions::parse_mentions;
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::service::{build_ratings, build_replies};
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
const FEED_PAGE_SIZE: u64 = 10;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/ratings/", get(list).post(create))
|
||||
.route("/ratings/feed/", get(feed))
|
||||
.route("/ratings/recent/", get(recent))
|
||||
.route(
|
||||
"/ratings/{id}/",
|
||||
get(retrieve).put(update).patch(update).delete(destroy),
|
||||
)
|
||||
.route("/ratings/{id}/reply/", post(reply))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PageParam {
|
||||
page: Option<String>,
|
||||
}
|
||||
|
||||
/// Load the acting user row (for superuser checks). 401 if the id is stale.
|
||||
async fn load_actor(state: &AppState, uid: i32) -> ApiResult<user::Model> {
|
||||
User::find_by_id(uid)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)
|
||||
}
|
||||
|
||||
/// Read an integer from a JSON value (accepts only JSON numbers, like DRF).
|
||||
fn as_i32(v: &Value) -> Option<i32> {
|
||||
v.as_i64().and_then(|n| i32::try_from(n).ok())
|
||||
}
|
||||
|
||||
/// GET /api/ratings/ — paginated, ordered by -created_at (IsAuthenticatedOrReadOnly).
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
Query(params): Query<PageParam>,
|
||||
) -> ApiResult<Json<Paginated<RatingOut>>> {
|
||||
let page = parse_page(params.page.as_deref());
|
||||
let size = DEFAULT_PAGE_SIZE;
|
||||
let count = Rating::find().count(&state.db).await?;
|
||||
let models = Rating::find()
|
||||
.order_by_desc(rating::Column::CreatedAt)
|
||||
.limit(size)
|
||||
.offset((page - 1) * size)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let results = build_ratings(&state, &ctx, models).await?;
|
||||
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
|
||||
}
|
||||
|
||||
/// GET /api/ratings/{id}/
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<RatingOut>> {
|
||||
let model = Rating::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// POST /api/ratings/ — create a rating for the authenticated user.
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
body: Bytes,
|
||||
) -> ApiResult<(StatusCode, Json<RatingOut>)> {
|
||||
// django_ratelimit(key="user", rate="10/m") on RatingViewSet.create.
|
||||
state.security.check_rate(
|
||||
&format!("rating:user:{uid}"),
|
||||
crate::throttle::RATING_CREATE,
|
||||
)?;
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
|
||||
// DRF validation, aggregated and field-keyed, in serializer field order.
|
||||
let mut errors = serde_json::Map::new();
|
||||
|
||||
// flavor (PrimaryKeyRelatedField, required)
|
||||
let flavor_id = match data.get("flavor") {
|
||||
None | Some(Value::Null) => {
|
||||
errors.insert("flavor".into(), json!(["This field is required."]));
|
||||
None
|
||||
}
|
||||
Some(v) => match as_i32(v) {
|
||||
Some(id) => Some(id),
|
||||
None => {
|
||||
errors.insert(
|
||||
"flavor".into(),
|
||||
json!(["Incorrect type. Expected pk value, received str."]),
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// score (IntegerField, 1..=10, required)
|
||||
let score = match data.get("score") {
|
||||
None | Some(Value::Null) => {
|
||||
errors.insert("score".into(), json!(["This field is required."]));
|
||||
None
|
||||
}
|
||||
Some(v) => match as_i32(v) {
|
||||
Some(s) if s < 1 => {
|
||||
errors.insert(
|
||||
"score".into(),
|
||||
json!(["Ensure this value is greater than or equal to 1."]),
|
||||
);
|
||||
None
|
||||
}
|
||||
Some(s) if s > 10 => {
|
||||
errors.insert(
|
||||
"score".into(),
|
||||
json!(["Ensure this value is less than or equal to 10."]),
|
||||
);
|
||||
None
|
||||
}
|
||||
Some(s) => Some(s),
|
||||
None => {
|
||||
errors.insert("score".into(), json!(["A valid integer is required."]));
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// comment (optional TextField)
|
||||
let comment = match data.get("comment") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) => Some(s.clone()),
|
||||
Some(_) => {
|
||||
errors.insert("comment".into(), json!(["Not a valid string."]));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Flavor must exist (PrimaryKeyRelatedField pk check).
|
||||
if let Some(fid) = flavor_id {
|
||||
if !errors.contains_key("flavor") {
|
||||
let exists = Flavor::find_by_id(fid).one(&state.db).await?.is_some();
|
||||
if !exists {
|
||||
errors.insert(
|
||||
"flavor".into(),
|
||||
json!([format!("Invalid pk \"{fid}\" - object does not exist.")]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(ApiError::Validation(Value::Object(errors)));
|
||||
}
|
||||
let (flavor_id, score) = (flavor_id.unwrap(), score.unwrap());
|
||||
|
||||
// perform_create: reject a second rating of the same flavor by this user.
|
||||
let dup = Rating::find()
|
||||
.filter(rating::Column::UserId.eq(uid))
|
||||
.filter(rating::Column::FlavorId.eq(flavor_id))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.is_some();
|
||||
if dup {
|
||||
return Err(ApiError::Validation(json!([
|
||||
"You have already rated this flavor."
|
||||
])));
|
||||
}
|
||||
|
||||
let now = crate::datetime::now_micros();
|
||||
let model = rating::ActiveModel {
|
||||
id: NotSet,
|
||||
user_id: Set(uid),
|
||||
flavor_id: Set(flavor_id),
|
||||
score: Set(score),
|
||||
comment: Set(comment.clone()),
|
||||
created_at: Set(now),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
|
||||
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
|
||||
parse_mentions(&state.db, text, uid, Some(model.id), None).await?;
|
||||
}
|
||||
|
||||
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
|
||||
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
||||
Ok((StatusCode::CREATED, Json(out)))
|
||||
}
|
||||
|
||||
/// PUT/PATCH /api/ratings/{id}/ — owner or superuser only. No mention parsing
|
||||
/// (Django's perform_update is the default; mentions only fire on create).
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
method: Method,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<RatingOut>> {
|
||||
let existing = Rating::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let actor = load_actor(&state, uid).await?;
|
||||
if existing.user_id != uid && !actor.is_superuser {
|
||||
return Err(ApiError::Forbidden(
|
||||
"You cannot edit/delete this rating.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let partial = method == Method::PATCH;
|
||||
let mut errors = serde_json::Map::new();
|
||||
let mut active: rating::ActiveModel = existing.clone().into();
|
||||
|
||||
// flavor
|
||||
match data.get("flavor") {
|
||||
Some(Value::Null) | None if !partial => {
|
||||
errors.insert("flavor".into(), json!(["This field is required."]));
|
||||
}
|
||||
Some(v) if !v.is_null() => match as_i32(v) {
|
||||
Some(fid) => {
|
||||
if Flavor::find_by_id(fid).one(&state.db).await?.is_some() {
|
||||
active.flavor_id = Set(fid);
|
||||
} else {
|
||||
errors.insert(
|
||||
"flavor".into(),
|
||||
json!([format!("Invalid pk \"{fid}\" - object does not exist.")]),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
errors.insert(
|
||||
"flavor".into(),
|
||||
json!(["Incorrect type. Expected pk value, received str."]),
|
||||
);
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// score
|
||||
match data.get("score") {
|
||||
Some(Value::Null) | None if !partial => {
|
||||
errors.insert("score".into(), json!(["This field is required."]));
|
||||
}
|
||||
Some(v) if !v.is_null() => match as_i32(v) {
|
||||
Some(s) if s < 1 => {
|
||||
errors.insert(
|
||||
"score".into(),
|
||||
json!(["Ensure this value is greater than or equal to 1."]),
|
||||
);
|
||||
}
|
||||
Some(s) if s > 10 => {
|
||||
errors.insert(
|
||||
"score".into(),
|
||||
json!(["Ensure this value is less than or equal to 10."]),
|
||||
);
|
||||
}
|
||||
Some(s) => active.score = Set(s),
|
||||
None => {
|
||||
errors.insert("score".into(), json!(["A valid integer is required."]));
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// comment
|
||||
match data.get("comment") {
|
||||
Some(Value::String(s)) => active.comment = Set(Some(s.clone())),
|
||||
Some(Value::Null) => active.comment = Set(None),
|
||||
Some(_) => {
|
||||
errors.insert("comment".into(), json!(["Not a valid string."]));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(ApiError::Validation(Value::Object(errors)));
|
||||
}
|
||||
|
||||
let model = active.update(&state.db).await?;
|
||||
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::Internal)
|
||||
}
|
||||
|
||||
/// DELETE /api/ratings/{id}/ — owner or superuser only.
|
||||
async fn destroy(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let existing = Rating::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let actor = load_actor(&state, uid).await?;
|
||||
if existing.user_id != uid && !actor.is_superuser {
|
||||
return Err(ApiError::Forbidden(
|
||||
"You cannot edit/delete this rating.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Django on_delete=CASCADE is emulated in Python, not enforced by SQLite FKs,
|
||||
// so replicate the collector: drop notifications pointing at this rating or
|
||||
// any of its replies, then the replies, then the rating itself.
|
||||
let txn = state.db.begin().await?;
|
||||
let reply_ids: Vec<i32> = Reply::find()
|
||||
.filter(reply::Column::RatingId.eq(id))
|
||||
.all(&txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.id)
|
||||
.collect();
|
||||
Notification::delete_many()
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(notification::Column::RatingId.eq(id))
|
||||
.add(notification::Column::ReplyId.is_in(reply_ids)),
|
||||
)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
Reply::delete_many()
|
||||
.filter(reply::Column::RatingId.eq(id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
Rating::delete_by_id(id).exec(&txn).await?;
|
||||
txn.commit().await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// GET /api/ratings/feed/ — ratings by followed users, FeedPagination (size 10).
|
||||
async fn feed(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
Query(params): Query<PageParam>,
|
||||
) -> ApiResult<Json<Paginated<RatingOut>>> {
|
||||
let page = parse_page(params.page.as_deref());
|
||||
let size = FEED_PAGE_SIZE;
|
||||
|
||||
// Followed user ids from api_user_following(from_user_id, to_user_id).
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
state.db.get_database_backend(),
|
||||
"SELECT to_user_id FROM api_user_following WHERE from_user_id = ?",
|
||||
[uid.into()],
|
||||
);
|
||||
let rows = state.db.query_all(stmt).await?;
|
||||
let followed: Vec<i32> = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<i32>("", "to_user_id").ok())
|
||||
.collect();
|
||||
if followed.is_empty() {
|
||||
return Ok(Json(Paginated::build(&ctx, Vec::new(), 0, page, size)));
|
||||
}
|
||||
|
||||
let count = Rating::find()
|
||||
.filter(rating::Column::UserId.is_in(followed.clone()))
|
||||
.count(&state.db)
|
||||
.await?;
|
||||
let models = Rating::find()
|
||||
.filter(rating::Column::UserId.is_in(followed))
|
||||
.order_by_desc(rating::Column::CreatedAt)
|
||||
.limit(size)
|
||||
.offset((page - 1) * size)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let results = build_ratings(&state, &ctx, models).await?;
|
||||
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
|
||||
}
|
||||
|
||||
/// GET /api/ratings/recent/ — 10 newest with a non-empty comment (AllowAny).
|
||||
async fn recent(State(state): State<AppState>, ctx: RequestCtx) -> ApiResult<Json<Vec<RatingOut>>> {
|
||||
let models = Rating::find()
|
||||
.filter(rating::Column::Comment.is_not_null())
|
||||
.filter(rating::Column::Comment.ne(""))
|
||||
.order_by_desc(rating::Column::CreatedAt)
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(build_ratings(&state, &ctx, models).await?))
|
||||
}
|
||||
|
||||
/// POST /api/ratings/{id}/reply/ — add a reply, notify the rating owner, and
|
||||
/// fan out @mentions.
|
||||
async fn reply(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<(StatusCode, Json<ReplyOut>)> {
|
||||
let rating = Rating::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let text = data.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if text.is_empty() {
|
||||
return Err(ApiError::Validation(json!({ "error": "Text is required" })));
|
||||
}
|
||||
|
||||
let now = crate::datetime::now_micros();
|
||||
let reply_model = reply::ActiveModel {
|
||||
id: NotSet,
|
||||
user_id: Set(uid),
|
||||
rating_id: Set(rating.id),
|
||||
text: Set(text.to_string()),
|
||||
created_at: Set(now),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
|
||||
if rating.user_id != uid {
|
||||
notification::ActiveModel {
|
||||
id: NotSet,
|
||||
is_read: Set(false),
|
||||
created_at: Set(now),
|
||||
actor_id: Set(uid),
|
||||
rating_id: Set(Some(rating.id)),
|
||||
recipient_id: Set(rating.user_id),
|
||||
reply_id: Set(Some(reply_model.id)),
|
||||
ticket_id: Set(None),
|
||||
notification_type: Set("reply".to_string()),
|
||||
profile_comment_id: Set(None),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?;
|
||||
|
||||
let mut dtos = build_replies(&state, vec![reply_model]).await?;
|
||||
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
||||
Ok((StatusCode::CREATED, Json(out)))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Port of `ReplyViewSet` (`api/views/rating.py`). All actions require
|
||||
//! authentication. Creation is intentionally not exposed: Django marks `rating`
|
||||
//! read-only on the serializer, so replies are only ever made via the rating's
|
||||
//! `reply` action — the frontend never POSTs to `/replies/`.
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::dto::ReplyOut;
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{notification, reply, user};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::mentions::parse_mentions;
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::service::build_replies;
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/replies/", get(list)).route(
|
||||
"/replies/{id}/",
|
||||
get(retrieve).put(update).patch(update).delete(destroy),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PageParam {
|
||||
page: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_actor(state: &AppState, uid: i32) -> ApiResult<user::Model> {
|
||||
User::find_by_id(uid)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)
|
||||
}
|
||||
|
||||
/// GET /api/replies/ — paginated, ordered by created_at asc.
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(_uid): AuthUser,
|
||||
Query(params): Query<PageParam>,
|
||||
) -> ApiResult<Json<Paginated<ReplyOut>>> {
|
||||
let page = parse_page(params.page.as_deref());
|
||||
let size = DEFAULT_PAGE_SIZE;
|
||||
let count = Reply::find().count(&state.db).await?;
|
||||
let models = Reply::find()
|
||||
.order_by_asc(reply::Column::CreatedAt)
|
||||
.limit(size)
|
||||
.offset((page - 1) * size)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let results = build_replies(&state, models).await?;
|
||||
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
|
||||
}
|
||||
|
||||
/// GET /api/replies/{id}/
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(_uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<ReplyOut>> {
|
||||
let model = Reply::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let mut dtos = build_replies(&state, vec![model]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// PUT/PATCH /api/replies/{id}/ — owner or superuser; re-parses @mentions.
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
method: Method,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<ReplyOut>> {
|
||||
let existing = Reply::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let actor = load_actor(&state, uid).await?;
|
||||
if existing.user_id != uid && !actor.is_superuser {
|
||||
return Err(ApiError::Forbidden("You cannot edit this reply.".into()));
|
||||
}
|
||||
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let partial = method == Method::PATCH;
|
||||
|
||||
let mut active: reply::ActiveModel = existing.clone().into();
|
||||
match data.get("text") {
|
||||
Some(Value::String(s)) if !s.is_empty() => active.text = Set(s.clone()),
|
||||
Some(Value::String(_)) => {
|
||||
return Err(ApiError::Validation(
|
||||
json!({ "text": ["This field may not be blank."] }),
|
||||
));
|
||||
}
|
||||
Some(Value::Null) | None if !partial => {
|
||||
return Err(ApiError::Validation(
|
||||
json!({ "text": ["This field is required."] }),
|
||||
));
|
||||
}
|
||||
Some(v) if !v.is_null() => {
|
||||
return Err(ApiError::Validation(
|
||||
json!({ "text": ["Not a valid string."] }),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let model = active.update(&state.db).await?;
|
||||
parse_mentions(
|
||||
&state.db,
|
||||
&model.text,
|
||||
uid,
|
||||
Some(model.rating_id),
|
||||
Some(model.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut dtos = build_replies(&state, vec![model]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::Internal)
|
||||
}
|
||||
|
||||
/// DELETE /api/replies/{id}/ — owner or superuser.
|
||||
async fn destroy(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let existing = Reply::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let actor = load_actor(&state, uid).await?;
|
||||
if existing.user_id != uid && !actor.is_superuser {
|
||||
return Err(ApiError::Forbidden("You cannot delete this reply.".into()));
|
||||
}
|
||||
// Emulate Django's CASCADE: clear notifications referencing this reply first
|
||||
// (SQLite FKs are enforced but have no ON DELETE CASCADE).
|
||||
let txn = state.db.begin().await?;
|
||||
Notification::delete_many()
|
||||
.filter(notification::Column::ReplyId.eq(id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
Reply::delete_by_id(id).exec(&txn).await?;
|
||||
txn.commit().await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 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::notification;
|
||||
use crate::entities::prelude::*;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::service::build_notifications;
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/notifications/", get(list))
|
||||
.route("/notifications/mark_all_read/", post(mark_all_read))
|
||||
.route("/notifications/{id}/", get(retrieve))
|
||||
.route("/notifications/{id}/mark_read/", post(mark_read))
|
||||
}
|
||||
|
||||
/// GET /api/notifications/ — caller's notifications, newest first (no pagination).
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<Vec<NotificationOut>>> {
|
||||
let models = Notification::find()
|
||||
.filter(notification::Column::RecipientId.eq(uid))
|
||||
.order_by_desc(notification::Column::CreatedAt)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(build_notifications(&state, &ctx, models).await?))
|
||||
}
|
||||
|
||||
/// GET /api/notifications/{id}/ — own only (404 otherwise).
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<NotificationOut>> {
|
||||
let n = Notification::find_by_id(id)
|
||||
.filter(notification::Column::RecipientId.eq(uid))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let mut dtos = build_notifications(&state, &ctx, vec![n]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// POST /api/notifications/mark_all_read/
|
||||
async fn mark_all_read(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
Notification::update_many()
|
||||
.col_expr(
|
||||
notification::Column::IsRead,
|
||||
sea_orm::sea_query::Expr::value(true),
|
||||
)
|
||||
.filter(notification::Column::RecipientId.eq(uid))
|
||||
.filter(notification::Column::IsRead.eq(false))
|
||||
.exec(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "status": "all read" })))
|
||||
}
|
||||
|
||||
/// POST /api/notifications/{id}/mark_read/ — own only.
|
||||
async fn mark_read(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let n = Notification::find_by_id(id)
|
||||
.filter(notification::Column::RecipientId.eq(uid))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let mut active: notification::ActiveModel = n.into();
|
||||
active.is_read = Set(true);
|
||||
active.update(&state.db).await?;
|
||||
Ok(Json(json!({ "status": "marked read" })))
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Port of `TicketViewSet` (`api/views/support.py`). All actions require
|
||||
//! authentication; non-superusers only see/act on their own tickets. Deletion
|
||||
//! is superuser-only. `update_status` requires staff (DRF `IsAdminUser`).
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::dto::{TicketMessageOut, TicketOut};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{notification, ticket, ticket_message, user};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::service::{build_tickets, ticket_message_out};
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/tickets/", get(list).post(create))
|
||||
.route(
|
||||
"/tickets/{id}/",
|
||||
get(retrieve).put(update).patch(update).delete(destroy),
|
||||
)
|
||||
.route("/tickets/{id}/add_message/", post(add_message))
|
||||
.route("/tickets/{id}/update_status/", post(update_status))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PageParam {
|
||||
page: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_user(state: &AppState, uid: i32) -> ApiResult<user::Model> {
|
||||
User::find_by_id(uid)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)
|
||||
}
|
||||
|
||||
const STATUSES: &[&str] = &["open", "in_progress", "resolved", "closed"];
|
||||
|
||||
/// Tickets visible to `me`: all for superusers, own otherwise (-updated_at).
|
||||
fn scoped_query(me: &user::Model) -> sea_orm::Select<Ticket> {
|
||||
let q = Ticket::find().order_by_desc(ticket::Column::UpdatedAt);
|
||||
if me.is_superuser {
|
||||
q
|
||||
} else {
|
||||
q.filter(ticket::Column::UserId.eq(me.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/tickets/
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
Query(params): Query<PageParam>,
|
||||
) -> ApiResult<Json<Paginated<TicketOut>>> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let page = parse_page(params.page.as_deref());
|
||||
let size = DEFAULT_PAGE_SIZE;
|
||||
let count = scoped_query(&me).count(&state.db).await?;
|
||||
let models = scoped_query(&me)
|
||||
.limit(size)
|
||||
.offset((page - 1) * size)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let results = build_tickets(&state, &ctx, models).await?;
|
||||
Ok(Json(Paginated::build(&ctx, results, count, page, size)))
|
||||
}
|
||||
|
||||
/// GET /api/tickets/{id}/
|
||||
async fn retrieve(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<TicketOut>> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let t = scoped_query(&me)
|
||||
.filter(ticket::Column::Id.eq(id))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
fn require_str(
|
||||
data: &Value,
|
||||
field: &str,
|
||||
partial: bool,
|
||||
max: Option<usize>,
|
||||
) -> ApiResult<Option<String>> {
|
||||
match data.get(field) {
|
||||
Some(Value::String(s)) if !s.is_empty() => {
|
||||
if let Some(m) = max {
|
||||
if s.chars().count() > m {
|
||||
return Err(ApiError::Validation(json!({
|
||||
field: [format!("Ensure this field has no more than {m} characters.")]
|
||||
})));
|
||||
}
|
||||
}
|
||||
Ok(Some(s.clone()))
|
||||
}
|
||||
Some(Value::String(_)) => Err(ApiError::Validation(
|
||||
json!({ field: ["This field may not be blank."] }),
|
||||
)),
|
||||
Some(v) if !v.is_null() => Err(ApiError::Validation(
|
||||
json!({ field: ["Not a valid string."] }),
|
||||
)),
|
||||
_ if partial => Ok(None),
|
||||
_ => Err(ApiError::Validation(
|
||||
json!({ field: ["This field is required."] }),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/tickets/ — create + notify other admins (ticket_new).
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
body: Bytes,
|
||||
) -> ApiResult<(StatusCode, Json<TicketOut>)> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let subject = require_str(&data, "subject", false, Some(200))?.unwrap();
|
||||
let description = require_str(&data, "description", false, None)?.unwrap();
|
||||
|
||||
let now = crate::datetime::now_micros();
|
||||
let t = ticket::ActiveModel {
|
||||
subject: Set(subject),
|
||||
description: Set(description),
|
||||
status: Set("open".into()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
user_id: Set(me.id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
|
||||
// Notify every other admin.
|
||||
let admins = User::find()
|
||||
.filter(user::Column::IsSuperuser.eq(true))
|
||||
.filter(user::Column::Id.ne(me.id))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
for admin in admins {
|
||||
notification::ActiveModel {
|
||||
is_read: Set(false),
|
||||
created_at: Set(crate::datetime::now_micros()),
|
||||
actor_id: Set(me.id),
|
||||
recipient_id: Set(admin.id),
|
||||
ticket_id: Set(Some(t.id)),
|
||||
notification_type: Set("ticket_new".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(dtos.pop().ok_or(ApiError::Internal)?),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT/PATCH /api/tickets/{id}/ — subject/description only (status, user read-only).
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
method: Method,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<TicketOut>> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let existing = scoped_query(&me)
|
||||
.filter(ticket::Column::Id.eq(id))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let partial = method == Method::PATCH;
|
||||
|
||||
let mut active: ticket::ActiveModel = existing.into();
|
||||
if let Some(s) = require_str(&data, "subject", partial, Some(200))? {
|
||||
active.subject = Set(s);
|
||||
}
|
||||
if let Some(s) = require_str(&data, "description", partial, None)? {
|
||||
active.description = Set(s);
|
||||
}
|
||||
active.updated_at = Set(crate::datetime::now_micros());
|
||||
let t = active.update(&state.db).await?;
|
||||
|
||||
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::Internal)
|
||||
}
|
||||
|
||||
/// DELETE /api/tickets/{id}/ — superuser only; cascades messages + notifications.
|
||||
async fn destroy(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
if !me.is_superuser {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Only admins can delete tickets.".into(),
|
||||
));
|
||||
}
|
||||
// get_object is scoped; superusers see all, so a missing ticket is 404.
|
||||
Ticket::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let txn = state.db.begin().await?;
|
||||
Notification::delete_many()
|
||||
.filter(notification::Column::TicketId.eq(id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
TicketMessage::delete_many()
|
||||
.filter(ticket_message::Column::TicketId.eq(id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
Ticket::delete_by_id(id).exec(&txn).await?;
|
||||
txn.commit().await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// POST /api/tickets/{id}/add_message/
|
||||
async fn add_message(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<(StatusCode, Json<TicketMessageOut>)> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let t = scoped_query(&me)
|
||||
.filter(ticket::Column::Id.eq(id))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let text = match data.get("text") {
|
||||
Some(Value::String(s)) if !s.is_empty() => s.clone(),
|
||||
_ => return Err(ApiError::Validation(json!({ "error": "Text is required" }))),
|
||||
};
|
||||
|
||||
let msg = ticket_message::ActiveModel {
|
||||
text: Set(text),
|
||||
created_at: Set(crate::datetime::now_micros()),
|
||||
ticket_id: Set(t.id),
|
||||
user_id: Set(me.id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
|
||||
// Notify the counterparty: admin → ticket owner; user → all other admins.
|
||||
if me.is_superuser {
|
||||
if t.user_id != me.id {
|
||||
notify_ticket(&state, me.id, t.user_id, t.id, "ticket_reply").await?;
|
||||
}
|
||||
} else {
|
||||
let admins = User::find()
|
||||
.filter(user::Column::IsSuperuser.eq(true))
|
||||
.filter(user::Column::Id.ne(me.id))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
for admin in admins {
|
||||
notify_ticket(&state, me.id, admin.id, t.id, "ticket_reply").await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ticket_message_out(msg, &me))))
|
||||
}
|
||||
|
||||
async fn notify_ticket(
|
||||
state: &AppState,
|
||||
actor: i32,
|
||||
recipient: i32,
|
||||
ticket_id: i32,
|
||||
kind: &str,
|
||||
) -> ApiResult<()> {
|
||||
notification::ActiveModel {
|
||||
is_read: Set(false),
|
||||
created_at: Set(crate::datetime::now_micros()),
|
||||
actor_id: Set(actor),
|
||||
recipient_id: Set(recipient),
|
||||
ticket_id: Set(Some(ticket_id)),
|
||||
notification_type: Set(kind.into()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/tickets/{id}/update_status/ — staff only.
|
||||
async fn update_status(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
if !me.is_staff {
|
||||
return Err(ApiError::Forbidden(
|
||||
"You do not have permission to perform this action.".into(),
|
||||
));
|
||||
}
|
||||
// update_status uses get_object on the (admin) scoped queryset → all tickets.
|
||||
let t = Ticket::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let new_status = data.get("status").and_then(|v| v.as_str());
|
||||
match new_status {
|
||||
Some(s) if STATUSES.contains(&s) => {
|
||||
let mut active: ticket::ActiveModel = t.into();
|
||||
active.status = Set(s.to_string());
|
||||
active.updated_at = Set(crate::datetime::now_micros());
|
||||
active.update(&state.db).await?;
|
||||
Ok(Json(json!({ "status": "updated" })))
|
||||
}
|
||||
_ => Err(ApiError::Validation(json!({ "error": "Invalid status" }))),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
//! Port of `api/services/search.py` (category-keyword extraction + relevance).
|
||||
|
||||
/// (keyword, category-slug) pairs, matched as substrings of the query.
|
||||
const CATEGORY_KEYWORDS: &[(&str, &str)] = &[
|
||||
("iced tea", "iced-tea"),
|
||||
("eistee", "iced-tea"),
|
||||
("energy", "energy"),
|
||||
("hydration", "hydration"),
|
||||
("milkshake", "milkshake"),
|
||||
];
|
||||
|
||||
/// Returns (matched category slug, remaining query with keyword removed).
|
||||
pub fn extract_category_slug(query: &str) -> (Option<String>, String) {
|
||||
let lowered = query.to_lowercase();
|
||||
let lowered = lowered.trim();
|
||||
for (kw, slug) in CATEGORY_KEYWORDS {
|
||||
if lowered.contains(kw) {
|
||||
let remaining = lowered.replace(kw, "");
|
||||
return (Some(slug.to_string()), remaining.trim().to_string());
|
||||
}
|
||||
}
|
||||
(None, lowered.to_string())
|
||||
}
|
||||
|
||||
/// Significant (len > 2) words from the remaining query, for icontains matching.
|
||||
pub fn query_words(remaining: &str) -> Vec<String> {
|
||||
remaining
|
||||
.split_whitespace()
|
||||
.filter(|w| w.len() > 2)
|
||||
.map(|w| w.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn score_relevance(name: &str, query: &str) -> i32 {
|
||||
let name = name.to_lowercase();
|
||||
if name == query {
|
||||
3
|
||||
} else if name.starts_with(query) {
|
||||
2
|
||||
} else if name.contains(query) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
//! Assembly of `FlavorOut` (with nested ratings/replies) from entity models,
|
||||
//! batching related lookups to avoid N+1. Mirrors FlavorSerializer +
|
||||
//! RatingSerializer behaviour.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, QueryFilter, QueryOrder, Statement,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut,
|
||||
TicketOut, UserOut,
|
||||
};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{
|
||||
category, flavor, notification, profile_comment, rating, reply, ticket, ticket_message, user,
|
||||
};
|
||||
use crate::error::ApiResult;
|
||||
use crate::media::{flavor_image_list, flavor_primary_image, image_field_url};
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
/// Comma-join i32 ids for an inlined SQL `IN (...)` list. Safe: ints only.
|
||||
fn id_list(ids: &[i32]) -> String {
|
||||
ids.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// Optional avatar → absolute media URL (empty path treated as absent).
|
||||
fn avatar_url(ctx: &RequestCtx, media: &str, avatar: &Option<String>) -> Option<String> {
|
||||
avatar
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|a| image_field_url(ctx, media, a))
|
||||
}
|
||||
|
||||
/// Django JSONField (list) stored as TEXT → Vec<String>, tolerating null/non-array.
|
||||
fn json_string_list(v: &Option<Value>) -> Vec<String> {
|
||||
match v {
|
||||
Some(Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_distribution() -> BTreeMap<String, i64> {
|
||||
(1..=10).map(|i| (i.to_string(), 0i64)).collect()
|
||||
}
|
||||
|
||||
/// Build `ReplyOut`s for a flat list of replies, resolving each author's
|
||||
/// username (StringRelatedField). Mirrors `ReplySerializer`.
|
||||
pub async fn build_replies(
|
||||
state: &AppState,
|
||||
replies: Vec<reply::Model>,
|
||||
) -> ApiResult<Vec<ReplyOut>> {
|
||||
if replies.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut user_ids: Vec<i32> = replies.iter().map(|r| r.user_id).collect();
|
||||
user_ids.sort_unstable();
|
||||
user_ids.dedup();
|
||||
let users = User::find()
|
||||
.filter(user::Column::Id.is_in(user_ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let names: HashMap<i32, String> = users.into_iter().map(|u| (u.id, u.username)).collect();
|
||||
Ok(replies
|
||||
.into_iter()
|
||||
.map(|rp| ReplyOut {
|
||||
id: rp.id,
|
||||
user: names.get(&rp.user_id).cloned().unwrap_or_default(),
|
||||
rating: rp.rating_id,
|
||||
text: rp.text,
|
||||
created_at: crate::datetime::drf_iso(&rp.created_at),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build `RatingOut`s for a flat list of ratings, preserving input order and
|
||||
/// resolving flavor/category/author/replies. Mirrors `RatingSerializer`.
|
||||
/// Used by the rating list/feed/recent/retrieve/create endpoints.
|
||||
pub async fn build_ratings(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
ratings: Vec<rating::Model>,
|
||||
) -> ApiResult<Vec<RatingOut>> {
|
||||
let media = &state.config.media_url;
|
||||
if ratings.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let flavor_ids: Vec<i32> = ratings.iter().map(|r| r.flavor_id).collect();
|
||||
let rating_ids: Vec<i32> = ratings.iter().map(|r| r.id).collect();
|
||||
|
||||
let flavors = Flavor::find()
|
||||
.filter(flavor::Column::Id.is_in(flavor_ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let flavor_map: HashMap<i32, flavor::Model> = flavors.into_iter().map(|f| (f.id, f)).collect();
|
||||
|
||||
let category_ids: Vec<i32> = flavor_map.values().map(|f| f.category_id).collect();
|
||||
let cats = Category::find()
|
||||
.filter(category::Column::Id.is_in(category_ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let cat_map: HashMap<i32, (String, String)> =
|
||||
cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect();
|
||||
|
||||
let replies = Reply::find()
|
||||
.filter(reply::Column::RatingId.is_in(rating_ids))
|
||||
.order_by_asc(reply::Column::CreatedAt)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let mut user_ids: Vec<i32> = ratings.iter().map(|r| r.user_id).collect();
|
||||
user_ids.extend(replies.iter().map(|r| r.user_id));
|
||||
user_ids.sort_unstable();
|
||||
user_ids.dedup();
|
||||
let users = User::find()
|
||||
.filter(user::Column::Id.is_in(user_ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let user_map: HashMap<i32, user::Model> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
|
||||
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
|
||||
for rp in &replies {
|
||||
let username = user_map
|
||||
.get(&rp.user_id)
|
||||
.map(|u| u.username.clone())
|
||||
.unwrap_or_default();
|
||||
replies_by_rating
|
||||
.entry(rp.rating_id)
|
||||
.or_default()
|
||||
.push(ReplyOut {
|
||||
id: rp.id,
|
||||
user: username,
|
||||
rating: rp.rating_id,
|
||||
text: rp.text.clone(),
|
||||
created_at: crate::datetime::drf_iso(&rp.created_at),
|
||||
});
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(ratings.len());
|
||||
for r in &ratings {
|
||||
let user = user_map.get(&r.user_id);
|
||||
let username = user.map(|u| u.username.clone()).unwrap_or_default();
|
||||
let user_avatar = user
|
||||
.and_then(|u| u.avatar.as_deref())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|a| image_field_url(ctx, media, a));
|
||||
|
||||
let fl = flavor_map.get(&r.flavor_id);
|
||||
let (cat_name, cat_slug) = fl
|
||||
.and_then(|f| cat_map.get(&f.category_id))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let flavor_image = fl.and_then(|f| {
|
||||
if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(image_field_url(ctx, media, img))
|
||||
} else {
|
||||
f.image_url.clone()
|
||||
}
|
||||
});
|
||||
|
||||
out.push(RatingOut {
|
||||
id: r.id,
|
||||
user: username,
|
||||
user_id: r.user_id,
|
||||
user_avatar,
|
||||
flavor: r.flavor_id,
|
||||
flavor_name: fl.map(|f| f.name.clone()).unwrap_or_default(),
|
||||
flavor_image,
|
||||
category_name: cat_name,
|
||||
category_slug: cat_slug,
|
||||
is_available: fl.map(|f| f.is_available).unwrap_or(false),
|
||||
is_legacy: fl.map(|f| f.is_legacy).unwrap_or(false),
|
||||
score: r.score,
|
||||
comment: r.comment.clone(),
|
||||
created_at: crate::datetime::drf_iso(&r.created_at),
|
||||
replies: replies_by_rating.remove(&r.id).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn build_flavors(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
flavors: Vec<flavor::Model>,
|
||||
viewer_id: Option<i32>,
|
||||
followed_ids: &[i32],
|
||||
) -> ApiResult<Vec<FlavorOut>> {
|
||||
let media = &state.config.media_url;
|
||||
if flavors.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let flavor_ids: Vec<i32> = flavors.iter().map(|f| f.id).collect();
|
||||
let category_ids: Vec<i32> = flavors.iter().map(|f| f.category_id).collect();
|
||||
|
||||
// Categories
|
||||
let cats = Category::find()
|
||||
.filter(crate::entities::category::Column::Id.is_in(category_ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let cat_map: HashMap<i32, (String, String)> =
|
||||
cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect();
|
||||
|
||||
// Ratings for these flavors (RatingSerializer order: -created_at)
|
||||
let ratings = Rating::find()
|
||||
.filter(rating::Column::FlavorId.is_in(flavor_ids.clone()))
|
||||
.order_by_desc(rating::Column::CreatedAt)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let rating_ids: Vec<i32> = ratings.iter().map(|r| r.id).collect();
|
||||
|
||||
// Replies (order: created_at asc)
|
||||
let replies = if rating_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
Reply::find()
|
||||
.filter(reply::Column::RatingId.is_in(rating_ids))
|
||||
.order_by_asc(reply::Column::CreatedAt)
|
||||
.all(&state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Users referenced by ratings + replies
|
||||
let mut user_ids: Vec<i32> = ratings.iter().map(|r| r.user_id).collect();
|
||||
user_ids.extend(replies.iter().map(|r| r.user_id));
|
||||
user_ids.sort_unstable();
|
||||
user_ids.dedup();
|
||||
let users = if user_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
User::find()
|
||||
.filter(user::Column::Id.is_in(user_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
};
|
||||
let user_map: HashMap<i32, user::Model> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
|
||||
// Replies grouped by rating id
|
||||
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
|
||||
for rp in &replies {
|
||||
let username = user_map
|
||||
.get(&rp.user_id)
|
||||
.map(|u| u.username.clone())
|
||||
.unwrap_or_default();
|
||||
replies_by_rating
|
||||
.entry(rp.rating_id)
|
||||
.or_default()
|
||||
.push(ReplyOut {
|
||||
id: rp.id,
|
||||
user: username,
|
||||
rating: rp.rating_id,
|
||||
text: rp.text.clone(),
|
||||
created_at: crate::datetime::drf_iso(&rp.created_at),
|
||||
});
|
||||
}
|
||||
|
||||
// Flavor lookup for per-rating flavor fields
|
||||
let flavor_map: HashMap<i32, &flavor::Model> = flavors.iter().map(|f| (f.id, f)).collect();
|
||||
|
||||
// Ratings grouped by flavor id
|
||||
let mut ratings_by_flavor: HashMap<i32, Vec<RatingOut>> = HashMap::new();
|
||||
for r in &ratings {
|
||||
let user = user_map.get(&r.user_id);
|
||||
let username = user.map(|u| u.username.clone()).unwrap_or_default();
|
||||
let user_avatar = user
|
||||
.and_then(|u| u.avatar.as_deref())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|a| image_field_url(ctx, media, a));
|
||||
|
||||
let fl = flavor_map.get(&r.flavor_id);
|
||||
let (cat_name, cat_slug) = fl
|
||||
.and_then(|f| cat_map.get(&f.category_id))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let flavor_image = fl.and_then(|f| {
|
||||
if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(image_field_url(ctx, media, img))
|
||||
} else {
|
||||
f.image_url.clone()
|
||||
}
|
||||
});
|
||||
|
||||
ratings_by_flavor
|
||||
.entry(r.flavor_id)
|
||||
.or_default()
|
||||
.push(RatingOut {
|
||||
id: r.id,
|
||||
user: username,
|
||||
user_id: r.user_id,
|
||||
user_avatar,
|
||||
flavor: r.flavor_id,
|
||||
flavor_name: fl.map(|f| f.name.clone()).unwrap_or_default(),
|
||||
flavor_image,
|
||||
category_name: cat_name,
|
||||
category_slug: cat_slug,
|
||||
is_available: fl.map(|f| f.is_available).unwrap_or(false),
|
||||
is_legacy: fl.map(|f| f.is_legacy).unwrap_or(false),
|
||||
score: r.score,
|
||||
comment: r.comment.clone(),
|
||||
created_at: crate::datetime::drf_iso(&r.created_at),
|
||||
replies: replies_by_rating.remove(&r.id).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
// Average + distribution per flavor, from loaded ratings
|
||||
let followed_set: std::collections::HashSet<i32> = followed_ids.iter().copied().collect();
|
||||
let mut avg_by_flavor: HashMap<i32, Option<f64>> = HashMap::new();
|
||||
let mut followed_avg_by_flavor: HashMap<i32, Option<f64>> = HashMap::new();
|
||||
let mut dist_by_flavor: HashMap<i32, BTreeMap<String, i64>> = HashMap::new();
|
||||
for fid in &flavor_ids {
|
||||
let mut dist = empty_distribution();
|
||||
let mut sum = 0i64;
|
||||
let mut n = 0i64;
|
||||
let mut fsum = 0i64;
|
||||
let mut fn_ = 0i64;
|
||||
for r in ratings.iter().filter(|r| r.flavor_id == *fid) {
|
||||
if (1..=10).contains(&r.score) {
|
||||
*dist.get_mut(&r.score.to_string()).unwrap() += 1;
|
||||
}
|
||||
sum += r.score as i64;
|
||||
n += 1;
|
||||
if followed_set.contains(&r.user_id) {
|
||||
fsum += r.score as i64;
|
||||
fn_ += 1;
|
||||
}
|
||||
}
|
||||
avg_by_flavor.insert(
|
||||
*fid,
|
||||
if n == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(sum as f64 / n as f64)
|
||||
},
|
||||
);
|
||||
followed_avg_by_flavor.insert(
|
||||
*fid,
|
||||
if fn_ == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(fsum as f64 / fn_ as f64)
|
||||
},
|
||||
);
|
||||
dist_by_flavor.insert(*fid, dist);
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(flavors.len());
|
||||
for f in &flavors {
|
||||
let (cat_name, cat_slug) = cat_map.get(&f.category_id).cloned().unwrap_or_default();
|
||||
let local_paths = json_string_list(&f.local_image_paths);
|
||||
let image_urls = json_string_list(&f.image_urls);
|
||||
let user_rating = viewer_id.and_then(|vid| {
|
||||
ratings
|
||||
.iter()
|
||||
.find(|r| r.flavor_id == f.id && r.user_id == vid)
|
||||
.map(|r| r.score)
|
||||
});
|
||||
out.push(FlavorOut {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
category: f.category_id,
|
||||
category_name: cat_name,
|
||||
category_slug: cat_slug,
|
||||
description: f.description.clone(),
|
||||
average_rating: avg_by_flavor.get(&f.id).copied().flatten(),
|
||||
followed_average_rating: followed_avg_by_flavor.get(&f.id).copied().flatten(),
|
||||
user_rating,
|
||||
ratings: ratings_by_flavor.remove(&f.id).unwrap_or_default(),
|
||||
image_url: flavor_primary_image(
|
||||
ctx,
|
||||
media,
|
||||
f.main_image_path.as_deref(),
|
||||
&local_paths,
|
||||
f.image.as_deref(),
|
||||
f.image_url.as_deref(),
|
||||
),
|
||||
image_urls: flavor_image_list(
|
||||
ctx,
|
||||
media,
|
||||
&local_paths,
|
||||
f.image.as_deref(),
|
||||
&image_urls,
|
||||
),
|
||||
is_available: f.is_available,
|
||||
is_legacy: f.is_legacy,
|
||||
shop_url: f.shop_url.clone(),
|
||||
rating_distribution: dist_by_flavor
|
||||
.remove(&f.id)
|
||||
.unwrap_or_else(empty_distribution),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `following`/`followers`/`unread` counts keyed by user id, from raw aggregate
|
||||
/// queries. Used by `build_users` to avoid N+1.
|
||||
async fn social_counts(
|
||||
state: &AppState,
|
||||
ids: &[i32],
|
||||
) -> ApiResult<(HashMap<i32, i64>, HashMap<i32, i64>, HashMap<i32, i64>)> {
|
||||
let mut following: HashMap<i32, i64> = HashMap::new();
|
||||
let mut followers: HashMap<i32, i64> = HashMap::new();
|
||||
let mut unread: HashMap<i32, i64> = HashMap::new();
|
||||
if ids.is_empty() {
|
||||
return Ok((following, followers, unread));
|
||||
}
|
||||
let list = id_list(ids);
|
||||
let backend = DbBackend::Sqlite;
|
||||
|
||||
let q = |sql: String| Statement::from_string(backend, sql);
|
||||
|
||||
for row in state
|
||||
.db
|
||||
.query_all(q(format!(
|
||||
"SELECT from_user_id AS uid, COUNT(*) AS c FROM api_user_following \
|
||||
WHERE from_user_id IN ({list}) GROUP BY from_user_id"
|
||||
)))
|
||||
.await?
|
||||
{
|
||||
following.insert(row.try_get("", "uid")?, row.try_get("", "c")?);
|
||||
}
|
||||
for row in state
|
||||
.db
|
||||
.query_all(q(format!(
|
||||
"SELECT to_user_id AS uid, COUNT(*) AS c FROM api_user_following \
|
||||
WHERE to_user_id IN ({list}) GROUP BY to_user_id"
|
||||
)))
|
||||
.await?
|
||||
{
|
||||
followers.insert(row.try_get("", "uid")?, row.try_get("", "c")?);
|
||||
}
|
||||
for row in state
|
||||
.db
|
||||
.query_all(q(format!(
|
||||
"SELECT recipient_id AS uid, COUNT(*) AS c FROM api_notification \
|
||||
WHERE recipient_id IN ({list}) AND is_read = 0 GROUP BY recipient_id"
|
||||
)))
|
||||
.await?
|
||||
{
|
||||
unread.insert(row.try_get("", "uid")?, row.try_get("", "c")?);
|
||||
}
|
||||
Ok((following, followers, unread))
|
||||
}
|
||||
|
||||
/// Set of user ids that `viewer` follows, restricted to `ids`.
|
||||
async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> ApiResult<Vec<i32>> {
|
||||
let Some(v) = viewer else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let list = id_list(ids);
|
||||
let rows = state
|
||||
.db
|
||||
.query_all(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
format!(
|
||||
"SELECT to_user_id AS uid FROM api_user_following \
|
||||
WHERE from_user_id = {v} AND to_user_id IN ({list})"
|
||||
),
|
||||
))
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|r| r.try_get("", "uid").map_err(Into::into))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build `UserOut`s (mirrors `UserSerializer`), batching social counts and the
|
||||
/// viewer's follow set. `viewer` is the authenticated request user, if any.
|
||||
pub async fn build_users(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
users: Vec<user::Model>,
|
||||
viewer: Option<i32>,
|
||||
) -> ApiResult<Vec<UserOut>> {
|
||||
let media = &state.config.media_url;
|
||||
if users.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
|
||||
let (following, followers, unread) = social_counts(state, &ids).await?;
|
||||
let followed: std::collections::HashSet<i32> = following_of(state, viewer, &ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let banner_ids: Vec<i32> = users.iter().filter_map(|u| u.selected_banner_id).collect();
|
||||
let banner_slugs: HashMap<i32, String> = if banner_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
Banner::find()
|
||||
.filter(crate::entities::banner::Column::Id.is_in(banner_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|b| (b.id, b.slug))
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(users
|
||||
.into_iter()
|
||||
.map(|u| UserOut {
|
||||
avatar: avatar_url(ctx, media, &u.avatar),
|
||||
following_count: *following.get(&u.id).unwrap_or(&0),
|
||||
followers_count: *followers.get(&u.id).unwrap_or(&0),
|
||||
is_following: followed.contains(&u.id),
|
||||
unread_notifications_count: *unread.get(&u.id).unwrap_or(&0),
|
||||
is_superuser: u.is_superuser,
|
||||
selected_banner_slug: u
|
||||
.selected_banner_id
|
||||
.and_then(|b| banner_slugs.get(&b).cloned()),
|
||||
selected_banner: u.selected_banner_id,
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
theme: u.theme,
|
||||
language: u.language,
|
||||
drawer_anchor: u.drawer_anchor,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Convenience: build a single `UserOut`.
|
||||
pub async fn build_user(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
u: user::Model,
|
||||
viewer: Option<i32>,
|
||||
) -> ApiResult<UserOut> {
|
||||
let mut v = build_users(state, ctx, vec![u], viewer).await?;
|
||||
v.pop().ok_or(crate::error::ApiError::Internal)
|
||||
}
|
||||
|
||||
/// Build `ProfileCommentOut`s (mirrors `ProfileCommentSerializer`).
|
||||
pub async fn build_profile_comments(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
comments: Vec<profile_comment::Model>,
|
||||
) -> ApiResult<Vec<ProfileCommentOut>> {
|
||||
let media = &state.config.media_url;
|
||||
if comments.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut author_ids: Vec<i32> = comments.iter().map(|c| c.author_id).collect();
|
||||
author_ids.sort_unstable();
|
||||
author_ids.dedup();
|
||||
let authors: HashMap<i32, user::Model> = User::find()
|
||||
.filter(user::Column::Id.is_in(author_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.id, u))
|
||||
.collect();
|
||||
Ok(comments
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let a = authors.get(&c.author_id);
|
||||
ProfileCommentOut {
|
||||
id: c.id,
|
||||
author_username: a.map(|u| u.username.clone()).unwrap_or_default(),
|
||||
author_avatar: a.and_then(|u| avatar_url(ctx, media, &u.avatar)),
|
||||
text: c.text,
|
||||
created_at: crate::datetime::drf_iso(&c.created_at),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build `NotificationOut`s (mirrors `NotificationSerializer`), resolving actor,
|
||||
/// flavor (via rating or reply→rating) and profile-comment owner.
|
||||
pub async fn build_notifications(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
notes: Vec<notification::Model>,
|
||||
) -> ApiResult<Vec<NotificationOut>> {
|
||||
let media = &state.config.media_url;
|
||||
if notes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let reply_ids: Vec<i32> = notes.iter().filter_map(|n| n.reply_id).collect();
|
||||
let replies: HashMap<i32, reply::Model> = if reply_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
Reply::find()
|
||||
.filter(reply::Column::Id.is_in(reply_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| (r.id, r))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Rating ids referenced directly or via a reply.
|
||||
let mut rating_ids: Vec<i32> = notes.iter().filter_map(|n| n.rating_id).collect();
|
||||
rating_ids.extend(replies.values().map(|r| r.rating_id));
|
||||
rating_ids.sort_unstable();
|
||||
rating_ids.dedup();
|
||||
let ratings: HashMap<i32, rating::Model> = if rating_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
Rating::find()
|
||||
.filter(rating::Column::Id.is_in(rating_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| (r.id, r))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let flavor_ids: Vec<i32> = ratings.values().map(|r| r.flavor_id).collect();
|
||||
let flavors: HashMap<i32, flavor::Model> = if flavor_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
Flavor::find()
|
||||
.filter(flavor::Column::Id.is_in(flavor_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|f| (f.id, f))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let pc_ids: Vec<i32> = notes.iter().filter_map(|n| n.profile_comment_id).collect();
|
||||
let pcs: HashMap<i32, profile_comment::Model> = if pc_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
ProfileComment::find()
|
||||
.filter(profile_comment::Column::Id.is_in(pc_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|c| (c.id, c))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut user_ids: Vec<i32> = notes.iter().map(|n| n.actor_id).collect();
|
||||
user_ids.extend(pcs.values().map(|c| c.profile_owner_id));
|
||||
user_ids.sort_unstable();
|
||||
user_ids.dedup();
|
||||
let users: HashMap<i32, user::Model> = User::find()
|
||||
.filter(user::Column::Id.is_in(user_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.id, u))
|
||||
.collect();
|
||||
|
||||
// Resolve the flavor (name, id) a notification points at.
|
||||
let flavor_of = |n: ¬ification::Model| -> Option<&flavor::Model> {
|
||||
if let Some(rid) = n.rating_id {
|
||||
return ratings.get(&rid).and_then(|r| flavors.get(&r.flavor_id));
|
||||
}
|
||||
if let Some(rep_id) = n.reply_id {
|
||||
let rep = replies.get(&rep_id)?;
|
||||
return ratings
|
||||
.get(&rep.rating_id)
|
||||
.and_then(|r| flavors.get(&r.flavor_id));
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
Ok(notes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let actor = users.get(&n.actor_id);
|
||||
let fl = flavor_of(n);
|
||||
NotificationOut {
|
||||
id: n.id,
|
||||
actor_username: actor.map(|u| u.username.clone()).unwrap_or_default(),
|
||||
actor_avatar: actor.and_then(|u| avatar_url(ctx, media, &u.avatar)),
|
||||
notification_type: n.notification_type.clone(),
|
||||
rating: n.rating_id,
|
||||
reply: n.reply_id,
|
||||
ticket: n.ticket_id,
|
||||
profile_comment: n.profile_comment_id,
|
||||
profile_owner_username: n
|
||||
.profile_comment_id
|
||||
.and_then(|id| pcs.get(&id))
|
||||
.and_then(|c| users.get(&c.profile_owner_id))
|
||||
.map(|u| u.username.clone()),
|
||||
is_read: n.is_read,
|
||||
created_at: crate::datetime::drf_iso(&n.created_at),
|
||||
flavor_name: fl.map(|f| f.name.clone()),
|
||||
flavor_id: fl.map(|f| f.id),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build a single `TicketMessageOut` when the author is already loaded
|
||||
/// (used by the `add_message` action's 201 response).
|
||||
pub fn ticket_message_out(msg: ticket_message::Model, author: &user::Model) -> TicketMessageOut {
|
||||
TicketMessageOut {
|
||||
id: msg.id,
|
||||
username: author.username.clone(),
|
||||
text: msg.text,
|
||||
created_at: crate::datetime::drf_iso(&msg.created_at),
|
||||
is_admin: author.is_superuser,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `TicketOut`s (mirrors `TicketSerializer` + nested `TicketMessageSerializer`).
|
||||
pub async fn build_tickets(
|
||||
state: &AppState,
|
||||
ctx: &RequestCtx,
|
||||
tickets: Vec<ticket::Model>,
|
||||
) -> ApiResult<Vec<TicketOut>> {
|
||||
let media = &state.config.media_url;
|
||||
if tickets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ticket_ids: Vec<i32> = tickets.iter().map(|t| t.id).collect();
|
||||
let messages = TicketMessage::find()
|
||||
.filter(ticket_message::Column::TicketId.is_in(ticket_ids))
|
||||
.order_by_asc(ticket_message::Column::CreatedAt)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let mut user_ids: Vec<i32> = tickets.iter().map(|t| t.user_id).collect();
|
||||
user_ids.extend(messages.iter().map(|m| m.user_id));
|
||||
user_ids.sort_unstable();
|
||||
user_ids.dedup();
|
||||
let users: HashMap<i32, user::Model> = User::find()
|
||||
.filter(user::Column::Id.is_in(user_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.id, u))
|
||||
.collect();
|
||||
|
||||
let mut msgs_by_ticket: HashMap<i32, Vec<TicketMessageOut>> = HashMap::new();
|
||||
for m in messages {
|
||||
let u = users.get(&m.user_id);
|
||||
msgs_by_ticket
|
||||
.entry(m.ticket_id)
|
||||
.or_default()
|
||||
.push(TicketMessageOut {
|
||||
id: m.id,
|
||||
username: u.map(|x| x.username.clone()).unwrap_or_default(),
|
||||
text: m.text,
|
||||
created_at: crate::datetime::drf_iso(&m.created_at),
|
||||
is_admin: u.map(|x| x.is_superuser).unwrap_or(false),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tickets
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let u = users.get(&t.user_id);
|
||||
TicketOut {
|
||||
user: t.user_id,
|
||||
username: u.map(|x| x.username.clone()).unwrap_or_default(),
|
||||
user_email: u.map(|x| x.email.clone()).unwrap_or_default(),
|
||||
user_avatar: u.and_then(|x| avatar_url(ctx, media, &x.avatar)),
|
||||
subject: t.subject,
|
||||
description: t.description,
|
||||
status: t.status,
|
||||
created_at: crate::datetime::drf_iso(&t.created_at),
|
||||
updated_at: crate::datetime::drf_iso(&t.updated_at),
|
||||
messages: msgs_by_ticket.remove(&t.id).unwrap_or_default(),
|
||||
id: t.id,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::throttle::Security;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: DatabaseConnection,
|
||||
pub config: Config,
|
||||
/// Names of jobs currently executing. Guards against a manual trigger and
|
||||
/// the scheduler (or two triggers) running the same job concurrently —
|
||||
/// these jobs mutate shared catalog rows and are not safe to overlap.
|
||||
pub running_jobs: Arc<Mutex<HashSet<String>>>,
|
||||
/// Rate limiting + confirmation-code TTL/attempt state (replaces
|
||||
/// `django_ratelimit`). Shared across handlers via `Arc`.
|
||||
pub security: Arc<Security>,
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! In-process rate limiting + confirmation-code hardening.
|
||||
//!
|
||||
//! Replaces what Django got from `django_ratelimit` (request throttling) and
|
||||
//! adds two protections the Django app lacked: a per-account attempt lockout on
|
||||
//! the code-verifying endpoints, and a TTL on the 6-digit confirmation codes
|
||||
//! (`email_confirmation_code` has no expiry column in the shared schema, so the
|
||||
//! TTL is tracked here in memory).
|
||||
//!
|
||||
//! Everything is in-memory. Two consequences worth knowing:
|
||||
//! - A process restart resets the counters (fail-open) — acceptable: the worst
|
||||
//! case is a brute-force attacker getting their window reset, still bounded by
|
||||
//! the very low per-hour limits, and the strangler runs a single Rust process.
|
||||
//! - Code expiry is best-effort: `code_expired` only reports `true` when we hold
|
||||
//! a record that is past its TTL. An unknown code (minted before a restart, or
|
||||
//! by Django directly) falls through to the existing DB-equality check, so a
|
||||
//! legitimate user is never locked out worse than Django did.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
/// A request rate, mirroring django_ratelimit's `N/period` notation.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Rate {
|
||||
pub limit: usize,
|
||||
pub window: Duration,
|
||||
}
|
||||
|
||||
impl Rate {
|
||||
pub const fn per_minute(limit: usize) -> Self {
|
||||
Rate {
|
||||
limit,
|
||||
window: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
pub const fn per_hour(limit: usize) -> Self {
|
||||
Rate {
|
||||
limit,
|
||||
window: Duration::from_secs(3600),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Django-parity request limits (key + rate match api/views) -------------
|
||||
/// `signup` — django_ratelimit key="ip" rate="5/h".
|
||||
pub const SIGNUP: Rate = Rate::per_hour(5);
|
||||
/// `request_password_reset` — key="ip" rate="3/h".
|
||||
pub const PASSWORD_RESET_REQUEST: Rate = Rate::per_hour(3);
|
||||
/// `complete_password_reset` — key="ip" rate="5/h".
|
||||
pub const PASSWORD_RESET_COMPLETE: Rate = Rate::per_hour(5);
|
||||
/// `request_account_deletion` — key="user" rate="3/h".
|
||||
pub const ACCOUNT_DELETION_REQUEST: Rate = Rate::per_hour(3);
|
||||
/// `RatingViewSet.create` — key="user" rate="10/m".
|
||||
pub const RATING_CREATE: Rate = Rate::per_minute(10);
|
||||
|
||||
// --- Added hardening (no Django equivalent) --------------------------------
|
||||
/// Login brute-force guard, keyed by IP (Django had none).
|
||||
pub const LOGIN: Rate = Rate::per_minute(10);
|
||||
/// Verification-email resends, keyed by IP — caps the email-bomb vector.
|
||||
pub const RESEND_VERIFICATION: Rate = Rate::per_hour(5);
|
||||
/// Per-account attempt lockout on the code-verifying endpoints. Generous enough
|
||||
/// that a real user fat-fingering a code never trips it, low enough to make the
|
||||
/// 6-digit space (1e6) infeasible even for an attacker rotating source IPs.
|
||||
pub const CODE_ATTEMPTS: Rate = Rate::per_hour(10);
|
||||
|
||||
/// How long a freshly minted confirmation code stays valid.
|
||||
pub const CODE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Shared security state: a sliding-window request log plus the code-mint clock.
|
||||
#[derive(Default)]
|
||||
pub struct Security {
|
||||
/// key -> timestamps of recent hits (oldest first).
|
||||
hits: Mutex<HashMap<String, VecDeque<Instant>>>,
|
||||
/// confirmation-code key -> mint time.
|
||||
codes: Mutex<HashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
impl Security {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record a hit on `key` and reject (429) if it exceeds `rate`. Uses a
|
||||
/// sliding-window log so a burst can't straddle a fixed boundary.
|
||||
pub fn check_rate(&self, key: &str, rate: Rate) -> Result<(), ApiError> {
|
||||
let now = Instant::now();
|
||||
let mut map = self.hits.lock().unwrap();
|
||||
let log = map.entry(key.to_string()).or_default();
|
||||
// Drop hits that have aged out of the window.
|
||||
while let Some(&front) = log.front() {
|
||||
if now.duration_since(front) >= rate.window {
|
||||
log.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if log.len() >= rate.limit {
|
||||
// Retry once the oldest in-window hit expires.
|
||||
let retry_after = log
|
||||
.front()
|
||||
.map(|&t| rate.window.saturating_sub(now.duration_since(t)))
|
||||
.unwrap_or(rate.window);
|
||||
return Err(ApiError::RateLimited {
|
||||
retry_after: retry_after.as_secs().max(1),
|
||||
});
|
||||
}
|
||||
log.push_back(now);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Note that a confirmation code was just minted for `key`.
|
||||
pub fn record_code(&self, key: &str) {
|
||||
self.codes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), Instant::now());
|
||||
}
|
||||
|
||||
/// True only when we hold a mint time for `key` that is older than `CODE_TTL`.
|
||||
/// Unknown keys return `false` (fall through to the DB-equality check).
|
||||
pub fn code_expired(&self, key: &str) -> bool {
|
||||
self.codes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(key)
|
||||
.is_some_and(|&t| t.elapsed() >= CODE_TTL)
|
||||
}
|
||||
|
||||
/// Forget a code after it has been consumed (or invalidated).
|
||||
pub fn clear_code(&self, key: &str) {
|
||||
self.codes.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
/// Drop empty/expired buckets so the maps don't grow unbounded. Called
|
||||
/// periodically from a background task.
|
||||
pub fn gc(&self) {
|
||||
let now = Instant::now();
|
||||
// Longest request window in use is one hour; reuse it as the sweep horizon.
|
||||
let max_window = Duration::from_secs(3600);
|
||||
let mut hits = self.hits.lock().unwrap();
|
||||
hits.retain(|_, log| {
|
||||
while let Some(&front) = log.front() {
|
||||
if now.duration_since(front) >= max_window {
|
||||
log.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
!log.is_empty()
|
||||
});
|
||||
drop(hits);
|
||||
let mut codes = self.codes.lock().unwrap();
|
||||
codes.retain(|_, &mut t| t.elapsed() < CODE_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allows_up_to_limit_then_blocks() {
|
||||
let s = Security::new();
|
||||
let rate = Rate::per_hour(3);
|
||||
for _ in 0..3 {
|
||||
assert!(s.check_rate("k", rate).is_ok());
|
||||
}
|
||||
let err = s.check_rate("k", rate).unwrap_err();
|
||||
assert!(matches!(err, ApiError::RateLimited { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_independent() {
|
||||
let s = Security::new();
|
||||
let rate = Rate::per_hour(1);
|
||||
assert!(s.check_rate("a", rate).is_ok());
|
||||
assert!(s.check_rate("b", rate).is_ok());
|
||||
assert!(s.check_rate("a", rate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_is_not_expired() {
|
||||
let s = Security::new();
|
||||
assert!(!s.code_expired("missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_code_is_fresh() {
|
||||
let s = Security::new();
|
||||
s.record_code("k");
|
||||
assert!(!s.code_expired("k"));
|
||||
s.clear_code("k");
|
||||
assert!(!s.code_expired("k"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! SimpleJWT-compatible token issuance.
|
||||
//!
|
||||
//! Mirrors `rest_framework_simplejwt`: HS256 signed with `SECRET_KEY`, claim set
|
||||
//! `{ token_type, exp, iat, jti, user_id }`, `jti` a uuid4 hex string. Lifetimes
|
||||
//! match `settings.SIMPLE_JWT` (access 60 min, refresh 14 days). Tokens minted
|
||||
//! here verify in Django and vice versa, so the strangler proxy can route auth
|
||||
//! to Rust while other endpoints stay on Django.
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const ACCESS_LIFETIME_SECS: i64 = 60 * 60; // ACCESS_TOKEN_LIFETIME = 60 min
|
||||
pub const REFRESH_LIFETIME_SECS: i64 = 14 * 24 * 60 * 60; // REFRESH_TOKEN_LIFETIME = 14 days
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TokenClaims {
|
||||
pub token_type: String,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
pub jti: String,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
impl TokenClaims {
|
||||
/// Convert an `exp` epoch into a naive-UTC datetime (for blacklist rows).
|
||||
pub fn expires_naive(exp: i64) -> NaiveDateTime {
|
||||
epoch_to_naive(exp)
|
||||
}
|
||||
}
|
||||
|
||||
/// A freshly minted token plus the fields needed to record it (jti/exp).
|
||||
pub struct Minted {
|
||||
pub token: String,
|
||||
pub jti: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
impl Minted {
|
||||
/// `expires_at` as a naive-UTC datetime, for the outstanding-token row.
|
||||
pub fn expires_at(&self) -> NaiveDateTime {
|
||||
epoch_to_naive(self.exp)
|
||||
}
|
||||
/// `created_at` (== iat) as a naive-UTC datetime.
|
||||
pub fn created_at(&self) -> NaiveDateTime {
|
||||
epoch_to_naive(self.iat)
|
||||
}
|
||||
}
|
||||
|
||||
fn mint(secret: &str, user_id: i32, token_type: &str, lifetime: i64) -> Minted {
|
||||
let now = Utc::now().timestamp();
|
||||
let exp = now + lifetime;
|
||||
let jti = Uuid::new_v4().simple().to_string(); // uuid4().hex — 32 hex chars
|
||||
let claims = TokenClaims {
|
||||
token_type: token_type.to_string(),
|
||||
exp,
|
||||
iat: now,
|
||||
jti: jti.clone(),
|
||||
user_id,
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.expect("HS256 encode never fails for serializable claims");
|
||||
Minted {
|
||||
token,
|
||||
jti,
|
||||
iat: now,
|
||||
exp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mint_access(secret: &str, user_id: i32) -> Minted {
|
||||
mint(secret, user_id, "access", ACCESS_LIFETIME_SECS)
|
||||
}
|
||||
|
||||
pub fn mint_refresh(secret: &str, user_id: i32) -> Minted {
|
||||
mint(secret, user_id, "refresh", REFRESH_LIFETIME_SECS)
|
||||
}
|
||||
|
||||
fn decode_claims(token: &str, secret: &str) -> Option<TokenClaims> {
|
||||
let mut v = Validation::new(Algorithm::HS256);
|
||||
v.validate_aud = false;
|
||||
decode::<TokenClaims>(token, &DecodingKey::from_secret(secret.as_bytes()), &v)
|
||||
.ok()
|
||||
.map(|d| d.claims)
|
||||
}
|
||||
|
||||
/// Verify signature + `exp` and require `token_type == "refresh"`.
|
||||
pub fn verify_refresh(token: &str, secret: &str) -> Option<TokenClaims> {
|
||||
let claims = decode_claims(token, secret)?;
|
||||
(claims.token_type == "refresh").then_some(claims)
|
||||
}
|
||||
|
||||
/// Verify signature + `exp` for any token type (matches SimpleJWT's
|
||||
/// `UntypedToken` used by the verify endpoint).
|
||||
pub fn verify_any(token: &str, secret: &str) -> Option<TokenClaims> {
|
||||
decode_claims(token, secret)
|
||||
}
|
||||
|
||||
fn epoch_to_naive(secs: i64) -> NaiveDateTime {
|
||||
DateTime::<Utc>::from_timestamp(secs, 0)
|
||||
.unwrap_or_default()
|
||||
.naive_utc()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SECRET: &str = "test-secret-matching-django";
|
||||
|
||||
#[test]
|
||||
fn access_round_trips_with_correct_claims() {
|
||||
let minted = mint_access(SECRET, 42);
|
||||
let claims = verify_any(&minted.token, SECRET).expect("valid access token");
|
||||
assert_eq!(claims.token_type, "access");
|
||||
assert_eq!(claims.user_id, 42);
|
||||
assert_eq!(claims.jti, minted.jti);
|
||||
assert_eq!(claims.exp - claims.iat, ACCESS_LIFETIME_SECS);
|
||||
assert_eq!(claims.jti.len(), 32); // uuid4().hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_round_trips_and_is_typed() {
|
||||
let minted = mint_refresh(SECRET, 7);
|
||||
let claims = verify_refresh(&minted.token, SECRET).expect("valid refresh token");
|
||||
assert_eq!(claims.token_type, "refresh");
|
||||
assert_eq!(claims.exp - claims.iat, REFRESH_LIFETIME_SECS);
|
||||
// An access token must not satisfy verify_refresh.
|
||||
let access = mint_access(SECRET, 7);
|
||||
assert!(verify_refresh(&access.token, SECRET).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_secret() {
|
||||
let minted = mint_access(SECRET, 1);
|
||||
assert!(verify_any(&minted.token, "other-secret").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_token() {
|
||||
let mut t = mint_access(SECRET, 1).token;
|
||||
// Flip a character in the payload segment.
|
||||
let mid = t.len() / 2;
|
||||
let ch = if &t[mid..=mid] == "A" { "B" } else { "A" };
|
||||
t.replace_range(mid..=mid, ch);
|
||||
assert!(verify_any(&t, SECRET).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Per-request context used to rebuild absolute URLs the way DRF's
|
||||
//! `request.build_absolute_uri()` does (scheme + host from the incoming
|
||||
//! request). Behind the strangler nginx proxy, `X-Forwarded-Proto` / `Host`
|
||||
//! carry the public values.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::HeaderMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// The client IP, mirroring Django's `log_user_ip`: prefer the first
|
||||
/// `X-Forwarded-For` hop (set by the strangler proxy), else the peer address
|
||||
/// (Django's `REMOTE_ADDR`). Used for IP logging and IP-keyed rate limiting.
|
||||
pub fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| peer.ip().to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestCtx {
|
||||
pub scheme: String,
|
||||
pub host: String,
|
||||
/// Full path + query of the current request (for pagination next/prev links).
|
||||
pub full_path: String,
|
||||
}
|
||||
|
||||
impl RequestCtx {
|
||||
/// Absolute URL for a root-relative path like "/media/x.jpg".
|
||||
pub fn absolute(&self, path: &str) -> String {
|
||||
let path = if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
};
|
||||
format!("{}://{}{}", self.scheme, self.host, path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for RequestCtx {
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let h = &parts.headers;
|
||||
let scheme = h
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
let host = h
|
||||
.get("x-forwarded-host")
|
||||
.or_else(|| h.get("host"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("localhost")
|
||||
.to_string();
|
||||
// Nested routers rewrite `parts.uri` to the router-relative path, which
|
||||
// would drop the `/api` prefix from pagination links. OriginalUri holds
|
||||
// the full incoming path.
|
||||
let uri = parts
|
||||
.extensions
|
||||
.get::<axum::extract::OriginalUri>()
|
||||
.map(|o| &o.0)
|
||||
.unwrap_or(&parts.uri);
|
||||
let full_path = uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str().to_string())
|
||||
.unwrap_or_else(|| uri.path().to_string());
|
||||
Ok(RequestCtx {
|
||||
scheme,
|
||||
host,
|
||||
full_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
+32
-124
@@ -1,137 +1,45 @@
|
||||
services:
|
||||
# --- Redis (Celery broker + result backend) ---
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: holy-redis
|
||||
command: redis-server --appendonly yes --save 60 1
|
||||
restart: unless-stopped
|
||||
sysctls:
|
||||
- net.core.somaxconn=512
|
||||
- vm.overcommit_memory=1
|
||||
networks:
|
||||
- holy-net
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# --- Backend Service ---
|
||||
backend:
|
||||
# --- Rust backend --------------------------------------------------------
|
||||
# The API. Owns db.sqlite3, media/, banners/ and legacy/ via bind mounts.
|
||||
# An in-process scheduler runs the jobs that Celery beat used to (gated by
|
||||
# ENABLE_SCHEDULER — exactly one process must own api_job). SECRET_KEY signs
|
||||
# the SimpleJWT-compatible tokens.
|
||||
backend-rs:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: holy-backend
|
||||
context: ./backend-rs
|
||||
container_name: holy-rust
|
||||
user: "app"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- holy-net
|
||||
ports:
|
||||
- 8000
|
||||
- 8001
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./legacy:/app/legacy
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
- 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}
|
||||
# Rust owns api_job scheduling (Celery beat retired).
|
||||
- ENABLE_SCHEDULER=${RUST_ENABLE_SCHEDULER:-true}
|
||||
- RUST_LOG=${RUST_LOG:-info,holy_backend_rs=info,sea_orm=warn}
|
||||
volumes:
|
||||
- ./backend:/srv/backend
|
||||
- ./legacy:/srv/legacy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: holy-worker
|
||||
command: celery -A holy_backend worker -l info --concurrency=2
|
||||
user: "app"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- holy-net
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
- SKIP_INIT=true
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./legacy:/app/legacy
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "celery -A holy_backend inspect ping -d celery@$$HOSTNAME || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
beat:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: holy-beat
|
||||
command: celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile /tmp/celerybeat.pid
|
||||
user: "app"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- holy-net
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
- SKIP_INIT=true
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./legacy:/app/legacy
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -f /tmp/celerybeat.pid && kill -0 $$(cat /tmp/celerybeat.pid) || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
flower:
|
||||
image: mher/flower:2.0
|
||||
container_name: holy-flower
|
||||
restart: unless-stopped
|
||||
command: celery --broker=${CELERY_BROKER_URL:-redis://redis:6379/0} flower --port=5555
|
||||
environment:
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
- FLOWER_BASIC_AUTH=${FLOWER_BASIC_AUTH:-admin:change-me}
|
||||
ports:
|
||||
- 5555
|
||||
networks:
|
||||
- holy-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
start_period: 20s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -151,14 +59,14 @@ services:
|
||||
- holy-net
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Serve /media/ straight from the shared mount (read-only) so media is
|
||||
# independent of the backend being up.
|
||||
- ./backend/media:/usr/share/nginx/media:ro
|
||||
depends_on:
|
||||
backend:
|
||||
backend-rs:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
holy-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
+4
-37
@@ -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: serves the SPA and proxies /api to the Rust backend.
|
||||
# proxy_params.conf holds shared proxy headers.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY proxy_params.conf /etc/nginx/proxy_params.conf
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -1,13 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
# The backend hostname is 'holy-backend' based on docker-compose.yml
|
||||
# We wait for port 8000 to be open
|
||||
echo "Waiting for backend (holy-backend:8000) to be available..."
|
||||
# Wait for the Rust backend (holy-rust:8001) before serving.
|
||||
echo "Waiting for backend (holy-rust:8001) to be available..."
|
||||
|
||||
MAX_RETRIES=30
|
||||
COUNT=0
|
||||
|
||||
while ! nc -z holy-backend 8000; do
|
||||
while ! nc -z holy-rust 8001; do
|
||||
COUNT=$((COUNT + 1))
|
||||
if [ $COUNT -ge $MAX_RETRIES ]; then
|
||||
echo "Backend timed out after $MAX_RETRIES attempts - starting Nginx anyway"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# =========================================================================
|
||||
# Frontend nginx: serves the SPA, proxies /api to the Rust backend
|
||||
# (holy-rust:8001), and serves /media straight from the shared bind mount.
|
||||
# The per-group /api locations are kept explicit (one proxy_pass each) for
|
||||
# readability; all point at rust_backend. The catch-all /api/ covers anything
|
||||
# not listed.
|
||||
# =========================================================================
|
||||
|
||||
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 -> Rust backend ---------------------------------------------
|
||||
# Longest-prefix match wins; these explicit groups and the catch-all
|
||||
# /api/ all resolve to rust_backend.
|
||||
location /api/categories/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location /api/flavors/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location /api/banners/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
|
||||
location /api/auth/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location = /api/token/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; } # legacy JWT alias
|
||||
location /api/ratings/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location /api/replies/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location /api/users/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location /api/notifications/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
location /api/tickets/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
|
||||
location /api/admin-custom/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
|
||||
# OpenAPI schema / Swagger UI:
|
||||
location /api/schema { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||
|
||||
# Catch-all for any /api path not listed above.
|
||||
location /api/ {
|
||||
proxy_pass http://rust_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;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"schema:export": "cd ../backend && ./venv/bin/python manage.py spectacular --file schema.yaml",
|
||||
"schema:export": "curl -fsS http://localhost:8001/api/schema/ -o ../backend/schema.yaml",
|
||||
"openapi:gen": "openapi-typescript ../backend/schema.yaml -o src/api/schema.d.ts",
|
||||
"openapi:sync": "npm run schema:export && npm run openapi:gen"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
+23
-8
@@ -1,7 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# This script creates a full snapshot of the database and user media.
|
||||
# Best run as a daily cron job.
|
||||
# Full snapshot of the SQLite database + user media. Host-side and safe to run
|
||||
# while the Rust backend has the DB open (WAL mode): uses `sqlite3 .backup`
|
||||
# when available, otherwise an online copy via the same backend container.
|
||||
# The Rust scheduler also runs backup_db on its own interval; this is the
|
||||
# manual/cron supplement. Best run as a daily cron job.
|
||||
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
cd "$ROOT_DIR"
|
||||
@@ -10,6 +14,7 @@ TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="full_snapshot_$TIMESTAMP"
|
||||
TEMP_DIR="backend/backups/$BACKUP_NAME"
|
||||
FINAL_DEST="backend/backups"
|
||||
DB_SRC="backend/db.sqlite3"
|
||||
|
||||
echo "========================================"
|
||||
echo " Starting Full Project Backup... "
|
||||
@@ -17,19 +22,29 @@ echo "========================================"
|
||||
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
# 1. Run the database backup command
|
||||
# 1. Database — consistent snapshot.
|
||||
echo "Backing up database..."
|
||||
docker exec holy-backend python manage.py backup_db --output "/app/backups/$BACKUP_NAME/db.sqlite3"
|
||||
if command -v sqlite3 &>/dev/null; then
|
||||
# Atomic, WAL-safe even with the backend holding the DB open.
|
||||
sqlite3 "$DB_SRC" ".backup '$TEMP_DIR/db.sqlite3'"
|
||||
else
|
||||
# No host sqlite3: copy the DB plus its WAL sidecars so an in-flight WAL
|
||||
# isn't lost. Install sqlite3 for a guaranteed-atomic snapshot.
|
||||
echo " host sqlite3 not found — copying db + WAL sidecars"
|
||||
cp "$DB_SRC" "$TEMP_DIR/db.sqlite3"
|
||||
[ -f "$DB_SRC-wal" ] && cp "$DB_SRC-wal" "$TEMP_DIR/db.sqlite3-wal"
|
||||
[ -f "$DB_SRC-shm" ] && cp "$DB_SRC-shm" "$TEMP_DIR/db.sqlite3-shm"
|
||||
fi
|
||||
|
||||
# 2. Copy the media folder
|
||||
# 2. Media.
|
||||
echo "Backing up media..."
|
||||
cp -r backend/media "$TEMP_DIR/media"
|
||||
|
||||
# 3. Create compressed archive
|
||||
# 3. Compress.
|
||||
echo "Compressing..."
|
||||
tar -czf "$FINAL_DEST/$BACKUP_NAME.tar.gz" -C "backend/backups" "$BACKUP_NAME"
|
||||
tar -czf "$FINAL_DEST/$BACKUP_NAME.tar.gz" -C "$FINAL_DEST" "$BACKUP_NAME"
|
||||
|
||||
# 4. Cleanup temp folder
|
||||
# 4. Cleanup temp folder.
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo ""
|
||||
|
||||
+7
-24
@@ -3,11 +3,6 @@
|
||||
# Get the root directory of the project
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Set environment variables for non-interactive backend checks
|
||||
export SECRET_KEY="pre-push-check-key"
|
||||
export DEBUG="false"
|
||||
export ALLOWED_HOSTS="localhost"
|
||||
|
||||
echo "========================================"
|
||||
echo " Running Pre-Push Integrity Checks "
|
||||
echo "========================================"
|
||||
@@ -23,32 +18,20 @@ if ! npm run build; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Backend Check
|
||||
# 2. Rust backend checks
|
||||
echo ""
|
||||
echo "Step 2: Checking Backend Integrity..."
|
||||
cd "$ROOT_DIR/backend"
|
||||
echo "Step 2: Checking Rust backend..."
|
||||
cd "$ROOT_DIR/backend-rs"
|
||||
|
||||
# Detect python executable (prefer venv if exists)
|
||||
if [ -f "venv/bin/python" ]; then
|
||||
PYTHON_EXEC="venv/bin/python"
|
||||
elif command -v python3 &>/dev/null; then
|
||||
PYTHON_EXEC="python3"
|
||||
else
|
||||
PYTHON_EXEC="python"
|
||||
fi
|
||||
|
||||
# Check for missing migrations
|
||||
if ! $PYTHON_EXEC manage.py makemigrations --check --dry-run &>/dev/null; then
|
||||
if ! cargo fmt --check; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Missing migrations detected."
|
||||
echo " Please run 'python manage.py makemigrations' locally."
|
||||
echo "❌ ERROR: Rust formatting issues. Run 'cargo fmt' in backend-rs/."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run Django system checks
|
||||
if ! $PYTHON_EXEC manage.py check; then
|
||||
if ! cargo check --locked; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Django system check failed."
|
||||
echo "❌ ERROR: Rust backend does not compile."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user