mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
rust fmt, updated ci, hooks and docs
This commit is contained in:
1 parent
8c120ae78d
commit
a4c9b5bad1
39 files changed
+679
-890
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.
|
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
|
||||||
DOMAIN="localhost"
|
DOMAIN="localhost"
|
||||||
|
|
||||||
# Set to a long, random string in production (e.g. 50+ chars). REQUIRED in prod.
|
# Signs the SimpleJWT-compatible tokens. Set to a long, random string in
|
||||||
SECRET_KEY="django-insecure-replace-this-in-production"
|
# 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,
|
# --- Background jobs (Rust in-process scheduler) ---
|
||||||
# holy_backend.settings.prod in production (Docker compose sets this).
|
# Lets the backend own api_job scheduling. EXACTLY ONE running instance may set
|
||||||
DJANGO_SETTINGS_MODULE=holy_backend.settings.dev
|
# this true, or scheduled jobs fire twice against the shared DB.
|
||||||
|
RUST_ENABLE_SCHEDULER=true
|
||||||
|
|
||||||
# Set to "false" in production for security and performance
|
# Set `Secure` on auth cookies (true in production).
|
||||||
DEBUG=true
|
JWT_AUTH_COOKIE_SECURE=true
|
||||||
|
|
||||||
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
# tracing/log filter for the Rust backend.
|
||||||
ADMIN_URL="admin"
|
# RUST_LOG=info,holy_backend_rs=info,sea_orm=warn
|
||||||
|
|
||||||
# --- 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
|
|
||||||
|
|
||||||
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
|
# --- 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
|
# CORS_ALLOWED_ORIGINS=https://example.com
|
||||||
# FRONTEND_URL=https://example.com
|
# FRONTEND_URL=https://example.com
|
||||||
# VITE_API_URL=https://example.com/api/
|
# VITE_API_URL=https://example.com/api/
|
||||||
|
|||||||
+22
-26
@@ -51,39 +51,35 @@ jobs:
|
|||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
SECRET_KEY: "ci-test-key"
|
SECRET_KEY: "ci-test-key"
|
||||||
DEBUG: "false"
|
|
||||||
ALLOWED_HOSTS: "localhost"
|
|
||||||
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Rust
|
||||||
uses: actions/setup-python@v4
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
python-version: '3.14.3'
|
components: rustfmt, clippy
|
||||||
cache: 'pip'
|
|
||||||
cache-dependency-path: backend/requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Cache cargo
|
||||||
run: pip install -r requirements-dev.txt
|
uses: actions/cache@v4
|
||||||
working-directory: backend
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
backend-rs/target
|
||||||
|
key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Ruff Lint
|
- name: Format Check
|
||||||
run: ruff check .
|
run: cargo fmt --check
|
||||||
working-directory: backend
|
working-directory: backend-rs
|
||||||
|
|
||||||
- name: Ruff Format Check
|
- name: Clippy
|
||||||
run: ruff format --check .
|
run: cargo clippy --all-targets
|
||||||
working-directory: backend
|
working-directory: backend-rs
|
||||||
|
|
||||||
- name: Django System Check
|
- name: Build
|
||||||
run: python manage.py check
|
run: cargo build --locked
|
||||||
working-directory: backend
|
working-directory: backend-rs
|
||||||
|
|
||||||
- name: Check Migrations
|
|
||||||
run: python manage.py makemigrations --check --dry-run
|
|
||||||
working-directory: backend
|
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: pytest
|
run: cargo test --locked
|
||||||
working-directory: backend
|
working-directory: backend-rs
|
||||||
-191
@@ -1,191 +0,0 @@
|
|||||||
# Django → Rust migration runbook (seamless, reversible)
|
|
||||||
|
|
||||||
Migrate the running Django backend to the Rust backend (`backend-rs/`) with
|
|
||||||
**zero downtime** and a **per-step rollback**. Endpoint groups cut over one at a
|
|
||||||
time behind nginx; either backend can serve any request, so rollback is just the
|
|
||||||
reverse edit and data never forks.
|
|
||||||
|
|
||||||
There is a driver script that performs every step below:
|
|
||||||
`scripts/cutover.sh` (run `scripts/cutover.sh` with no args for the menu). Do the
|
|
||||||
steps manually or let the script do them — both edit the same files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why it's safe
|
|
||||||
|
|
||||||
Both backends share one substrate, so a request is serviceable by either:
|
|
||||||
|
|
||||||
| Shared thing | How |
|
|
||||||
|---|---|
|
|
||||||
| Database | same `backend/db.sqlite3` (WAL on), bind-mounted into both containers |
|
|
||||||
| Media | same `backend/media/`; nginx serves `/media/` straight from the mount (`:ro`) |
|
|
||||||
| Auth | same `SECRET_KEY` → SimpleJWT HS256 tokens verify on both; cookies cross-valid |
|
|
||||||
| Token blacklist | shared `token_blacklist_*` tables → logout/refresh consistent across a flip |
|
|
||||||
|
|
||||||
**The one invariant — never violate:** exactly **one** scheduler may own the
|
|
||||||
`api_job` table at a time. Django's Celery `beat` owns it until Phase 3; the Rust
|
|
||||||
in-process scheduler (`ENABLE_SCHEDULER`) stays **false** until then. Never both,
|
|
||||||
or scheduled jobs fire twice.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **`SECRET_KEY` in `.env` must equal Django's exact value.** If it differs, all
|
|
||||||
existing JWTs break the instant you cut over auth. Verify before starting.
|
|
||||||
2. Back up first: `bash scripts/backup.sh` (snapshots SQLite + media).
|
|
||||||
3. Optionally enable **live nginx reloads** so flips don't rebuild the frontend
|
|
||||||
image. Add to the `frontend` service in `docker-compose.yml`:
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
- ./frontend/proxy_params.conf:/etc/nginx/proxy_params.conf
|
|
||||||
```
|
|
||||||
then `docker compose up -d frontend`. Without this, each phase rebuilds the
|
|
||||||
frontend image (`docker compose up -d --build frontend`) — slower, but still
|
|
||||||
no API downtime. (`scripts/cutover.sh dev-mount` prints these lines.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Deploy Rust in shadow (no traffic)
|
|
||||||
|
|
||||||
Bring up `holy-rust` beside Django. It only reads; nginx still sends all traffic
|
|
||||||
to Django.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build backend-rs # or: scripts/cutover.sh phase0
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke-test it directly, bypassing nginx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/ # or: scripts/cutover.sh smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
Spot-check a few endpoints against `holy-rust:8001` and compare to Django.
|
|
||||||
|
|
||||||
**Rollback:** `docker compose stop backend-rs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Reads
|
|
||||||
|
|
||||||
Lowest risk: read-only, no auth side effects.
|
|
||||||
|
|
||||||
Flip these locations in `frontend/nginx.conf` from `django_backend` →
|
|
||||||
`rust_backend`, then reload:
|
|
||||||
|
|
||||||
- `/api/categories/`
|
|
||||||
- `/api/flavors/`
|
|
||||||
- `/api/banners/`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/cutover.sh phase1
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch `docker logs holy-rust` and your error rate for a few minutes.
|
|
||||||
|
|
||||||
**Rollback:** `scripts/cutover.sh rollback 1`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Auth + writes
|
|
||||||
|
|
||||||
JWTs and cookies are cross-valid, so logged-in sessions survive the flip.
|
|
||||||
|
|
||||||
Flip:
|
|
||||||
|
|
||||||
- `/api/auth/`
|
|
||||||
- `= /api/token/` (legacy JWT alias)
|
|
||||||
- `/api/ratings/`
|
|
||||||
- `/api/replies/`
|
|
||||||
- `/api/users/`
|
|
||||||
- `/api/notifications/`
|
|
||||||
- `/api/tickets/`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/cutover.sh phase2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rollback:** `scripts/cutover.sh rollback 2`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Admin + jobs (the careful one)
|
|
||||||
|
|
||||||
This is the only step that touches the scheduler invariant. Do it in order.
|
|
||||||
|
|
||||||
1. Flip `/api/admin-custom/` → `rust_backend`, reload.
|
|
||||||
2. **Stop Django's scheduler/workers** so nobody schedules:
|
|
||||||
```bash
|
|
||||||
docker compose stop beat worker flower
|
|
||||||
```
|
|
||||||
3. **Hand scheduling to Rust:** set `RUST_ENABLE_SCHEDULER=true` in `.env`, then
|
|
||||||
recreate the Rust container:
|
|
||||||
```bash
|
|
||||||
docker compose up -d backend-rs
|
|
||||||
```
|
|
||||||
4. Confirm single ownership:
|
|
||||||
```bash
|
|
||||||
docker logs holy-rust | grep "job scheduler ENABLED"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/cutover.sh phase3 # does 1-4, prompts before the handover
|
|
||||||
```
|
|
||||||
|
|
||||||
At no point are both schedulers on → no double-run.
|
|
||||||
|
|
||||||
**Rollback:** `scripts/cutover.sh rollback 3` — sets `RUST_ENABLE_SCHEDULER=false`,
|
|
||||||
recreates `backend-rs`, restarts `beat worker flower`, flips admin back to Django.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — Retire Django
|
|
||||||
|
|
||||||
Point the catch-all at Rust. Rust also serves OpenAPI + Swagger, so `/api/schema`
|
|
||||||
goes with it.
|
|
||||||
|
|
||||||
1. Flip `location /api/` and `/api/schema` → `rust_backend`, reload:
|
|
||||||
```bash
|
|
||||||
scripts/cutover.sh phase4
|
|
||||||
```
|
|
||||||
Django now receives only `/admin/` + `/static/` (its own admin site). `/media/`
|
|
||||||
is already served straight from nginx, independent of either backend.
|
|
||||||
2. Decide the Django admin site's fate. The app's admin UI uses
|
|
||||||
`/api/admin-custom/` (now on Rust), so Django `/admin/` is usually unneeded:
|
|
||||||
- **Keep it:** leave `backend` up for `/admin/` + `/static/`.
|
|
||||||
- **Drop it:** `docker compose stop backend`, then delete the `/admin/` and
|
|
||||||
`/static/` location blocks from `nginx.conf` and reload.
|
|
||||||
3. Once Django is fully off, stop Redis (only a Celery broker + Django cache —
|
|
||||||
Rust uses neither):
|
|
||||||
```bash
|
|
||||||
docker compose stop redis
|
|
||||||
```
|
|
||||||
4. Eventually remove `backend`, `worker`, `beat`, `flower`, `redis` from
|
|
||||||
`docker-compose.yml`.
|
|
||||||
|
|
||||||
**Rollback:** `scripts/cutover.sh rollback 4` (before removing services).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verify anytime
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/cutover.sh status # current routing per group + scheduler ownership
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes / caveats
|
|
||||||
|
|
||||||
- **Rate-limit counters reset at cutover.** Django's `django_ratelimit` lives in
|
|
||||||
Redis; Rust's throttle is in-process. Acceptable — windows are minutes/hours.
|
|
||||||
- **Reloads:** if the conf is baked into the image (default), a flip rebuilds the
|
|
||||||
frontend image. Bind-mount the conf (see Prerequisites) for instant
|
|
||||||
`nginx -s reload`. The script auto-detects which mode you're in.
|
|
||||||
- **Canonical data:** `backend/db.sqlite3` + `backend/media/` are host bind
|
|
||||||
mounts — never recreated by rebuilds. Keep them; they're the source of truth
|
|
||||||
the whole time.
|
|
||||||
- The same content lives in `backend-rs/README.md` ("Production cutover runbook")
|
|
||||||
as the in-tree reference.
|
|
||||||
@@ -18,7 +18,7 @@ A high-density, community-driven platform for "Holy Energy" enthusiasts. Browse
|
|||||||
## 🛠️ Setup & Installation
|
## 🛠️ Setup & Installation
|
||||||
|
|
||||||
### Option 1: Docker (Recommended)
|
### 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:**
|
1. **Clone the repository:**
|
||||||
```bash
|
```bash
|
||||||
@@ -28,62 +28,48 @@ The easiest way to get the full stack (Backend + Frontend + DB) running behind a
|
|||||||
2. **Environment Configuration:**
|
2. **Environment Configuration:**
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your SMTP settings and Domain
|
# Edit .env: set SECRET_KEY, DOMAIN, and SMTP settings
|
||||||
```
|
```
|
||||||
3. **Start Services:**
|
3. **Start Services:**
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
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)
|
### Option 2: Local Manual Setup (Development)
|
||||||
|
|
||||||
#### Backend
|
#### Backend
|
||||||
1. **Navigate and Install:**
|
```bash
|
||||||
```bash
|
cd backend-rs
|
||||||
cd backend
|
cp .env.example .env # set SECRET_KEY; point DATABASE_URL at ../backend/db.sqlite3
|
||||||
python -m venv venv
|
cargo run # listens on 0.0.0.0:8001
|
||||||
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.*
|
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
1. **Navigate and Install:**
|
```bash
|
||||||
```bash
|
cd frontend
|
||||||
cd frontend
|
npm install
|
||||||
npm install
|
npm run dev
|
||||||
```
|
```
|
||||||
2. **Run Dev Server:**
|
|
||||||
```bash
|
> 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.
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
The Holy Flavor Rater uses a modern **decoupled architecture**:
|
The Holy Flavor Rater uses a **decoupled architecture**:
|
||||||
|
|
||||||
### Backend (Django REST Framework)
|
### Backend (Rust — Axum + SeaORM)
|
||||||
- **Models:** Custom User model, Flavors (with category constraints), Ratings (with mention parsing), and Dynamic Banners.
|
- **Web/DB:** Axum 0.8 over SeaORM (SQLite), utoipa for OpenAPI + Swagger UI. Lives in `backend-rs/`.
|
||||||
- **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.
|
- **Models:** Custom User, Flavors (with `UniqueConstraint(name, category)`), Ratings (with `@mention` parsing), Tickets, Notifications, and Dynamic Banners.
|
||||||
- **Caching & rate-limiting:** Django cache uses Redis (`RedisCache` on DB 1). `django_ratelimit` consumes the same cache.
|
- **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:** JWT authentication (SimpleJWT) with refresh-token rotation, CSRF protection, and email-verified account activation.
|
- **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:** `/health/` endpoint for the backend; Flower UI on `:5555` for queue + worker state (basic-auth gated).
|
- **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)
|
### Frontend (React 19 + TypeScript)
|
||||||
- **State Management:** React Hooks and local state for high performance.
|
- **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
|
### Integrity Protection
|
||||||
This project includes a **Pre-Push Git Hook** to ensure code quality. It automatically:
|
This project includes a **Pre-Push Git Hook** to ensure code quality. It automatically:
|
||||||
1. Attempts a full Frontend build (`tsc` + `vite build`).
|
1. Attempts a full Frontend build (`tsc` + `vite build`).
|
||||||
2. Runs Backend system checks and validates missing migrations.
|
2. Runs Rust backend checks (`cargo check`).
|
||||||
|
|
||||||
**To install the hooks:**
|
**To install the hooks:**
|
||||||
```bash
|
```bash
|
||||||
bash scripts/install-hooks.sh
|
bash scripts/install-hooks.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Management Commands
|
### Background Jobs
|
||||||
Each is also exposed as a Celery task (`api.<name>`) and triggerable from the admin Jobs tab.
|
Run by the scheduler on their interval, or triggered from the admin Jobs tab (`POST /api/admin-custom/{pk}/trigger_job/`):
|
||||||
- `python manage.py sync_flavors`: Syncs the latest products from the official Holy Energy API.
|
- `sync_flavors`: Syncs the latest products from the official Holy Energy (Shopify) catalog.
|
||||||
- `python manage.py cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
- `cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
||||||
- `python manage.py seed_legacy_flavors`: Loads retired flavors from `legacy/*.json`.
|
- `seed_legacy`: Loads retired flavors from `legacy/*.json`.
|
||||||
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`.
|
- `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/`.
|
- `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**.
|
`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).
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💾 Backups & Restoration
|
## 💾 Backups & Restoration
|
||||||
|
|
||||||
### Create a Backup
|
### Create a Backup
|
||||||
To create a full snapshot of your database and user-uploaded media:
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/backup.sh
|
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:**
|
**Automating with Cron** — nightly at 2:00 AM:
|
||||||
Add this to your `crontab -e` to backup every night at 2:00 AM:
|
|
||||||
```bash
|
```bash
|
||||||
0 2 * * * /path/to/Holy-Flavor-Rater/scripts/backup.sh
|
0 2 * * * /path/to/Holy-Flavor-Rater/scripts/backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restore from Backup
|
### Restore from Backup
|
||||||
1. Extract the archive: `tar -xzf backup_filename.tar.gz`
|
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:
|
3. Replace the current files:
|
||||||
```bash
|
```bash
|
||||||
cp extracted_folder/db.sqlite3 backend/db.sqlite3
|
cp extracted_folder/db.sqlite3 backend/db.sqlite3
|
||||||
cp -r extracted_folder/media/* backend/media/
|
cp -r extracted_folder/media/* backend/media/
|
||||||
```
|
```
|
||||||
4. Restart: `docker-compose up -d`
|
4. Restart: `docker compose up -d`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌍 Production Configuration
|
## 🌍 Production Configuration
|
||||||
|
|
||||||
Ensure your `.env` contains:
|
Set these in `.env` (see also `backend-rs/.env.example`):
|
||||||
- `DEBUG=false`
|
- `SECRET_KEY`: long random string that signs JWTs — **keep it constant** or all tokens invalidate.
|
||||||
- `DJANGO_SETTINGS_MODULE=holy_backend.settings.prod`
|
- `DOMAIN`: your domain (e.g. `holy.narl.io`); derives `FRONTEND_URL` / CORS defaults.
|
||||||
- `SECRET_KEY`: A long random string.
|
- `FRONTEND_URL`: base URL for one-click email verification links.
|
||||||
- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`).
|
- `JWT_AUTH_COOKIE_SECURE=true`: set `Secure` on auth cookies.
|
||||||
- `FRONTEND_URL`: For one-click email verification links.
|
- `RUST_ENABLE_SCHEDULER=true`: lets the backend own `api_job` scheduling. **Exactly one** running instance may have this enabled.
|
||||||
- `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND`: Default `redis://redis:6379/0` works inside the compose network.
|
- `EMAIL_*`: SMTP settings for transactional email (unset `EMAIL_HOST` logs to console).
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
MIT
|
MIT
|
||||||
+14
-14
@@ -1,44 +1,44 @@
|
|||||||
# Path to the SAME sqlite file Django uses (strangler: both backends share one DB).
|
# Path to the sqlite database. Relative paths resolve from the process CWD;
|
||||||
# Relative paths resolve from the process CWD; absolute is safest.
|
# absolute is safest. Use mode=rw in prod so a wrong path fails loudly.
|
||||||
DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc
|
DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc
|
||||||
|
|
||||||
# MUST equal Django's SECRET_KEY so SimpleJWT tokens verify across both backends.
|
# Signs the SimpleJWT-compatible tokens. Keep this CONSTANT across deploys and
|
||||||
SECRET_KEY=change-me-to-match-django
|
# 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.
|
# Where this service binds.
|
||||||
BIND_ADDR=0.0.0.0:8001
|
BIND_ADDR=0.0.0.0:8001
|
||||||
|
|
||||||
# Media URL prefix Django serves under (matches MEDIA_URL = "media/").
|
# Media URL prefix (matches the historical Django MEDIA_URL = "media/").
|
||||||
MEDIA_URL=media/
|
MEDIA_URL=media/
|
||||||
|
|
||||||
# Filesystem root for uploaded media (avatars). Must point at the SAME directory
|
# Filesystem root for uploaded media (avatars).
|
||||||
# Django uses (MEDIA_ROOT) so both backends read/write the same files.
|
|
||||||
MEDIA_ROOT=../backend/media
|
MEDIA_ROOT=../backend/media
|
||||||
|
|
||||||
# Base URL of the frontend, used to build email links (verify-email, reset).
|
# Base URL of the frontend, used to build email links (verify-email, reset).
|
||||||
# Matches Django's FRONTEND_URL.
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
# --- Email (lettre) -------------------------------------------------------
|
# --- Email (lettre) -------------------------------------------------------
|
||||||
# Leave EMAIL_HOST unset to log emails to the console (dev). Set it to enable
|
# Leave EMAIL_HOST unset to log emails to the console (dev). Set it to enable
|
||||||
# real SMTP. These mirror Django's EMAIL_* settings.
|
# real SMTP.
|
||||||
#EMAIL_HOST=smtp.example.com
|
#EMAIL_HOST=smtp.example.com
|
||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_USE_TLS=true
|
EMAIL_USE_TLS=true
|
||||||
EMAIL_USE_SSL=false
|
EMAIL_USE_SSL=false
|
||||||
#EMAIL_HOST_USER=noreply@example.com
|
#EMAIL_HOST_USER=noreply@example.com
|
||||||
#EMAIL_HOST_PASSWORD=change-me
|
#EMAIL_HOST_PASSWORD=change-me
|
||||||
# Accept self-signed SMTP certs (mirrors api.email_backend.InsecureEmailBackend).
|
# Accept self-signed SMTP certs.
|
||||||
EMAIL_SKIP_CERT_VERIFICATION=false
|
EMAIL_SKIP_CERT_VERIFICATION=false
|
||||||
# From address; falls back to EMAIL_HOST_USER then noreply@localhost.
|
# From address; falls back to EMAIL_HOST_USER then noreply@localhost.
|
||||||
#DEFAULT_FROM_EMAIL=noreply@example.com
|
#DEFAULT_FROM_EMAIL=noreply@example.com
|
||||||
|
|
||||||
# Set `Secure` on auth cookies (true in prod, like Django's JWT_AUTH_COOKIE_SECURE).
|
# Set `Secure` on auth cookies (true in prod).
|
||||||
JWT_AUTH_COOKIE_SECURE=false
|
JWT_AUTH_COOKIE_SECURE=false
|
||||||
|
|
||||||
# In-process job scheduler (replaces Celery beat). MUST stay false while
|
# In-process job scheduler (replaces Celery beat). Exactly one running instance
|
||||||
# Django's beat container is still running — otherwise scheduled jobs fire twice
|
# may set this true — it owns scheduling of the api_job table. Additional
|
||||||
# against the shared api_job table. Set true ONLY after stopping Django beat.
|
# instances must leave it false or jobs fire twice.
|
||||||
ENABLE_SCHEDULER=false
|
ENABLE_SCHEDULER=false
|
||||||
|
|
||||||
# tracing filter
|
# tracing filter
|
||||||
|
|||||||
+21
-83
@@ -185,94 +185,35 @@ due `sync_flavors` job which fetched `weareholy.com` and reported
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # set SECRET_KEY to match Django; point DATABASE_URL at the shared db
|
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)
|
cargo run # listens on BIND_ADDR (default 0.0.0.0:8001)
|
||||||
```
|
```
|
||||||
|
|
||||||
`SECRET_KEY` **must** equal Django's, or JWTs minted by Django won't verify here.
|
`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.)
|
||||||
|
|
||||||
## Strangler proxy (nginx)
|
## Deployment
|
||||||
|
|
||||||
The live routing is in **`frontend/nginx.conf`** (copied into the `holy-frontend`
|
The Rust backend is the production API; Django/Celery/Redis were retired after
|
||||||
image; shared proxy headers live in `frontend/proxy_params.conf`). Two upstreams
|
the strangler migration completed. `docker compose` (`docker-compose.yml` +
|
||||||
are declared — `django_backend` (`holy-backend:8000`) and `rust_backend`
|
`docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`)
|
||||||
(`holy-rust:8001`) — and each `/api/<group>/` location has its own `proxy_pass`.
|
and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend,
|
||||||
Cutting a group over is a one-line edit (`django_backend` → `rust_backend`)
|
and serves `/media` from the shared `./backend/media` bind mount; see
|
||||||
followed by a reload; rollback is the reverse edit. Because both backends share
|
`frontend/nginx.conf`.
|
||||||
one SQLite DB, one media dir, and the same `SECRET_KEY`, either can serve any
|
|
||||||
request and tokens stay valid across a flip.
|
|
||||||
|
|
||||||
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
||||||
`next`/`previous`) are rebuilt from them, matching DRF's `build_absolute_uri`.
|
`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
|
`X-Forwarded-For` lets `/api/users/me/` log the real client IP (it falls back to
|
||||||
the peer address otherwise, matching Django's `REMOTE_ADDR`).
|
the peer address otherwise).
|
||||||
|
|
||||||
## Production cutover runbook (seamless, reversible)
|
**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.
|
||||||
|
|
||||||
Goal: migrate a running Django deployment to Rust with **no downtime** and a
|
The canonical data lives in host bind mounts — `backend/db.sqlite3` (WAL on) and
|
||||||
per-step rollback. Everything hinges on the shared substrate already in place —
|
`backend/media/` — and survives image rebuilds. Keep them.
|
||||||
same `db.sqlite3` (WAL on), same media dir, same `SECRET_KEY` — so a request can
|
|
||||||
be served by either backend interchangeably.
|
|
||||||
|
|
||||||
**Invariant (do not violate):** exactly **one** scheduler may own the `api_job`
|
|
||||||
table at any time. Django's `beat` owns it until Phase 3; Rust's in-process
|
|
||||||
scheduler (`ENABLE_SCHEDULER`) is **off** until then. Never both.
|
|
||||||
|
|
||||||
### Phase 0 — Deploy Rust in shadow (no traffic)
|
|
||||||
1. Set `SECRET_KEY` in `.env` to the **exact** value Django already uses (else
|
|
||||||
existing JWTs break on cutover). Optionally set `FLOWER_BASIC_AUTH`.
|
|
||||||
2. `docker compose up -d --build backend-rs` — brings up `holy-rust` beside
|
|
||||||
Django. `ENABLE_SCHEDULER` defaults to false; it only reads the DB.
|
|
||||||
3. Smoke-test directly: `docker exec holy-frontend wget -qO- http://holy-rust:8001/health/`
|
|
||||||
and spot-check a few endpoints against it. nginx still sends all prod traffic
|
|
||||||
to Django. **Rollback:** `docker compose stop backend-rs`.
|
|
||||||
|
|
||||||
### Phase 1 — Reads
|
|
||||||
4. In `frontend/nginx.conf`, flip `categories`, `flavors`, `banners` to
|
|
||||||
`rust_backend`. Rebuild/reload the frontend (`docker compose up -d --build
|
|
||||||
frontend`, or `docker exec holy-frontend nginx -s reload` if you bind-mount the
|
|
||||||
conf). Watch `docker logs holy-rust` + error rates. **Rollback:** flip back, reload.
|
|
||||||
|
|
||||||
### Phase 2 — Auth + writes
|
|
||||||
5. Flip `auth`, `token` (legacy alias), `ratings`, `replies`, `users`,
|
|
||||||
`notifications`, `tickets`. JWTs and cookies are cross-valid, so in-flight
|
|
||||||
sessions survive. Reload. **Rollback:** flip back, reload.
|
|
||||||
|
|
||||||
### Phase 3 — Admin + jobs (the careful one)
|
|
||||||
6. Flip `admin-custom` to `rust_backend`, reload.
|
|
||||||
7. **Stop Django's scheduler/workers:** `docker compose stop beat worker flower`.
|
|
||||||
Now nobody schedules jobs.
|
|
||||||
8. Hand scheduling to Rust: set `RUST_ENABLE_SCHEDULER=true` in `.env`, then
|
|
||||||
`docker compose up -d backend-rs`. Confirm the log line
|
|
||||||
`job scheduler ENABLED`. Single owner throughout → no double-run.
|
|
||||||
**Rollback:** set `RUST_ENABLE_SCHEDULER=false` + recreate `backend-rs`, then
|
|
||||||
`docker compose start beat worker`.
|
|
||||||
|
|
||||||
### Phase 4 — Retire Django
|
|
||||||
9. Point the catch-all `location /api/` at `rust_backend` (covers `/api/schema`
|
|
||||||
too — Rust serves OpenAPI + Swagger). Reload. Django now receives only
|
|
||||||
`/admin/` + `/static/` (its admin site). `/media/` is already served straight
|
|
||||||
from nginx (the `:ro` mount), independent of either backend.
|
|
||||||
10. Decide the Django admin site's fate. The app's own admin UI uses
|
|
||||||
`/api/admin-custom/`, so the Django `/admin/` site is usually unneeded:
|
|
||||||
- Keeping it: leave the `backend` container up for `/admin/` + `/static/`.
|
|
||||||
- Dropping it: `docker compose stop backend`, delete the `/admin/` + `/static/`
|
|
||||||
locations from nginx.
|
|
||||||
11. Once Django is fully off, `redis` (only a Celery broker + Django cache — Rust
|
|
||||||
uses neither) can be stopped too: `docker compose stop redis`. Eventually
|
|
||||||
remove `backend`, `worker`, `beat`, `flower`, `redis` from compose.
|
|
||||||
|
|
||||||
### Notes / caveats
|
|
||||||
- **Rate-limit counters reset at cutover** — Django's `django_ratelimit` lives in
|
|
||||||
Redis; Rust's throttle is in-process. Acceptable (windows are minutes/hours).
|
|
||||||
- **Token blacklist is shared** (both use `token_blacklist_*` tables), so logout
|
|
||||||
/ refresh-rotation stays consistent across a flip.
|
|
||||||
- Reloads: the frontend image bakes the conf, so a conf change needs
|
|
||||||
`docker compose up -d --build frontend`. For instant reloads during cutover,
|
|
||||||
bind-mount `./frontend/nginx.conf:/etc/nginx/conf.d/default.conf` temporarily and
|
|
||||||
use `nginx -s reload`.
|
|
||||||
- Keep `backend/db.sqlite3` + `backend/media/` (host bind mounts) as the canonical
|
|
||||||
data across all of this — neither is recreated by rebuilds.
|
|
||||||
|
|
||||||
## Caveats / parity TODO
|
## Caveats / parity TODO
|
||||||
|
|
||||||
@@ -291,9 +232,7 @@ scheduler (`ENABLE_SCHEDULER`) is **off** until then. Never both.
|
|||||||
never used them.
|
never used them.
|
||||||
- Admin endpoints + background jobs are ported (see the Admin + jobs slice).
|
- Admin endpoints + background jobs are ported (see the Admin + jobs slice).
|
||||||
The job-trigger and admin write endpoints are **not** rate-limited (those are
|
The job-trigger and admin write endpoints are **not** rate-limited (those are
|
||||||
staff-only; `django_ratelimit` only covered the public flows below). When Rust
|
staff-only; `django_ratelimit` only covered the public flows below).
|
||||||
owns jobs in prod, disable the Celery `beat`/`worker` containers to avoid
|
|
||||||
double-execution.
|
|
||||||
|
|
||||||
## Security & hardening
|
## Security & hardening
|
||||||
|
|
||||||
@@ -338,9 +277,8 @@ in-flight requests drain. `/health/` now runs `SELECT 1` so the Docker
|
|||||||
healthcheck fails when SQLite is unreachable.
|
healthcheck fails when SQLite is unreachable.
|
||||||
|
|
||||||
SQLite runs in **WAL** journal mode (enabled once at startup, persistent in the
|
SQLite runs in **WAL** journal mode (enabled once at startup, persistent in the
|
||||||
file header, so it covers the Django side too) so readers don't block behind the
|
file header) so readers don't block behind the writer; `busy_timeout` is sqlx's
|
||||||
writer under the shared-DB strangler; `busy_timeout` is sqlx's default 5 s,
|
default 5 s, matching Django's SQLite timeout. The default `DATABASE_URL` uses `mode=rw` (not
|
||||||
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.
|
`rwc`) so a wrong path fails loudly instead of silently creating an empty DB.
|
||||||
|
|
||||||
Confirmation codes are compared in **constant time** (`subtle::ConstantTimeEq`)
|
Confirmation codes are compared in **constant time** (`subtle::ConstantTimeEq`)
|
||||||
|
|||||||
@@ -104,11 +104,16 @@ impl Config {
|
|||||||
},
|
},
|
||||||
email: EmailConfig {
|
email: EmailConfig {
|
||||||
host: env::var("EMAIL_HOST").ok().filter(|s| !s.is_empty()),
|
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),
|
port: env::var("EMAIL_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(587),
|
||||||
use_tls: env_bool("EMAIL_USE_TLS", false),
|
use_tls: env_bool("EMAIL_USE_TLS", false),
|
||||||
use_ssl: env_bool("EMAIL_USE_SSL", false),
|
use_ssl: env_bool("EMAIL_USE_SSL", false),
|
||||||
host_user: env::var("EMAIL_HOST_USER").ok().filter(|s| !s.is_empty()),
|
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()),
|
host_password: env::var("EMAIL_HOST_PASSWORD")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty()),
|
||||||
skip_cert_verification: env_bool("EMAIL_SKIP_CERT_VERIFICATION", false),
|
skip_cert_verification: env_bool("EMAIL_SKIP_CERT_VERIFICATION", false),
|
||||||
default_from: env::var("DEFAULT_FROM_EMAIL")
|
default_from: env::var("DEFAULT_FROM_EMAIL")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -120,9 +125,9 @@ impl Config {
|
|||||||
.unwrap_or_else(|_| "../backend/banners".to_string()),
|
.unwrap_or_else(|_| "../backend/banners".to_string()),
|
||||||
legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".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()),
|
backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../backend/backups".to_string()),
|
||||||
// Default OFF: a fresh deploy runs beside Django, which still owns the
|
// Default OFF so ad-hoc `cargo run` instances don't run jobs.
|
||||||
// scheduler. Operator flips ENABLE_SCHEDULER=true only after stopping
|
// Production (docker compose) sets ENABLE_SCHEDULER=true on the one
|
||||||
// Django beat (see README cutover steps).
|
// instance that should own `api_job` scheduling.
|
||||||
enable_scheduler: env_bool("ENABLE_SCHEDULER", false),
|
enable_scheduler: env_bool("ENABLE_SCHEDULER", false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use chrono::{NaiveDateTime, Timelike, Utc};
|
|||||||
/// so truncate before inserting to keep the on-disk format identical.
|
/// so truncate before inserting to keep the on-disk format identical.
|
||||||
pub fn now_micros() -> NaiveDateTime {
|
pub fn now_micros() -> NaiveDateTime {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
now.with_nanosecond(now.nanosecond() / 1000 * 1000).unwrap_or(now)
|
now.with_nanosecond(now.nanosecond() / 1000 * 1000)
|
||||||
|
.unwrap_or(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a naive-UTC datetime exactly as DRF serializes an aware UTC
|
/// Format a naive-UTC datetime exactly as DRF serializes an aware UTC
|
||||||
|
|||||||
@@ -42,14 +42,17 @@ pub async fn send_mail(
|
|||||||
|
|
||||||
let mut builder = Message::builder().from(from).subject(subject);
|
let mut builder = Message::builder().from(from).subject(subject);
|
||||||
for r in recipients {
|
for r in recipients {
|
||||||
let mbox: Mailbox = r.parse().map_err(|e| format!("invalid recipient {r}: {e}"))?;
|
let mbox: Mailbox = r
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("invalid recipient {r}: {e}"))?;
|
||||||
builder = builder.to(mbox);
|
builder = builder.to(mbox);
|
||||||
}
|
}
|
||||||
let message = builder
|
let message = builder
|
||||||
.body(body.to_string())
|
.body(body.to_string())
|
||||||
.map_err(|e| format!("build email: {e}"))?;
|
.map_err(|e| format!("build email: {e}"))?;
|
||||||
|
|
||||||
let mut transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(cfg.port);
|
let mut transport =
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(cfg.port);
|
||||||
|
|
||||||
if cfg.use_ssl || cfg.use_tls {
|
if cfg.use_ssl || cfg.use_tls {
|
||||||
let tls = TlsParameters::builder(host.to_string())
|
let tls = TlsParameters::builder(host.to_string())
|
||||||
|
|||||||
+10
-5
@@ -56,7 +56,8 @@ impl IntoResponse for ApiError {
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
if let Ok(hv) = axum::http::HeaderValue::from_str(&retry_after.to_string()) {
|
if let Ok(hv) = axum::http::HeaderValue::from_str(&retry_after.to_string()) {
|
||||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, hv);
|
resp.headers_mut()
|
||||||
|
.insert(axum::http::header::RETRY_AFTER, hv);
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
@@ -73,11 +74,15 @@ impl IntoResponse for ApiError {
|
|||||||
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||||
ApiError::Db(e) => {
|
ApiError::Db(e) => {
|
||||||
tracing::error!(error = %e, "database error");
|
tracing::error!(error = %e, "database error");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Server error.".to_string())
|
(
|
||||||
}
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::Internal => {
|
"Server error.".to_string(),
|
||||||
(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()
|
(status, Json(json!({ "detail": detail }))).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ impl BackgroundJob for BackupDb {
|
|||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
log.push("Full archive created successfully.".to_string());
|
log.push("Full archive created successfully.".to_string());
|
||||||
log.push(format!("Cleaning up backups older than {RETENTION_DAYS} days..."));
|
log.push(format!(
|
||||||
|
"Cleaning up backups older than {RETENTION_DAYS} days..."
|
||||||
|
));
|
||||||
log.extend(archive_log);
|
log.extend(archive_log);
|
||||||
|
|
||||||
Ok(log.join("\n"))
|
Ok(log.join("\n"))
|
||||||
@@ -99,7 +101,11 @@ fn prune_old(backup_dir: &str) -> anyhow::Result<Vec<String>> {
|
|||||||
let modified = entry.metadata().and_then(|m| m.modified()).ok();
|
let modified = entry.metadata().and_then(|m| m.modified()).ok();
|
||||||
if let Some(mtime) = modified {
|
if let Some(mtime) = modified {
|
||||||
if mtime < cutoff {
|
if mtime < cutoff {
|
||||||
let name = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
match fs::remove_file(&path) {
|
match fs::remove_file(&path) {
|
||||||
Ok(_) => log.push(format!(" -> Deleted old backup: {name}")),
|
Ok(_) => log.push(format!(" -> Deleted old backup: {name}")),
|
||||||
Err(e) => log.push(format!(" -> Failed to delete {name}: {e}")),
|
Err(e) => log.push(format!(" -> Failed to delete {name}: {e}")),
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ use super::BackgroundJob;
|
|||||||
/// Trailing form-factor phrases stripped during name normalization. Each is a
|
/// Trailing form-factor phrases stripped during name normalization. Each is a
|
||||||
/// sequence of whitespace-separated tokens; the match requires a preceding
|
/// 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.
|
/// token (Django's leading `\s+`), so a name that is *only* the phrase is kept.
|
||||||
const SUFFIXES: &[&[&str]] = &[
|
const SUFFIXES: &[&[&str]] = &[&["energy"], &["hydration"], &["iced", "tea"], &["(legacy)"]];
|
||||||
&["energy"],
|
|
||||||
&["hydration"],
|
|
||||||
&["iced", "tea"],
|
|
||||||
&["(legacy)"],
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Lowercase, trim, and strip the known trailing suffixes (Django's
|
/// Lowercase, trim, and strip the known trailing suffixes (Django's
|
||||||
/// `normalize_name`). The result is a grouping key, not a display value.
|
/// `normalize_name`). The result is a grouping key, not a display value.
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use sea_orm::ActiveValue::Set;
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter};
|
||||||
use unicode_normalization::UnicodeNormalization;
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
use crate::entities::prelude::Job as JobEntity;
|
|
||||||
use crate::entities::job;
|
use crate::entities::job;
|
||||||
|
use crate::entities::prelude::Job as JobEntity;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub use scheduler::spawn_scheduler;
|
pub use scheduler::spawn_scheduler;
|
||||||
@@ -136,7 +136,11 @@ async fn run_inner(state: &AppState, job: &dyn BackgroundJob) -> anyhow::Result<
|
|||||||
/// media image paths (and category slugs) identical to Django's so files and
|
/// media image paths (and category slugs) identical to Django's so files and
|
||||||
/// rows are reused, not duplicated.
|
/// rows are reused, not duplicated.
|
||||||
pub fn slugify(value: &str) -> String {
|
pub fn slugify(value: &str) -> String {
|
||||||
let ascii: String = value.nfkd().filter(|c| c.is_ascii()).collect::<String>().to_lowercase();
|
let ascii: String = value
|
||||||
|
.nfkd()
|
||||||
|
.filter(|c| c.is_ascii())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
// Keep word chars, hyphen and whitespace; drop the rest.
|
// Keep word chars, hyphen and whitespace; drop the rest.
|
||||||
let kept: String = ascii
|
let kept: String = ascii
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
|
||||||
use crate::entities::prelude::Job as JobEntity;
|
|
||||||
use crate::entities::job;
|
use crate::entities::job;
|
||||||
|
use crate::entities::prelude::Job as JobEntity;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const TICK: Duration = Duration::from_secs(60);
|
const TICK: Duration = Duration::from_secs(60);
|
||||||
@@ -57,7 +57,11 @@ async fn tick_once(state: &AppState) -> anyhow::Result<()> {
|
|||||||
if state.running_jobs.lock().await.contains(job.name()) {
|
if state.running_jobs.lock().await.contains(job.name()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tracing::info!(job = job.name(), interval_hours = row.interval_hours, "scheduling due job");
|
tracing::info!(
|
||||||
|
job = job.name(),
|
||||||
|
interval_hours = row.interval_hours,
|
||||||
|
"scheduling due job"
|
||||||
|
);
|
||||||
let st = state.clone();
|
let st = state.clone();
|
||||||
tokio::spawn(async move { super::run_job(&st, job).await });
|
tokio::spawn(async move { super::run_job(&st, job).await });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ impl BackgroundJob for SeedBanners {
|
|||||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
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 {
|
if let Err(e) = sync_one(db, &path, &mut log).await {
|
||||||
log.push(format!("Failed to sync banner from {filename}: {e}"));
|
log.push(format!("Failed to sync banner from {filename}: {e}"));
|
||||||
}
|
}
|
||||||
@@ -63,7 +67,11 @@ async fn sync_one(
|
|||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
log: &mut Vec<String>,
|
log: &mut Vec<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let data: Value = serde_json::from_str(&content)?;
|
let data: Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
@@ -78,9 +86,16 @@ async fn sync_one(
|
|||||||
|
|
||||||
match existing {
|
match existing {
|
||||||
None => {
|
None => {
|
||||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(slug).to_string();
|
let name = data
|
||||||
let description =
|
.get("name")
|
||||||
data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
.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!({}));
|
let settings = data.get("settings").cloned().unwrap_or_else(|| json!({}));
|
||||||
// Mirror Banner.save(): become active when no other banner is active.
|
// Mirror Banner.save(): become active when no other banner is active.
|
||||||
let no_active = active_count(db, None).await? == 0;
|
let no_active = active_count(db, None).await? == 0;
|
||||||
@@ -130,7 +145,10 @@ async fn sync_one(
|
|||||||
// Banner.save() invariant on every save.
|
// Banner.save() invariant on every save.
|
||||||
if was_active {
|
if was_active {
|
||||||
Banner::update_many()
|
Banner::update_many()
|
||||||
.col_expr(banner::Column::IsActive, sea_orm::sea_query::Expr::value(false))
|
.col_expr(
|
||||||
|
banner::Column::IsActive,
|
||||||
|
sea_orm::sea_query::Expr::value(false),
|
||||||
|
)
|
||||||
.filter(banner::Column::IsActive.eq(true))
|
.filter(banner::Column::IsActive.eq(true))
|
||||||
.filter(banner::Column::Id.ne(pk))
|
.filter(banner::Column::Id.ne(pk))
|
||||||
.exec(db)
|
.exec(db)
|
||||||
@@ -139,7 +157,9 @@ async fn sync_one(
|
|||||||
am.is_active = Set(true);
|
am.is_active = Set(true);
|
||||||
}
|
}
|
||||||
am.update(db).await?;
|
am.update(db).await?;
|
||||||
log.push(format!("Synced new settings for banner '{slug}' from {filename}"));
|
log.push(format!(
|
||||||
|
"Synced new settings for banner '{slug}' from {filename}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ use std::path::Path;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sea_orm::sea_query::{Expr, Func};
|
use sea_orm::sea_query::{Expr, Func};
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
use sea_orm::{
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::entities::prelude::{Category, Flavor};
|
use crate::entities::prelude::{Category, Flavor};
|
||||||
@@ -21,8 +19,9 @@ use crate::state::AppState;
|
|||||||
use super::download::{download_image, ext_for, hash_for, http_client};
|
use super::download::{download_image, ext_for, hash_for, http_client};
|
||||||
use super::{slugify, BackgroundJob};
|
use super::{slugify, BackgroundJob};
|
||||||
|
|
||||||
const PACK_KEYWORDS: &[&str] =
|
const PACK_KEYWORDS: &[&str] = &[
|
||||||
&["bundle", "set", "box", "probe", "sample", "taster", "shaker", "starter"];
|
"bundle", "set", "box", "probe", "sample", "taster", "shaker", "starter",
|
||||||
|
];
|
||||||
|
|
||||||
pub struct SeedLegacy;
|
pub struct SeedLegacy;
|
||||||
|
|
||||||
@@ -61,7 +60,11 @@ impl BackgroundJob for SeedLegacy {
|
|||||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_lowercase();
|
let fname = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
// Category from filename.
|
// Category from filename.
|
||||||
let file_cat: Option<&category::Model> = if fname.contains("energy") {
|
let file_cat: Option<&category::Model> = if fname.contains("energy") {
|
||||||
@@ -78,7 +81,9 @@ impl BackgroundJob for SeedLegacy {
|
|||||||
|
|
||||||
let content = fs::read_to_string(&path)?;
|
let content = fs::read_to_string(&path)?;
|
||||||
let items: Value = serde_json::from_str(&content)?;
|
let items: Value = serde_json::from_str(&content)?;
|
||||||
let Some(arr) = items.as_array() else { continue };
|
let Some(arr) = items.as_array() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
for data in arr {
|
for data in arr {
|
||||||
let Some(name) = data.get("Name").and_then(|v| v.as_str()) else {
|
let Some(name) = data.get("Name").and_then(|v| v.as_str()) else {
|
||||||
|
|||||||
@@ -28,14 +28,44 @@ const DOWNLOAD_WORKERS: usize = 8;
|
|||||||
const PACKS_SLUG: &str = "packs-and-other";
|
const PACKS_SLUG: &str = "packs-and-other";
|
||||||
const PACKS_NAME: &str = "Packs and other";
|
const PACKS_NAME: &str = "Packs and other";
|
||||||
|
|
||||||
const FORM_WORDS: &[&str] =
|
const FORM_WORDS: &[&str] = &[
|
||||||
&["bundle", "sachet", "sachetbox", "bottle", "box", "pack", "set", "sachets"];
|
"bundle",
|
||||||
|
"sachet",
|
||||||
|
"sachetbox",
|
||||||
|
"bottle",
|
||||||
|
"box",
|
||||||
|
"pack",
|
||||||
|
"set",
|
||||||
|
"sachets",
|
||||||
|
];
|
||||||
const NON_FLAVOR_TOKENS: &[&str] = &[
|
const NON_FLAVOR_TOKENS: &[&str] = &[
|
||||||
"value", "shaker", "merch", "sticker", "packaging", "material", "other", "items", "mixed",
|
"value",
|
||||||
"collector", "collector's", "free", "products", "naturalrabatt", "sample", "samplebox",
|
"shaker",
|
||||||
|
"merch",
|
||||||
|
"sticker",
|
||||||
|
"packaging",
|
||||||
|
"material",
|
||||||
|
"other",
|
||||||
|
"items",
|
||||||
|
"mixed",
|
||||||
|
"collector",
|
||||||
|
"collector's",
|
||||||
|
"free",
|
||||||
|
"products",
|
||||||
|
"naturalrabatt",
|
||||||
|
"sample",
|
||||||
|
"samplebox",
|
||||||
];
|
];
|
||||||
const PACK_TITLE_KEYWORDS: &[&str] = &[
|
const PACK_TITLE_KEYWORDS: &[&str] = &[
|
||||||
"bundle", "set", "box", "probe", "sample", "taster", "starter", "collection", "probier",
|
"bundle",
|
||||||
|
"set",
|
||||||
|
"box",
|
||||||
|
"probe",
|
||||||
|
"sample",
|
||||||
|
"taster",
|
||||||
|
"starter",
|
||||||
|
"collection",
|
||||||
|
"probier",
|
||||||
];
|
];
|
||||||
const ACCESSORY_TITLE_KEYWORDS: &[&str] = &["bürste", "reinigung", "ersatzteil", "zubehör"];
|
const ACCESSORY_TITLE_KEYWORDS: &[&str] = &["bürste", "reinigung", "ersatzteil", "zubehör"];
|
||||||
const TAG_FAMILY: &[(&[&str], &str)] = &[
|
const TAG_FAMILY: &[(&[&str], &str)] = &[
|
||||||
@@ -85,7 +115,11 @@ impl BackgroundJob for SyncFlavors {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let products = data.get("products").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
let products = data
|
||||||
|
.get("products")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
log.push(format!("Found {} products.", products.len()));
|
log.push(format!("Found {} products.", products.len()));
|
||||||
|
|
||||||
let packs = goc_category_by_slug_named(db, PACKS_SLUG, PACKS_NAME).await?;
|
let packs = goc_category_by_slug_named(db, PACKS_SLUG, PACKS_NAME).await?;
|
||||||
@@ -95,31 +129,54 @@ impl BackgroundJob for SyncFlavors {
|
|||||||
let mut synced_ids: Vec<i64> = Vec::new();
|
let mut synced_ids: Vec<i64> = Vec::new();
|
||||||
|
|
||||||
for p in &products {
|
for p in &products {
|
||||||
let title = p.get("title").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
let title = p
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
let tags = parse_tags(p);
|
let tags = parse_tags(p);
|
||||||
let tags_lower: Vec<String> = tags.iter().map(|t| t.to_lowercase()).collect();
|
let tags_lower: Vec<String> = tags.iter().map(|t| t.to_lowercase()).collect();
|
||||||
let title_lower = title.to_lowercase();
|
let title_lower = title.to_lowercase();
|
||||||
|
|
||||||
// Syrup ships as one article whose variants are the flavors.
|
// Syrup ships as one article whose variants are the flavors.
|
||||||
if is_syrup_variant_product(p) {
|
if is_syrup_variant_product(p) {
|
||||||
let (c, u) = sync_syrup_variants(db, &client, media_root, p, &mut synced_ids).await?;
|
let (c, u) =
|
||||||
|
sync_syrup_variants(db, &client, media_root, p, &mut synced_ids).await?;
|
||||||
created += c;
|
created += c;
|
||||||
updated += u;
|
updated += u;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(cat) =
|
let Some(cat) = resolve_category(db, p, &title_lower, &tags_lower, &packs).await?
|
||||||
resolve_category(db, p, &title_lower, &tags_lower, &packs).await?
|
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let variants = p.get("variants").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
let variants = p
|
||||||
let is_available = variants.iter().any(|v| v.get("available").and_then(|a| a.as_bool()).unwrap_or(false));
|
.get("variants")
|
||||||
let images = p.get("images").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
.and_then(|v| v.as_array())
|
||||||
let image_url = images.first().and_then(|i| i.get("src")).and_then(|s| s.as_str()).map(String::from);
|
.cloned()
|
||||||
let image_urls: Vec<String> =
|
.unwrap_or_default();
|
||||||
images.iter().filter_map(|i| i.get("src").and_then(|s| s.as_str()).map(String::from)).collect();
|
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 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 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 {
|
let Some(pid) = p.get("id").and_then(|v| v.as_i64()) else {
|
||||||
@@ -176,7 +233,10 @@ fn shop_url_for(handle: &str) -> String {
|
|||||||
/// Tags as a lowercase-able list; Shopify returns either an array or a CSV string.
|
/// Tags as a lowercase-able list; Shopify returns either an array or a CSV string.
|
||||||
fn parse_tags(p: &Value) -> Vec<String> {
|
fn parse_tags(p: &Value) -> Vec<String> {
|
||||||
match p.get("tags") {
|
match p.get("tags") {
|
||||||
Some(Value::Array(a)) => a.iter().filter_map(|t| t.as_str().map(String::from)).collect(),
|
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(),
|
Some(Value::String(s)) => s.split(',').map(|t| t.trim().to_string()).collect(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -209,10 +269,16 @@ fn family_from_product_type(product_type: &str) -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut tokens: Vec<&str> = stripped.split_whitespace().collect();
|
let mut tokens: Vec<&str> = stripped.split_whitespace().collect();
|
||||||
if tokens.iter().any(|t| NON_FLAVOR_TOKENS.contains(&t.to_lowercase().as_str())) {
|
if tokens
|
||||||
|
.iter()
|
||||||
|
.any(|t| NON_FLAVOR_TOKENS.contains(&t.to_lowercase().as_str()))
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
while tokens.last().is_some_and(|t| FORM_WORDS.contains(&t.to_lowercase().as_str())) {
|
while tokens
|
||||||
|
.last()
|
||||||
|
.is_some_and(|t| FORM_WORDS.contains(&t.to_lowercase().as_str()))
|
||||||
|
{
|
||||||
tokens.pop();
|
tokens.pop();
|
||||||
}
|
}
|
||||||
if tokens.is_empty() {
|
if tokens.is_empty() {
|
||||||
@@ -254,11 +320,20 @@ fn is_syrup_variant_product(p: &Value) -> bool {
|
|||||||
.get("options")
|
.get("options")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|opts| {
|
.map(|opts| {
|
||||||
opts.iter()
|
opts.iter().any(|o| {
|
||||||
.any(|o| o.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase() == "flavor")
|
o.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase()
|
||||||
|
== "flavor"
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let variant_count = p.get("variants").and_then(|v| v.as_array()).map(|v| v.len()).unwrap_or(0);
|
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
|
has_flavor_option && variant_count > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +348,10 @@ async fn resolve_category(
|
|||||||
if is_pack(title_lower, tags_lower) {
|
if is_pack(title_lower, tags_lower) {
|
||||||
return Ok(Some(packs.clone()));
|
return Ok(Some(packs.clone()));
|
||||||
}
|
}
|
||||||
if ACCESSORY_TITLE_KEYWORDS.iter().any(|k| title_lower.contains(k)) {
|
if ACCESSORY_TITLE_KEYWORDS
|
||||||
|
.iter()
|
||||||
|
.any(|k| title_lower.contains(k))
|
||||||
|
{
|
||||||
return Ok(Some(packs.clone()));
|
return Ok(Some(packs.clone()));
|
||||||
}
|
}
|
||||||
if let Some(family) =
|
if let Some(family) =
|
||||||
@@ -293,7 +371,10 @@ async fn resolve_category(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// get_or_create a category from a family name, keyed by `slugify(name)`.
|
/// 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> {
|
async fn category_for_family(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<category::Model> {
|
||||||
goc_category_by_slug_named(db, &slugify(name), name).await
|
goc_category_by_slug_named(db, &slugify(name), name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +414,11 @@ async fn sync_syrup_variants(
|
|||||||
|
|
||||||
let mut created = 0usize;
|
let mut created = 0usize;
|
||||||
let mut updated = 0usize;
|
let mut updated = 0usize;
|
||||||
let variants = p.get("variants").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
let variants = p
|
||||||
|
.get("variants")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
for v in &variants {
|
for v in &variants {
|
||||||
let name = v
|
let name = v
|
||||||
.get("option1")
|
.get("option1")
|
||||||
@@ -354,7 +439,10 @@ async fn sync_syrup_variants(
|
|||||||
.and_then(|s| s.as_str())
|
.and_then(|s| s.as_str())
|
||||||
.map(String::from);
|
.map(String::from);
|
||||||
let image_urls: Vec<String> = image_url.clone().into_iter().collect();
|
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 available = v
|
||||||
|
.get("available")
|
||||||
|
.and_then(|a| a.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let was_created = upsert_flavor(
|
let was_created = upsert_flavor(
|
||||||
db,
|
db,
|
||||||
@@ -412,7 +500,9 @@ async fn upsert_flavor(
|
|||||||
}
|
}
|
||||||
if existing.is_none() {
|
if existing.is_none() {
|
||||||
existing = Flavor::find()
|
existing = Flavor::find()
|
||||||
.filter(Expr::expr(Func::lower(Expr::col(flavor::Column::Name))).eq(name.to_lowercase()))
|
.filter(
|
||||||
|
Expr::expr(Func::lower(Expr::col(flavor::Column::Name))).eq(name.to_lowercase()),
|
||||||
|
)
|
||||||
.filter(flavor::Column::CategoryId.eq(category_id))
|
.filter(flavor::Column::CategoryId.eq(category_id))
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -510,8 +600,8 @@ async fn download_gallery(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let succeeded: HashMap<usize, String> = futures::stream::iter(targets.clone().into_iter().map(
|
let succeeded: HashMap<usize, String> =
|
||||||
|(i, u, rel)| {
|
futures::stream::iter(targets.clone().into_iter().map(|(i, u, rel)| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let media_root = media_root.to_string();
|
let media_root = media_root.to_string();
|
||||||
async move {
|
async move {
|
||||||
@@ -519,22 +609,31 @@ async fn download_gallery(
|
|||||||
let ok = download_image(&client, &u, &abs).await;
|
let ok = download_image(&client, &u, &abs).await;
|
||||||
(i, ok, rel)
|
(i, ok, rel)
|
||||||
}
|
}
|
||||||
},
|
}))
|
||||||
))
|
|
||||||
.buffer_unordered(DOWNLOAD_WORKERS)
|
.buffer_unordered(DOWNLOAD_WORKERS)
|
||||||
.filter_map(|(i, ok, rel)| async move { if ok { Some((i, rel)) } else { None } })
|
.filter_map(|(i, ok, rel)| async move {
|
||||||
|
if ok {
|
||||||
|
Some((i, rel))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
targets.iter().filter_map(|(i, _, _)| succeeded.get(i).cloned()).collect()
|
targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(i, _, _)| succeeded.get(i).cloned())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Django JSONField (list) stored as TEXT → Vec<String>.
|
/// Django JSONField (list) stored as TEXT → Vec<String>.
|
||||||
fn json_str_list(v: &Option<Value>) -> Vec<String> {
|
fn json_str_list(v: &Option<Value>) -> Vec<String> {
|
||||||
match v {
|
match v {
|
||||||
Some(Value::Array(arr)) => {
|
Some(Value::Array(arr)) => arr
|
||||||
arr.iter().filter_map(|x| x.as_str().map(String::from)).collect()
|
.iter()
|
||||||
}
|
.filter_map(|x| x.as_str().map(String::from))
|
||||||
|
.collect(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,8 @@ mod entities;
|
|||||||
mod error;
|
mod error;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
mod media;
|
mod media;
|
||||||
mod merge;
|
|
||||||
mod mentions;
|
mod mentions;
|
||||||
|
mod merge;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pagination;
|
mod pagination;
|
||||||
mod password;
|
mod password;
|
||||||
@@ -63,21 +63,21 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
running_jobs: std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
|
running_jobs: std::sync::Arc::new(
|
||||||
|
tokio::sync::Mutex::new(std::collections::HashSet::new()),
|
||||||
|
),
|
||||||
security: std::sync::Arc::new(crate::throttle::Security::new()),
|
security: std::sync::Arc::new(crate::throttle::Security::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background scheduler: runs jobs whose `interval_hours` has elapsed,
|
// Background scheduler: runs jobs whose `interval_hours` has elapsed,
|
||||||
// replacing Celery beat. Restart-safe (last_run persists in the DB).
|
// replacing Celery beat. Restart-safe (last_run persists in the DB).
|
||||||
// Gated so it never double-schedules against a still-running Django beat
|
// Gated by ENABLE_SCHEDULER — exactly one process may own `api_job`, so
|
||||||
// during the strangler cutover — exactly one scheduler may own `api_job`.
|
// enable the scheduler on at most one running instance.
|
||||||
if state.config.enable_scheduler {
|
if state.config.enable_scheduler {
|
||||||
tracing::info!("job scheduler ENABLED (this process owns api_job scheduling)");
|
tracing::info!("job scheduler ENABLED (this process owns api_job scheduling)");
|
||||||
jobs::spawn_scheduler(state.clone());
|
jobs::spawn_scheduler(state.clone());
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!("job scheduler DISABLED (ENABLE_SCHEDULER!=true)");
|
||||||
"job scheduler DISABLED (ENABLE_SCHEDULER!=true) — Django beat assumed to own jobs"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodically sweep aged-out rate-limit / code buckets so the in-memory
|
// Periodically sweep aged-out rate-limit / code buckets so the in-memory
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ use crate::entities::{flavor, notification, rating, reply};
|
|||||||
/// Rank a rating for conflict resolution: longest comment wins, newer breaks
|
/// Rank a rating for conflict resolution: longest comment wins, newer breaks
|
||||||
/// ties (mirrors Python's tuple comparison `(len(comment), created_at)`).
|
/// ties (mirrors Python's tuple comparison `(len(comment), created_at)`).
|
||||||
fn quality(r: &rating::Model) -> (usize, chrono::NaiveDateTime) {
|
fn quality(r: &rating::Model) -> (usize, chrono::NaiveDateTime) {
|
||||||
(r.comment.as_deref().unwrap_or("").chars().count(), r.created_at)
|
(
|
||||||
|
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
|
/// Merge `remove` into `keep`. Re-assigns all of `remove`'s ratings; when a user
|
||||||
|
|||||||
@@ -47,13 +47,7 @@ fn with_page(ctx: &RequestCtx, page: u64) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Serialize> Paginated<T> {
|
impl<T: Serialize> Paginated<T> {
|
||||||
pub fn build(
|
pub fn build(ctx: &RequestCtx, results: Vec<T>, count: u64, page: u64, page_size: u64) -> Self {
|
||||||
ctx: &RequestCtx,
|
|
||||||
results: Vec<T>,
|
|
||||||
count: u64,
|
|
||||||
page: u64,
|
|
||||||
page_size: u64,
|
|
||||||
) -> Self {
|
|
||||||
let total_pages = count.div_ceil(page_size.max(1));
|
let total_pages = count.div_ceil(page_size.max(1));
|
||||||
let next = if page < total_pages {
|
let next = if page < total_pages {
|
||||||
Some(with_page(ctx, page + 1))
|
Some(with_page(ctx, page + 1))
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
//! (argon2/bcrypt) would need their own branch — login would fall through to a
|
//! (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.
|
//! rejection for those, so keep issuance on Django if the hasher ever changes.
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
@@ -45,7 +45,12 @@ pub fn verify_password(password: &str, encoded: &str) -> bool {
|
|||||||
if iterations == 0 {
|
if iterations == 0 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), iterations, &mut derived);
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||||
|
password.as_bytes(),
|
||||||
|
salt.as_bytes(),
|
||||||
|
iterations,
|
||||||
|
&mut derived,
|
||||||
|
);
|
||||||
|
|
||||||
// Constant-time compare, length-checked first.
|
// Constant-time compare, length-checked first.
|
||||||
if expected.len() != derived.len() {
|
if expected.len() != derived.len() {
|
||||||
@@ -60,7 +65,12 @@ pub fn verify_password(password: &str, encoded: &str) -> bool {
|
|||||||
pub fn hash_password(password: &str) -> String {
|
pub fn hash_password(password: &str) -> String {
|
||||||
let salt = uuid::Uuid::new_v4().simple().to_string();
|
let salt = uuid::Uuid::new_v4().simple().to_string();
|
||||||
let mut derived = [0u8; DK_LEN];
|
let mut derived = [0u8; DK_LEN];
|
||||||
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), ITERATIONS, &mut derived);
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||||
|
password.as_bytes(),
|
||||||
|
salt.as_bytes(),
|
||||||
|
ITERATIONS,
|
||||||
|
&mut derived,
|
||||||
|
);
|
||||||
let b64 = B64.encode(derived);
|
let b64 = B64.encode(derived);
|
||||||
format!("{ALGORITHM}${ITERATIONS}${salt}${b64}")
|
format!("{ALGORITHM}${ITERATIONS}${salt}${b64}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ fn too_similar(password: &str, attrs: &UserAttrs) -> Result<(), String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if quick_ratio(&password, part) >= MAX_SIMILARITY {
|
if quick_ratio(&password, part) >= MAX_SIMILARITY {
|
||||||
return Err(format!("The password is too similar to the {verbose_name}."));
|
return Err(format!(
|
||||||
|
"The password is too similar to the {verbose_name}."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,15 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/admin-custom/send_test_email/", post(send_test_email))
|
.route("/admin-custom/send_test_email/", post(send_test_email))
|
||||||
.route("/admin-custom/users/", get(users_list))
|
.route("/admin-custom/users/", get(users_list))
|
||||||
.route("/admin-custom/{pk}/trigger_job/", post(trigger_job))
|
.route("/admin-custom/{pk}/trigger_job/", post(trigger_job))
|
||||||
.route("/admin-custom/{pk}/update_job_schedule/", patch(update_job_schedule))
|
.route(
|
||||||
|
"/admin-custom/{pk}/update_job_schedule/",
|
||||||
|
patch(update_job_schedule),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/admin-custom/{pk}/user_detail/",
|
"/admin-custom/{pk}/user_detail/",
|
||||||
get(user_detail_get).patch(user_detail_patch).delete(user_detail_delete),
|
get(user_detail_get)
|
||||||
|
.patch(user_detail_patch)
|
||||||
|
.delete(user_detail_delete),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +140,9 @@ fn opt_bool(data: &Value, field: &str) -> ApiResult<Option<bool>> {
|
|||||||
match data.get(field) {
|
match data.get(field) {
|
||||||
None | Some(Value::Null) => Ok(None),
|
None | Some(Value::Null) => Ok(None),
|
||||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||||
_ => Err(ApiError::Validation(json!({ field: ["Must be a valid boolean."] }))),
|
_ => Err(ApiError::Validation(
|
||||||
|
json!({ field: ["Must be a valid boolean."] }),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +164,9 @@ async fn config_patch(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
return Err(ApiError::Validation(json!({ "site_name": ["Not a valid string."] })))
|
return Err(ApiError::Validation(
|
||||||
|
json!({ "site_name": ["Not a valid string."] }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(b) = opt_bool(&data, "maintenance_mode")? {
|
if let Some(b) = opt_bool(&data, "maintenance_mode")? {
|
||||||
@@ -218,11 +227,14 @@ async fn trigger_job(
|
|||||||
_admin: AdminUser,
|
_admin: AdminUser,
|
||||||
Path(pk): Path<i32>,
|
Path(pk): Path<i32>,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let row = Job::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
let row = Job::find_by_id(pk)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
let Some(job_impl) = jobs::lookup(&row.name) else {
|
let Some(job_impl) = jobs::lookup(&row.name) else {
|
||||||
return Err(ApiError::JsonStatus(
|
return Err(ApiError::JsonStatus(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
json!({ "error": format!("No celery task registered for {}", row.name) }),
|
json!({ "error": format!("No job registered for {}", row.name) }),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +257,10 @@ async fn update_job_schedule(
|
|||||||
Path(pk): Path<i32>,
|
Path(pk): Path<i32>,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let row = Job::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
let row = Job::find_by_id(pk)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
let data = parse_body(&body);
|
let data = parse_body(&body);
|
||||||
|
|
||||||
let interval = match data.get("interval_hours") {
|
let interval = match data.get("interval_hours") {
|
||||||
@@ -381,8 +396,10 @@ async fn users_list(State(state): State<AppState>, _admin: AdminUser) -> ApiResu
|
|||||||
let out: Vec<Value> = users
|
let out: Vec<Value> = users
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| {
|
.map(|u| {
|
||||||
let ips: Vec<String> =
|
let ips: Vec<String> = ip_map
|
||||||
ip_map.get(&u.id).map(|v| v.iter().take(3).cloned().collect()).unwrap_or_default();
|
.get(&u.id)
|
||||||
|
.map(|v| v.iter().take(3).cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
admin_user_json(u, &ips)
|
admin_user_json(u, &ips)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -395,7 +412,10 @@ async fn build_user_detail(
|
|||||||
ctx: &RequestCtx,
|
ctx: &RequestCtx,
|
||||||
u: &user::Model,
|
u: &user::Model,
|
||||||
) -> ApiResult<Value> {
|
) -> ApiResult<Value> {
|
||||||
let ips = ips_by_user(state, &[u.id]).await?.remove(&u.id).unwrap_or_default();
|
let ips = ips_by_user(state, &[u.id])
|
||||||
|
.await?
|
||||||
|
.remove(&u.id)
|
||||||
|
.unwrap_or_default();
|
||||||
let ratings = Rating::find()
|
let ratings = Rating::find()
|
||||||
.filter(rating::Column::UserId.eq(u.id))
|
.filter(rating::Column::UserId.eq(u.id))
|
||||||
.order_by_desc(rating::Column::CreatedAt)
|
.order_by_desc(rating::Column::CreatedAt)
|
||||||
@@ -413,7 +433,10 @@ async fn user_detail_get(
|
|||||||
_admin: AdminUser,
|
_admin: AdminUser,
|
||||||
Path(pk): Path<i32>,
|
Path(pk): Path<i32>,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let u = User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
let u = User::find_by_id(pk)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
Ok(Json(build_user_detail(&state, &ctx, &u).await?))
|
Ok(Json(build_user_detail(&state, &ctx, &u).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +447,10 @@ async fn user_detail_patch(
|
|||||||
Path(pk): Path<i32>,
|
Path(pk): Path<i32>,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let u = User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
let u = User::find_by_id(pk)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
let data = parse_body(&body);
|
let data = parse_body(&body);
|
||||||
let u = match data.get("is_active") {
|
let u = match data.get("is_active") {
|
||||||
Some(Value::Bool(b)) => {
|
Some(Value::Bool(b)) => {
|
||||||
@@ -442,7 +468,10 @@ async fn user_detail_delete(
|
|||||||
_admin: AdminUser,
|
_admin: AdminUser,
|
||||||
Path(pk): Path<i32>,
|
Path(pk): Path<i32>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
User::find_by_id(pk)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
crate::routes::users::delete_user_cascade(&state, pk).await?;
|
crate::routes::users::delete_user_cascade(&state, pk).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -20,9 +20,7 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::blacklist::{blacklist, is_blacklisted, record_outstanding};
|
use crate::blacklist::{blacklist, is_blacklisted, record_outstanding};
|
||||||
use crate::cookies::{
|
use crate::cookies::{clear_cookie, cookie_value, set_cookie, COOKIE_ACCESS, COOKIE_REFRESH};
|
||||||
clear_cookie, cookie_value, set_cookie, COOKIE_ACCESS, COOKIE_REFRESH,
|
|
||||||
};
|
|
||||||
use crate::entities::prelude::*;
|
use crate::entities::prelude::*;
|
||||||
use crate::entities::user;
|
use crate::entities::user;
|
||||||
use crate::error::ApiResult;
|
use crate::error::ApiResult;
|
||||||
@@ -102,7 +100,11 @@ async fn login(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(ok_with_cookies(state.config.jwt_cookie_secure, &access, &refresh))
|
Ok(ok_with_cookies(
|
||||||
|
state.config.jwt_cookie_secure,
|
||||||
|
&access,
|
||||||
|
&refresh,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
@@ -225,7 +227,12 @@ fn ok_with_cookies(secure: bool, access: &Minted, refresh: &Minted) -> Response
|
|||||||
);
|
);
|
||||||
append_cookie(
|
append_cookie(
|
||||||
&mut resp,
|
&mut resp,
|
||||||
&set_cookie(COOKIE_REFRESH, &refresh.token, REFRESH_LIFETIME_SECS, secure),
|
&set_cookie(
|
||||||
|
COOKIE_REFRESH,
|
||||||
|
&refresh.token,
|
||||||
|
REFRESH_LIFETIME_SECS,
|
||||||
|
secure,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ async fn top(
|
|||||||
.limit(10)
|
.limit(10)
|
||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(build_flavors(&state, &ctx, models, viewer, &[]).await?))
|
Ok(Json(
|
||||||
|
build_flavors(&state, &ctx, models, viewer, &[]).await?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/flavors/newest/ — 10 most recently created flavors.
|
/// GET /api/flavors/newest/ — 10 most recently created flavors.
|
||||||
@@ -156,7 +158,9 @@ async fn newest(
|
|||||||
.limit(10)
|
.limit(10)
|
||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(build_flavors(&state, &ctx, models, viewer, &[]).await?))
|
Ok(Json(
|
||||||
|
build_flavors(&state, &ctx, models, viewer, &[]).await?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the
|
/// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the
|
||||||
@@ -189,7 +193,9 @@ async fn followed_top(
|
|||||||
.limit(10)
|
.limit(10)
|
||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(build_flavors(&state, &ctx, models, Some(uid), &[]).await?))
|
Ok(Json(
|
||||||
|
build_flavors(&state, &ctx, models, Some(uid), &[]).await?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -98,9 +98,10 @@ async fn create(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<(StatusCode, Json<RatingOut>)> {
|
) -> ApiResult<(StatusCode, Json<RatingOut>)> {
|
||||||
// django_ratelimit(key="user", rate="10/m") on RatingViewSet.create.
|
// django_ratelimit(key="user", rate="10/m") on RatingViewSet.create.
|
||||||
state
|
state.security.check_rate(
|
||||||
.security
|
&format!("rating:user:{uid}"),
|
||||||
.check_rate(&format!("rating:user:{uid}"), crate::throttle::RATING_CREATE)?;
|
crate::throttle::RATING_CREATE,
|
||||||
|
)?;
|
||||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||||
|
|
||||||
// DRF validation, aggregated and field-keyed, in serializer field order.
|
// DRF validation, aggregated and field-keyed, in serializer field order.
|
||||||
@@ -398,10 +399,7 @@ async fn feed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/ratings/recent/ — 10 newest with a non-empty comment (AllowAny).
|
/// GET /api/ratings/recent/ — 10 newest with a non-empty comment (AllowAny).
|
||||||
async fn recent(
|
async fn recent(State(state): State<AppState>, ctx: RequestCtx) -> ApiResult<Json<Vec<RatingOut>>> {
|
||||||
State(state): State<AppState>,
|
|
||||||
ctx: RequestCtx,
|
|
||||||
) -> ApiResult<Json<Vec<RatingOut>>> {
|
|
||||||
let models = Rating::find()
|
let models = Rating::find()
|
||||||
.filter(rating::Column::Comment.is_not_null())
|
.filter(rating::Column::Comment.is_not_null())
|
||||||
.filter(rating::Column::Comment.ne(""))
|
.filter(rating::Column::Comment.ne(""))
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ use crate::state::AppState;
|
|||||||
use crate::web::RequestCtx;
|
use crate::web::RequestCtx;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new().route("/replies/", get(list)).route(
|
||||||
.route("/replies/", get(list))
|
|
||||||
.route(
|
|
||||||
"/replies/{id}/",
|
"/replies/{id}/",
|
||||||
get(retrieve).put(update).patch(update).delete(destroy),
|
get(retrieve).put(update).patch(update).delete(destroy),
|
||||||
)
|
)
|
||||||
@@ -124,7 +122,14 @@ async fn update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let model = active.update(&state.db).await?;
|
let model = active.update(&state.db).await?;
|
||||||
parse_mentions(&state.db, &model.text, uid, Some(model.rating_id), Some(model.id)).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?;
|
let mut dtos = build_replies(&state, vec![model]).await?;
|
||||||
dtos.pop().map(Json).ok_or(ApiError::Internal)
|
dtos.pop().map(Json).ok_or(ApiError::Internal)
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ use axum::extract::{Path, State};
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
use sea_orm::{
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::dto::NotificationOut;
|
use crate::dto::NotificationOut;
|
||||||
use crate::entities::prelude::*;
|
|
||||||
use crate::entities::notification;
|
use crate::entities::notification;
|
||||||
|
use crate::entities::prelude::*;
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::service::build_notifications;
|
use crate::service::build_notifications;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -64,7 +62,10 @@ async fn mark_all_read(
|
|||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
Notification::update_many()
|
Notification::update_many()
|
||||||
.col_expr(notification::Column::IsRead, sea_orm::sea_query::Expr::value(true))
|
.col_expr(
|
||||||
|
notification::Column::IsRead,
|
||||||
|
sea_orm::sea_query::Expr::value(true),
|
||||||
|
)
|
||||||
.filter(notification::Column::RecipientId.eq(uid))
|
.filter(notification::Column::RecipientId.eq(uid))
|
||||||
.filter(notification::Column::IsRead.eq(false))
|
.filter(notification::Column::IsRead.eq(false))
|
||||||
.exec(&state.db)
|
.exec(&state.db)
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ async fn retrieve(
|
|||||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_str(data: &Value, field: &str, partial: bool, max: Option<usize>) -> ApiResult<Option<String>> {
|
fn require_str(
|
||||||
|
data: &Value,
|
||||||
|
field: &str,
|
||||||
|
partial: bool,
|
||||||
|
max: Option<usize>,
|
||||||
|
) -> ApiResult<Option<String>> {
|
||||||
match data.get(field) {
|
match data.get(field) {
|
||||||
Some(Value::String(s)) if !s.is_empty() => {
|
Some(Value::String(s)) if !s.is_empty() => {
|
||||||
if let Some(m) = max {
|
if let Some(m) = max {
|
||||||
@@ -112,9 +117,9 @@ fn require_str(data: &Value, field: &str, partial: bool, max: Option<usize>) ->
|
|||||||
Some(Value::String(_)) => Err(ApiError::Validation(
|
Some(Value::String(_)) => Err(ApiError::Validation(
|
||||||
json!({ field: ["This field may not be blank."] }),
|
json!({ field: ["This field may not be blank."] }),
|
||||||
)),
|
)),
|
||||||
Some(v) if !v.is_null() => {
|
Some(v) if !v.is_null() => Err(ApiError::Validation(
|
||||||
Err(ApiError::Validation(json!({ field: ["Not a valid string."] })))
|
json!({ field: ["Not a valid string."] }),
|
||||||
}
|
)),
|
||||||
_ if partial => Ok(None),
|
_ if partial => Ok(None),
|
||||||
_ => Err(ApiError::Validation(
|
_ => Err(ApiError::Validation(
|
||||||
json!({ field: ["This field is required."] }),
|
json!({ field: ["This field is required."] }),
|
||||||
@@ -168,7 +173,10 @@ async fn create(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
|
let mut dtos = build_tickets(&state, &ctx, vec![t]).await?;
|
||||||
Ok((StatusCode::CREATED, Json(dtos.pop().ok_or(ApiError::Internal)?)))
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(dtos.pop().ok_or(ApiError::Internal)?),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT/PATCH /api/tickets/{id}/ — subject/description only (status, user read-only).
|
/// PUT/PATCH /api/tickets/{id}/ — subject/description only (status, user read-only).
|
||||||
@@ -211,7 +219,9 @@ async fn destroy(
|
|||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
let me = load_user(&state, uid).await?;
|
let me = load_user(&state, uid).await?;
|
||||||
if !me.is_superuser {
|
if !me.is_superuser {
|
||||||
return Err(ApiError::Forbidden("Only admins can delete tickets.".into()));
|
return Err(ApiError::Forbidden(
|
||||||
|
"Only admins can delete tickets.".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// get_object is scoped; superusers see all, so a missing ticket is 404.
|
// get_object is scoped; superusers see all, so a missing ticket is 404.
|
||||||
Ticket::find_by_id(id)
|
Ticket::find_by_id(id)
|
||||||
|
|||||||
@@ -39,9 +39,23 @@ use crate::throttle;
|
|||||||
use crate::web::RequestCtx;
|
use crate::web::RequestCtx;
|
||||||
|
|
||||||
const THEMES: &[&str] = &[
|
const THEMES: &[&str] = &[
|
||||||
"holy_light", "holy_dark", "latte", "frappe", "macchiato", "mocha", "pink_pastel",
|
"holy_light",
|
||||||
"mint_pastel", "lavender_pastel", "dracula", "nord", "gruvbox", "oceanic", "t0p_sai",
|
"holy_dark",
|
||||||
"t0p_trench", "t0p_blurryface", "t0p_clancy",
|
"latte",
|
||||||
|
"frappe",
|
||||||
|
"macchiato",
|
||||||
|
"mocha",
|
||||||
|
"pink_pastel",
|
||||||
|
"mint_pastel",
|
||||||
|
"lavender_pastel",
|
||||||
|
"dracula",
|
||||||
|
"nord",
|
||||||
|
"gruvbox",
|
||||||
|
"oceanic",
|
||||||
|
"t0p_sai",
|
||||||
|
"t0p_trench",
|
||||||
|
"t0p_blurryface",
|
||||||
|
"t0p_clancy",
|
||||||
];
|
];
|
||||||
const LANGUAGES: &[&str] = &["en", "de"];
|
const LANGUAGES: &[&str] = &["en", "de"];
|
||||||
const DRAWER_ANCHORS: &[&str] = &["left", "right"];
|
const DRAWER_ANCHORS: &[&str] = &["left", "right"];
|
||||||
@@ -60,10 +74,22 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/users/change_password/", post(change_password))
|
.route("/users/change_password/", post(change_password))
|
||||||
.route("/users/update_profile/", patch(update_profile))
|
.route("/users/update_profile/", patch(update_profile))
|
||||||
.route("/users/confirm_email/", post(confirm_email))
|
.route("/users/confirm_email/", post(confirm_email))
|
||||||
.route("/users/request_password_reset/", post(request_password_reset))
|
.route(
|
||||||
.route("/users/complete_password_reset/", post(complete_password_reset))
|
"/users/request_password_reset/",
|
||||||
.route("/users/request_account_deletion/", post(request_account_deletion))
|
post(request_password_reset),
|
||||||
.route("/users/confirm_account_deletion/", post(confirm_account_deletion))
|
)
|
||||||
|
.route(
|
||||||
|
"/users/complete_password_reset/",
|
||||||
|
post(complete_password_reset),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/request_account_deletion/",
|
||||||
|
post(request_account_deletion),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/confirm_account_deletion/",
|
||||||
|
post(confirm_account_deletion),
|
||||||
|
)
|
||||||
.route("/users/dashboard/", get(dashboard))
|
.route("/users/dashboard/", get(dashboard))
|
||||||
.route("/users/{id}/", get(retrieve))
|
.route("/users/{id}/", get(retrieve))
|
||||||
.route("/users/{id}/follow/", post(follow))
|
.route("/users/{id}/follow/", post(follow))
|
||||||
@@ -207,7 +233,9 @@ async fn following_ids(state: &AppState, from: i32) -> ApiResult<Vec<i32>> {
|
|||||||
[from.into()],
|
[from.into()],
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
rows.into_iter().map(|r| r.try_get("", "uid").map_err(Into::into)).collect()
|
rows.into_iter()
|
||||||
|
.map(|r| r.try_get("", "uid").map_err(Into::into))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User ids that follow `to` (the `followers` set).
|
/// User ids that follow `to` (the `followers` set).
|
||||||
@@ -220,7 +248,9 @@ async fn follower_ids(state: &AppState, to: i32) -> ApiResult<Vec<i32>> {
|
|||||||
[to.into()],
|
[to.into()],
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
rows.into_iter().map(|r| r.try_get("", "uid").map_err(Into::into)).collect()
|
rows.into_iter()
|
||||||
|
.map(|r| r.try_get("", "uid").map_err(Into::into))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn users_by_ids(state: &AppState, ids: &[i32]) -> ApiResult<Vec<user::Model>> {
|
async fn users_by_ids(state: &AppState, ids: &[i32]) -> ApiResult<Vec<user::Model>> {
|
||||||
@@ -368,9 +398,10 @@ async fn update_preferences(
|
|||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
let banner_id = v.as_i64().map(|n| n as i32).or_else(|| {
|
let banner_id = v
|
||||||
v.as_str().and_then(|s| s.parse::<i32>().ok())
|
.as_i64()
|
||||||
});
|
.map(|n| n as i32)
|
||||||
|
.or_else(|| v.as_str().and_then(|s| s.parse::<i32>().ok()));
|
||||||
match banner_id {
|
match banner_id {
|
||||||
Some(bid) => {
|
Some(bid) => {
|
||||||
let banner = Banner::find_by_id(bid)
|
let banner = Banner::find_by_id(bid)
|
||||||
@@ -472,8 +503,14 @@ async fn change_password(
|
|||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let u = load_user(&state, uid).await?;
|
let u = load_user(&state, uid).await?;
|
||||||
let data = parse_body(&body);
|
let data = parse_body(&body);
|
||||||
let old = data.get("old_password").and_then(|v| v.as_str()).unwrap_or("");
|
let old = data
|
||||||
let new = data.get("new_password").and_then(|v| v.as_str()).unwrap_or("");
|
.get("old_password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let new = data
|
||||||
|
.get("new_password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
if !crate::password::verify_password(old, &u.password) {
|
if !crate::password::verify_password(old, &u.password) {
|
||||||
return Err(err_json(
|
return Err(err_json(
|
||||||
@@ -594,7 +631,9 @@ async fn confirm_email(
|
|||||||
active.email_confirmation_code = Set(None);
|
active.email_confirmation_code = Set(None);
|
||||||
active.update(&state.db).await?;
|
active.update(&state.db).await?;
|
||||||
state.security.clear_code(&code_key);
|
state.security.clear_code(&code_key);
|
||||||
Ok(Json(json!({ "status": "Email confirmed", "email": pending })))
|
Ok(Json(
|
||||||
|
json!({ "status": "Email confirmed", "email": pending }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- signup / verification ----------------------------------------------
|
// ---- signup / verification ----------------------------------------------
|
||||||
@@ -607,7 +646,9 @@ async fn signup(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<(StatusCode, Json<Value>)> {
|
) -> ApiResult<(StatusCode, Json<Value>)> {
|
||||||
let ip = crate::web::client_ip(&headers, peer);
|
let ip = crate::web::client_ip(&headers, peer);
|
||||||
state.security.check_rate(&format!("signup:ip:{ip}"), throttle::SIGNUP)?;
|
state
|
||||||
|
.security
|
||||||
|
.check_rate(&format!("signup:ip:{ip}"), throttle::SIGNUP)?;
|
||||||
let data = parse_body(&body);
|
let data = parse_body(&body);
|
||||||
let (Some(username), Some(email), Some(password)) = (
|
let (Some(username), Some(email), Some(password)) = (
|
||||||
field_str(&data, "username"),
|
field_str(&data, "username"),
|
||||||
@@ -835,9 +876,10 @@ async fn request_password_reset(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let ip = crate::web::client_ip(&headers, peer);
|
let ip = crate::web::client_ip(&headers, peer);
|
||||||
state
|
state.security.check_rate(
|
||||||
.security
|
&format!("pwreset_req:ip:{ip}"),
|
||||||
.check_rate(&format!("pwreset_req:ip:{ip}"), throttle::PASSWORD_RESET_REQUEST)?;
|
throttle::PASSWORD_RESET_REQUEST,
|
||||||
|
)?;
|
||||||
let generic = json!({
|
let generic = json!({
|
||||||
"status": "If an account exists with this email, a reset code has been sent."
|
"status": "If an account exists with this email, a reset code has been sent."
|
||||||
});
|
});
|
||||||
@@ -878,12 +920,16 @@ async fn complete_password_reset(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let ip = crate::web::client_ip(&headers, peer);
|
let ip = crate::web::client_ip(&headers, peer);
|
||||||
state
|
state.security.check_rate(
|
||||||
.security
|
&format!("pwreset_done:ip:{ip}"),
|
||||||
.check_rate(&format!("pwreset_done:ip:{ip}"), throttle::PASSWORD_RESET_COMPLETE)?;
|
throttle::PASSWORD_RESET_COMPLETE,
|
||||||
|
)?;
|
||||||
let data = parse_body(&body);
|
let data = parse_body(&body);
|
||||||
let email = field_str(&data, "email").unwrap_or_default();
|
let email = field_str(&data, "email").unwrap_or_default();
|
||||||
let code = data.get("code").and_then(|v| v.as_str()).unwrap_or_default();
|
let code = data
|
||||||
|
.get("code")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
let new_password = data.get("password").and_then(|v| v.as_str()).unwrap_or("");
|
let new_password = data.get("password").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
let code_key = pwreset_code_key(&email);
|
let code_key = pwreset_code_key(&email);
|
||||||
@@ -935,9 +981,10 @@ async fn request_account_deletion(
|
|||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
_body: Bytes,
|
_body: Bytes,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
state
|
state.security.check_rate(
|
||||||
.security
|
&format!("deletion_req:user:{uid}"),
|
||||||
.check_rate(&format!("deletion_req:user:{uid}"), throttle::ACCOUNT_DELETION_REQUEST)?;
|
throttle::ACCOUNT_DELETION_REQUEST,
|
||||||
|
)?;
|
||||||
let user = load_user(&state, uid).await?;
|
let user = load_user(&state, uid).await?;
|
||||||
let code = gen_code();
|
let code = gen_code();
|
||||||
let uname = user.username.clone();
|
let uname = user.username.clone();
|
||||||
@@ -994,9 +1041,8 @@ async fn confirm_account_deletion(
|
|||||||
/// in dependency order, inside one transaction.
|
/// in dependency order, inside one transaction.
|
||||||
pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult<()> {
|
pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult<()> {
|
||||||
let txn = state.db.begin().await?;
|
let txn = state.db.begin().await?;
|
||||||
let exec = |sql: &'static str| {
|
let exec =
|
||||||
Statement::from_sql_and_values(DbBackend::Sqlite, sql, [uid.into()])
|
|sql: &'static str| Statement::from_sql_and_values(DbBackend::Sqlite, sql, [uid.into()]);
|
||||||
};
|
|
||||||
|
|
||||||
// Notifications referencing the user directly (recipient/actor) or via the
|
// Notifications referencing the user directly (recipient/actor) or via the
|
||||||
// user's ratings/replies/tickets/profile-comments.
|
// user's ratings/replies/tickets/profile-comments.
|
||||||
@@ -1015,18 +1061,21 @@ pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult
|
|||||||
OR rating_id IN (SELECT id FROM api_rating WHERE user_id = ?1)",
|
OR rating_id IN (SELECT id FROM api_rating WHERE user_id = ?1)",
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
txn.execute(exec("DELETE FROM api_rating WHERE user_id = ?")).await?;
|
txn.execute(exec("DELETE FROM api_rating WHERE user_id = ?"))
|
||||||
|
.await?;
|
||||||
txn.execute(exec(
|
txn.execute(exec(
|
||||||
"DELETE FROM api_ticketmessage WHERE user_id = ?1 \
|
"DELETE FROM api_ticketmessage WHERE user_id = ?1 \
|
||||||
OR ticket_id IN (SELECT id FROM api_ticket WHERE user_id = ?1)",
|
OR ticket_id IN (SELECT id FROM api_ticket WHERE user_id = ?1)",
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
txn.execute(exec("DELETE FROM api_ticket WHERE user_id = ?")).await?;
|
txn.execute(exec("DELETE FROM api_ticket WHERE user_id = ?"))
|
||||||
|
.await?;
|
||||||
txn.execute(exec(
|
txn.execute(exec(
|
||||||
"DELETE FROM api_profilecomment WHERE author_id = ?1 OR profile_owner_id = ?1",
|
"DELETE FROM api_profilecomment WHERE author_id = ?1 OR profile_owner_id = ?1",
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
txn.execute(exec("DELETE FROM api_userip WHERE user_id = ?")).await?;
|
txn.execute(exec("DELETE FROM api_userip WHERE user_id = ?"))
|
||||||
|
.await?;
|
||||||
txn.execute(exec(
|
txn.execute(exec(
|
||||||
"DELETE FROM api_user_following WHERE from_user_id = ?1 OR to_user_id = ?1",
|
"DELETE FROM api_user_following WHERE from_user_id = ?1 OR to_user_id = ?1",
|
||||||
))
|
))
|
||||||
@@ -1041,7 +1090,8 @@ pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult
|
|||||||
"DELETE FROM token_blacklist_outstandingtoken WHERE user_id = ?",
|
"DELETE FROM token_blacklist_outstandingtoken WHERE user_id = ?",
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
txn.execute(exec("DELETE FROM api_user WHERE id = ?")).await?;
|
txn.execute(exec("DELETE FROM api_user WHERE id = ?"))
|
||||||
|
.await?;
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1194,10 @@ async fn add_comment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut dtos = build_profile_comments(&state, &ctx, vec![comment]).await?;
|
let mut dtos = build_profile_comments(&state, &ctx, vec![comment]).await?;
|
||||||
Ok((StatusCode::CREATED, Json(dtos.pop().ok_or(ApiError::Internal)?)))
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(dtos.pop().ok_or(ApiError::Internal)?),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/users/{id}/delete_comment/{comment_id}/
|
/// DELETE /api/users/{id}/delete_comment/{comment_id}/
|
||||||
@@ -1298,8 +1351,14 @@ async fn dashboard(
|
|||||||
.map(|c| (c.id, c.name))
|
.map(|c| (c.id, c.name))
|
||||||
.collect();
|
.collect();
|
||||||
all_flavors.sort_by(|a, b| {
|
all_flavors.sort_by(|a, b| {
|
||||||
let ca = cat_names.get(&a.category_id).map(String::as_str).unwrap_or("");
|
let ca = cat_names
|
||||||
let cb = cat_names.get(&b.category_id).map(String::as_str).unwrap_or("");
|
.get(&a.category_id)
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let cb = cat_names
|
||||||
|
.get(&b.category_id)
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
|
ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+56
-23
@@ -10,11 +10,13 @@ use sea_orm::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut, TicketOut,
|
FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut,
|
||||||
UserOut,
|
TicketOut, UserOut,
|
||||||
};
|
};
|
||||||
use crate::entities::prelude::*;
|
use crate::entities::prelude::*;
|
||||||
use crate::entities::{category, flavor, notification, profile_comment, rating, reply, ticket, ticket_message, user};
|
use crate::entities::{
|
||||||
|
category, flavor, notification, profile_comment, rating, reply, ticket, ticket_message, user,
|
||||||
|
};
|
||||||
use crate::error::ApiResult;
|
use crate::error::ApiResult;
|
||||||
use crate::media::{flavor_image_list, flavor_primary_image, image_field_url};
|
use crate::media::{flavor_image_list, flavor_primary_image, image_field_url};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -22,7 +24,10 @@ use crate::web::RequestCtx;
|
|||||||
|
|
||||||
/// Comma-join i32 ids for an inlined SQL `IN (...)` list. Safe: ints only.
|
/// Comma-join i32 ids for an inlined SQL `IN (...)` list. Safe: ints only.
|
||||||
fn id_list(ids: &[i32]) -> String {
|
fn id_list(ids: &[i32]) -> String {
|
||||||
ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",")
|
ids.iter()
|
||||||
|
.map(|i| i.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional avatar → absolute media URL (empty path treated as absent).
|
/// Optional avatar → absolute media URL (empty path treated as absent).
|
||||||
@@ -204,10 +209,8 @@ pub async fn build_flavors(
|
|||||||
.filter(crate::entities::category::Column::Id.is_in(category_ids))
|
.filter(crate::entities::category::Column::Id.is_in(category_ids))
|
||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
let cat_map: HashMap<i32, (String, String)> = cats
|
let cat_map: HashMap<i32, (String, String)> =
|
||||||
.into_iter()
|
cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect();
|
||||||
.map(|c| (c.id, (c.name, c.slug)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Ratings for these flavors (RatingSerializer order: -created_at)
|
// Ratings for these flavors (RatingSerializer order: -created_at)
|
||||||
let ratings = Rating::find()
|
let ratings = Rating::find()
|
||||||
@@ -241,8 +244,7 @@ pub async fn build_flavors(
|
|||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
let user_map: HashMap<i32, user::Model> =
|
let user_map: HashMap<i32, user::Model> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||||
users.into_iter().map(|u| (u.id, u)).collect();
|
|
||||||
|
|
||||||
// Replies grouped by rating id
|
// Replies grouped by rating id
|
||||||
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
|
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
|
||||||
@@ -264,8 +266,7 @@ pub async fn build_flavors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flavor lookup for per-rating flavor fields
|
// Flavor lookup for per-rating flavor fields
|
||||||
let flavor_map: HashMap<i32, &flavor::Model> =
|
let flavor_map: HashMap<i32, &flavor::Model> = flavors.iter().map(|f| (f.id, f)).collect();
|
||||||
flavors.iter().map(|f| (f.id, f)).collect();
|
|
||||||
|
|
||||||
// Ratings grouped by flavor id
|
// Ratings grouped by flavor id
|
||||||
let mut ratings_by_flavor: HashMap<i32, Vec<RatingOut>> = HashMap::new();
|
let mut ratings_by_flavor: HashMap<i32, Vec<RatingOut>> = HashMap::new();
|
||||||
@@ -334,9 +335,22 @@ pub async fn build_flavors(
|
|||||||
fn_ += 1;
|
fn_ += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avg_by_flavor.insert(*fid, if n == 0 { None } else { Some(sum as f64 / n as f64) });
|
avg_by_flavor.insert(
|
||||||
followed_avg_by_flavor
|
*fid,
|
||||||
.insert(*fid, if fn_ == 0 { None } else { Some(fsum as f64 / fn_ as f64) });
|
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);
|
dist_by_flavor.insert(*fid, dist);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,11 +384,19 @@ pub async fn build_flavors(
|
|||||||
f.image.as_deref(),
|
f.image.as_deref(),
|
||||||
f.image_url.as_deref(),
|
f.image_url.as_deref(),
|
||||||
),
|
),
|
||||||
image_urls: flavor_image_list(ctx, media, &local_paths, f.image.as_deref(), &image_urls),
|
image_urls: flavor_image_list(
|
||||||
|
ctx,
|
||||||
|
media,
|
||||||
|
&local_paths,
|
||||||
|
f.image.as_deref(),
|
||||||
|
&image_urls,
|
||||||
|
),
|
||||||
is_available: f.is_available,
|
is_available: f.is_available,
|
||||||
is_legacy: f.is_legacy,
|
is_legacy: f.is_legacy,
|
||||||
shop_url: f.shop_url.clone(),
|
shop_url: f.shop_url.clone(),
|
||||||
rating_distribution: dist_by_flavor.remove(&f.id).unwrap_or_else(empty_distribution),
|
rating_distribution: dist_by_flavor
|
||||||
|
.remove(&f.id)
|
||||||
|
.unwrap_or_else(empty_distribution),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -432,7 +454,9 @@ async fn social_counts(
|
|||||||
|
|
||||||
/// Set of user ids that `viewer` follows, restricted to `ids`.
|
/// Set of user ids that `viewer` follows, restricted to `ids`.
|
||||||
async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> ApiResult<Vec<i32>> {
|
async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> ApiResult<Vec<i32>> {
|
||||||
let Some(v) = viewer else { return Ok(Vec::new()) };
|
let Some(v) = viewer else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -447,7 +471,9 @@ async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> Api
|
|||||||
),
|
),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
rows.into_iter().map(|r| r.try_get("", "uid").map_err(Into::into)).collect()
|
rows.into_iter()
|
||||||
|
.map(|r| r.try_get("", "uid").map_err(Into::into))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build `UserOut`s (mirrors `UserSerializer`), batching social counts and the
|
/// Build `UserOut`s (mirrors `UserSerializer`), batching social counts and the
|
||||||
@@ -464,8 +490,10 @@ pub async fn build_users(
|
|||||||
}
|
}
|
||||||
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
|
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
|
||||||
let (following, followers, unread) = social_counts(state, &ids).await?;
|
let (following, followers, unread) = social_counts(state, &ids).await?;
|
||||||
let followed: std::collections::HashSet<i32> =
|
let followed: std::collections::HashSet<i32> = following_of(state, viewer, &ids)
|
||||||
following_of(state, viewer, &ids).await?.into_iter().collect();
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
let banner_ids: Vec<i32> = users.iter().filter_map(|u| u.selected_banner_id).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() {
|
let banner_slugs: HashMap<i32, String> = if banner_ids.is_empty() {
|
||||||
@@ -636,7 +664,9 @@ pub async fn build_notifications(
|
|||||||
}
|
}
|
||||||
if let Some(rep_id) = n.reply_id {
|
if let Some(rep_id) = n.reply_id {
|
||||||
let rep = replies.get(&rep_id)?;
|
let rep = replies.get(&rep_id)?;
|
||||||
return ratings.get(&rep.rating_id).and_then(|r| flavors.get(&r.flavor_id));
|
return ratings
|
||||||
|
.get(&rep.rating_id)
|
||||||
|
.and_then(|r| flavors.get(&r.flavor_id));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -713,7 +743,10 @@ pub async fn build_tickets(
|
|||||||
let mut msgs_by_ticket: HashMap<i32, Vec<TicketMessageOut>> = HashMap::new();
|
let mut msgs_by_ticket: HashMap<i32, Vec<TicketMessageOut>> = HashMap::new();
|
||||||
for m in messages {
|
for m in messages {
|
||||||
let u = users.get(&m.user_id);
|
let u = users.get(&m.user_id);
|
||||||
msgs_by_ticket.entry(m.ticket_id).or_default().push(TicketMessageOut {
|
msgs_by_ticket
|
||||||
|
.entry(m.ticket_id)
|
||||||
|
.or_default()
|
||||||
|
.push(TicketMessageOut {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
username: u.map(|x| x.username.clone()).unwrap_or_default(),
|
username: u.map(|x| x.username.clone()).unwrap_or_default(),
|
||||||
text: m.text,
|
text: m.text,
|
||||||
|
|||||||
@@ -30,10 +30,16 @@ pub struct Rate {
|
|||||||
|
|
||||||
impl Rate {
|
impl Rate {
|
||||||
pub const fn per_minute(limit: usize) -> Self {
|
pub const fn per_minute(limit: usize) -> Self {
|
||||||
Rate { limit, window: Duration::from_secs(60) }
|
Rate {
|
||||||
|
limit,
|
||||||
|
window: Duration::from_secs(60),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub const fn per_hour(limit: usize) -> Self {
|
pub const fn per_hour(limit: usize) -> Self {
|
||||||
Rate { limit, window: Duration::from_secs(3600) }
|
Rate {
|
||||||
|
limit,
|
||||||
|
window: Duration::from_secs(3600),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +112,10 @@ impl Security {
|
|||||||
|
|
||||||
/// Note that a confirmation code was just minted for `key`.
|
/// Note that a confirmation code was just minted for `key`.
|
||||||
pub fn record_code(&self, key: &str) {
|
pub fn record_code(&self, key: &str) {
|
||||||
self.codes.lock().unwrap().insert(key.to_string(), Instant::now());
|
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`.
|
/// True only when we hold a mint time for `key` that is older than `CODE_TTL`.
|
||||||
|
|||||||
+9
-14
@@ -1,11 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
# --- Rust backend (strangler) --------------------------------------------
|
# --- Rust backend --------------------------------------------------------
|
||||||
# Runs beside Django, sharing the SAME db.sqlite3, media/, banners/ and
|
# The API. Owns db.sqlite3, media/, banners/ and legacy/ via bind mounts.
|
||||||
# legacy/ via bind mounts and the SAME SECRET_KEY (so SimpleJWT tokens verify
|
# An in-process scheduler runs the jobs that Celery beat used to (gated by
|
||||||
# across both). nginx routes endpoint groups here one at a time; see
|
# ENABLE_SCHEDULER — exactly one process must own api_job). SECRET_KEY signs
|
||||||
# backend-rs/README.md for the cutover order. ENABLE_SCHEDULER stays false
|
# the SimpleJWT-compatible tokens.
|
||||||
# until Django's beat container is stopped — only one scheduler may own
|
|
||||||
# api_job, or jobs run twice.
|
|
||||||
backend-rs:
|
backend-rs:
|
||||||
build:
|
build:
|
||||||
context: ./backend-rs
|
context: ./backend-rs
|
||||||
@@ -30,8 +28,8 @@ services:
|
|||||||
- BACKUP_DIR=/srv/backend/backups
|
- BACKUP_DIR=/srv/backend/backups
|
||||||
- FRONTEND_URL=${FRONTEND_URL:-http://${DOMAIN:-localhost}}
|
- FRONTEND_URL=${FRONTEND_URL:-http://${DOMAIN:-localhost}}
|
||||||
- JWT_AUTH_COOKIE_SECURE=${JWT_AUTH_COOKIE_SECURE:-true}
|
- JWT_AUTH_COOKIE_SECURE=${JWT_AUTH_COOKIE_SECURE:-true}
|
||||||
# Keep false until Django beat is stopped (Phase 3 of cutover).
|
# Rust owns api_job scheduling (Celery beat retired).
|
||||||
- ENABLE_SCHEDULER=${RUST_ENABLE_SCHEDULER:-false}
|
- ENABLE_SCHEDULER=${RUST_ENABLE_SCHEDULER:-true}
|
||||||
- RUST_LOG=${RUST_LOG:-info,holy_backend_rs=info,sea_orm=warn}
|
- RUST_LOG=${RUST_LOG:-info,holy_backend_rs=info,sea_orm=warn}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/srv/backend
|
- ./backend:/srv/backend
|
||||||
@@ -62,8 +60,8 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
# Serve /media/ straight from the shared mount (read-only), decoupling it
|
# Serve /media/ straight from the shared mount (read-only) so media is
|
||||||
# from either backend so Django can be retired without losing media.
|
# independent of the backend being up.
|
||||||
- ./backend/media:/usr/share/nginx/media:ro
|
- ./backend/media:/usr/share/nginx/media:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-rs:
|
backend-rs:
|
||||||
@@ -72,6 +70,3 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
holy-net:
|
holy-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
+2
-2
@@ -31,8 +31,8 @@ COPY --from=build-stage /app/dist /usr/share/nginx/html
|
|||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Nginx config: SPA + strangler API routing (Django/Rust per-group). See
|
# Nginx config: serves the SPA and proxies /api to the Rust backend.
|
||||||
# nginx.conf for the cutover layout. proxy_params.conf holds shared headers.
|
# proxy_params.conf holds shared proxy headers.
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY proxy_params.conf /etc/nginx/proxy_params.conf
|
COPY proxy_params.conf /etc/nginx/proxy_params.conf
|
||||||
|
|
||||||
|
|||||||
+10
-23
@@ -1,17 +1,9 @@
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Strangler routing: Django (holy-backend) and Rust (holy-rust) run side by
|
# Frontend nginx: serves the SPA, proxies /api to the Rust backend
|
||||||
# side, sharing one SQLite DB + media dir + SECRET_KEY. Every endpoint group
|
# (holy-rust:8001), and serves /media straight from the shared bind mount.
|
||||||
# below can be served by EITHER backend, so cutover is per-group and instant:
|
# The per-group /api locations are kept explicit (one proxy_pass each) for
|
||||||
# change a `proxy_pass` target, then `docker exec holy-frontend nginx -s reload`
|
# readability; all point at rust_backend. The catch-all /api/ covers anything
|
||||||
# (or recreate the container). Rollback is the same edit in reverse.
|
# not listed.
|
||||||
#
|
|
||||||
# Cutover order (see backend-rs/README.md):
|
|
||||||
# Phase 1 reads -> categories, flavors, banners
|
|
||||||
# Phase 2 auth+write -> auth/token, ratings, replies, users, notifications, tickets
|
|
||||||
# Phase 3 admin+jobs -> admin-custom (THEN stop Django beat + set
|
|
||||||
# RUST_ENABLE_SCHEDULER=true so exactly one scheduler runs)
|
|
||||||
# Phase 4 retire -> point catch-all /api/ at rust; serve /media/ from
|
|
||||||
# nginx; drop Django /admin/ + /static/ if unused.
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
upstream rust_backend {
|
upstream rust_backend {
|
||||||
@@ -28,17 +20,13 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- API: per-group strangler routing --------------------------------
|
# --- API -> Rust backend ---------------------------------------------
|
||||||
# Longest-prefix match wins, so these specific groups override the
|
# Longest-prefix match wins; these explicit groups and the catch-all
|
||||||
# catch-all /api/ below. To cut a group over, swap django_backend ->
|
# /api/ all resolve to rust_backend.
|
||||||
# rust_backend on its proxy_pass line.
|
|
||||||
|
|
||||||
# Phase 1 — read-only (safe to move first):
|
|
||||||
location /api/categories/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
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/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/banners/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||||
|
|
||||||
# Phase 2 — auth + writes:
|
|
||||||
location /api/auth/ { 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/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/ratings/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||||
@@ -47,13 +35,12 @@ server {
|
|||||||
location /api/notifications/ { 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/tickets/ { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||||
|
|
||||||
# Phase 3 — admin + jobs (cut over, THEN flip the scheduler ownership):
|
|
||||||
location /api/admin-custom/ { proxy_pass http://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 (both backends serve these):
|
# OpenAPI schema / Swagger UI:
|
||||||
location /api/schema { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
location /api/schema { proxy_pass http://rust_backend; include /etc/nginx/proxy_params.conf; }
|
||||||
|
|
||||||
# Catch-all: anything not explicitly cut over stays on Django.
|
# Catch-all for any /api path not listed above.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://rust_backend;
|
proxy_pass http://rust_backend;
|
||||||
include /etc/nginx/proxy_params.conf;
|
include /etc/nginx/proxy_params.conf;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"preview": "vite preview",
|
"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:gen": "openapi-typescript ../backend/schema.yaml -o src/api/schema.d.ts",
|
||||||
"openapi:sync": "npm run schema:export && npm run openapi:gen"
|
"openapi:sync": "npm run schema:export && npm run openapi:gen"
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-8
@@ -1,7 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# This script creates a full snapshot of the database and user media.
|
# Full snapshot of the SQLite database + user media. Host-side and safe to run
|
||||||
# Best run as a daily cron job.
|
# 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)
|
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
@@ -10,6 +14,7 @@ TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|||||||
BACKUP_NAME="full_snapshot_$TIMESTAMP"
|
BACKUP_NAME="full_snapshot_$TIMESTAMP"
|
||||||
TEMP_DIR="backend/backups/$BACKUP_NAME"
|
TEMP_DIR="backend/backups/$BACKUP_NAME"
|
||||||
FINAL_DEST="backend/backups"
|
FINAL_DEST="backend/backups"
|
||||||
|
DB_SRC="backend/db.sqlite3"
|
||||||
|
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Starting Full Project Backup... "
|
echo " Starting Full Project Backup... "
|
||||||
@@ -17,19 +22,29 @@ echo "========================================"
|
|||||||
|
|
||||||
mkdir -p "$TEMP_DIR"
|
mkdir -p "$TEMP_DIR"
|
||||||
|
|
||||||
# 1. Run the database backup command
|
# 1. Database — consistent snapshot.
|
||||||
echo "Backing up database..."
|
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..."
|
echo "Backing up media..."
|
||||||
cp -r backend/media "$TEMP_DIR/media"
|
cp -r backend/media "$TEMP_DIR/media"
|
||||||
|
|
||||||
# 3. Create compressed archive
|
# 3. Compress.
|
||||||
echo "Compressing..."
|
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"
|
rm -rf "$TEMP_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# =========================================================================
|
|
||||||
# Strangler cutover driver: Django -> Rust, zero downtime, reversible.
|
|
||||||
#
|
|
||||||
# Flips nginx `proxy_pass` targets one phase-group at a time and reloads
|
|
||||||
# nginx, then (Phase 3) hands job scheduling from Django's Celery beat to
|
|
||||||
# the Rust in-process scheduler. Every phase has an inverse `rollback`.
|
|
||||||
#
|
|
||||||
# It edits frontend/nginx.conf in place. To make reloads INSTANT (no image
|
|
||||||
# rebuild), run `cutover.sh dev-mount` once first: it bind-mounts the conf
|
|
||||||
# into holy-frontend so `nginx -s reload` picks up edits live. Without that,
|
|
||||||
# each flip rebuilds the frontend image (slower, but still no API downtime).
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/cutover.sh status # show current routing + scheduler
|
|
||||||
# scripts/cutover.sh phase0 # build + start holy-rust (shadow)
|
|
||||||
# scripts/cutover.sh phase1 # flip reads
|
|
||||||
# scripts/cutover.sh phase2 # flip auth + writes
|
|
||||||
# scripts/cutover.sh phase3 # flip admin, hand off scheduler
|
|
||||||
# scripts/cutover.sh phase4 # flip catch-all (retire Django path)
|
|
||||||
# scripts/cutover.sh rollback {1|2|3|4} # undo a phase
|
|
||||||
# scripts/cutover.sh smoke # hit holy-rust /health/ directly
|
|
||||||
#
|
|
||||||
# Invariant: exactly ONE scheduler owns api_job. Django beat owns it until
|
|
||||||
# phase3; Rust's ENABLE_SCHEDULER stays false until then. Never both.
|
|
||||||
# =========================================================================
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
CONF="$REPO/frontend/nginx.conf"
|
|
||||||
ENVFILE="$REPO/.env"
|
|
||||||
DC="docker compose"
|
|
||||||
|
|
||||||
# Endpoint groups per phase (nginx location prefixes, sans trailing proxy line).
|
|
||||||
READS=(/api/categories/ /api/flavors/ /api/banners/)
|
|
||||||
WRITES=(/api/auth/ "= /api/token/" /api/ratings/ /api/replies/ /api/users/ /api/notifications/ /api/tickets/)
|
|
||||||
ADMIN=(/api/admin-custom/)
|
|
||||||
CATCHALL=(/api/schema /api/)
|
|
||||||
|
|
||||||
c_red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
|
||||||
c_grn() { printf '\033[32m%s\033[0m\n' "$*"; }
|
|
||||||
c_ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
|
||||||
die() { c_red "ERROR: $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
[ -f "$CONF" ] || die "nginx.conf not found at $CONF"
|
|
||||||
|
|
||||||
# Flip the upstream on the location line(s) for a given prefix.
|
|
||||||
# $1 = location token (e.g. /api/flavors/ or '= /api/token/')
|
|
||||||
# $2 = target upstream (rust_backend | django_backend)
|
|
||||||
flip_one() {
|
|
||||||
local loc="$1" target="$2"
|
|
||||||
# Match: location <loc> { proxy_pass http://<anything>; ... }
|
|
||||||
# Anchor on the exact location token to avoid hitting the catch-all /api/.
|
|
||||||
local esc; esc="$(printf '%s' "$loc" | sed 's/[.[\*^$/]/\\&/g')"
|
|
||||||
# The catch-all "/api/" lives on its own multi-line block; handle separately.
|
|
||||||
if [ "$loc" = "/api/" ]; then
|
|
||||||
# Flip only the catch-all block's proxy_pass (the one not on a one-liner).
|
|
||||||
perl -0pi -e "s{(location /api/ \{\s*\n\s*proxy_pass http://)\w+;}{\${1}${target};}g" "$CONF"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
sed -i -E "s|(location ${esc}[[:space:]]+\{[[:space:]]+proxy_pass http://)[a-z_]+;|\1${target};|" "$CONF"
|
|
||||||
}
|
|
||||||
|
|
||||||
flip_group() {
|
|
||||||
local target="$1"; shift
|
|
||||||
local g
|
|
||||||
for g in "$@"; do flip_one "$g" "$target"; done
|
|
||||||
}
|
|
||||||
|
|
||||||
reload_nginx() {
|
|
||||||
if $DC ps frontend --format '{{.Names}}' 2>/dev/null | grep -q holy-frontend \
|
|
||||||
&& docker inspect holy-frontend --format '{{range .Mounts}}{{.Destination}}{{"\n"}}{{end}}' 2>/dev/null \
|
|
||||||
| grep -q '/etc/nginx/conf.d/default.conf'; then
|
|
||||||
c_ylw "conf is bind-mounted -> live reload"
|
|
||||||
docker exec holy-frontend nginx -t && docker exec holy-frontend nginx -s reload
|
|
||||||
else
|
|
||||||
c_ylw "conf baked into image -> rebuilding frontend (no API downtime)"
|
|
||||||
$DC up -d --build frontend
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
set_env() { # set_env KEY value (idempotent upsert in .env)
|
|
||||||
local k="$1" v="$2"
|
|
||||||
[ -f "$ENVFILE" ] || touch "$ENVFILE"
|
|
||||||
if grep -qE "^${k}=" "$ENVFILE"; then
|
|
||||||
sed -i -E "s|^${k}=.*|${k}=${v}|" "$ENVFILE"
|
|
||||||
else
|
|
||||||
printf '%s=%s\n' "$k" "$v" >> "$ENVFILE"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm() {
|
|
||||||
read -r -p "$1 [y/N] " a; [ "$a" = y ] || [ "$a" = Y ] || die "aborted"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_status() {
|
|
||||||
c_grn "== nginx routing (frontend/nginx.conf) =="
|
|
||||||
grep -E 'location (= )?/api/' "$CONF" | sed -E 's/\s*include.*//' \
|
|
||||||
| sed -E 's/proxy_pass http:\/\/(rust_backend)/proxy_pass -> \x1b[32m\1\x1b[0m/; s/proxy_pass http:\/\/(django_backend)/proxy_pass -> \1/'
|
|
||||||
echo
|
|
||||||
c_grn "== scheduler ownership =="
|
|
||||||
local rust_sched; rust_sched="$(grep -E '^RUST_ENABLE_SCHEDULER=' "$ENVFILE" 2>/dev/null | cut -d= -f2 || true)"
|
|
||||||
echo "RUST_ENABLE_SCHEDULER=${rust_sched:-unset (=false)}"
|
|
||||||
if $DC ps beat --format '{{.Names}} {{.State}}' 2>/dev/null | grep -q running; then
|
|
||||||
echo "Django beat: RUNNING (Django owns api_job)"
|
|
||||||
else
|
|
||||||
echo "Django beat: stopped"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_smoke() {
|
|
||||||
c_grn "Hitting holy-rust /health/ directly (bypasses nginx)..."
|
|
||||||
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/ \
|
|
||||||
|| die "holy-rust not reachable — run phase0 first?"
|
|
||||||
echo; c_grn "OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_phase0() {
|
|
||||||
grep -qE '^SECRET_KEY=.+' "$ENVFILE" 2>/dev/null \
|
|
||||||
|| die "SECRET_KEY must be set in .env to the SAME value Django uses (JWTs break otherwise)"
|
|
||||||
c_grn "Phase 0: building + starting holy-rust in shadow (no traffic)..."
|
|
||||||
$DC up -d --build backend-rs
|
|
||||||
c_ylw "Wait for healthy, then: scripts/cutover.sh smoke"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_phase1() { c_grn "Phase 1: flip READS -> rust"; flip_group rust_backend "${READS[@]}"; reload_nginx; cmd_status; }
|
|
||||||
cmd_phase2() { c_grn "Phase 2: flip AUTH+WRITES -> rust"; flip_group rust_backend "${WRITES[@]}"; reload_nginx; cmd_status; }
|
|
||||||
|
|
||||||
cmd_phase3() {
|
|
||||||
c_grn "Phase 3: flip ADMIN -> rust, then hand off scheduler"
|
|
||||||
flip_group rust_backend "${ADMIN[@]}"; reload_nginx
|
|
||||||
c_ylw "About to STOP Django beat/worker/flower and ENABLE Rust scheduler."
|
|
||||||
c_ylw "This transfers api_job ownership. Ensure no job is mid-run."
|
|
||||||
confirm "Proceed with scheduler handover?"
|
|
||||||
$DC stop beat worker flower
|
|
||||||
set_env RUST_ENABLE_SCHEDULER true
|
|
||||||
$DC up -d backend-rs
|
|
||||||
c_grn "Done. Check: docker logs holy-rust | grep 'job scheduler ENABLED'"
|
|
||||||
cmd_status
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_phase4() {
|
|
||||||
c_grn "Phase 4: flip CATCH-ALL /api/ + /api/schema -> rust (Django serves only /admin/ + /static/)"
|
|
||||||
flip_group rust_backend "${CATCHALL[@]}"; reload_nginx
|
|
||||||
c_ylw "Django now handles only its admin site. To drop it entirely:"
|
|
||||||
c_ylw " $DC stop backend ; then remove /admin/ + /static/ locations from nginx.conf"
|
|
||||||
c_ylw " $DC stop redis # only a Celery broker + Django cache; Rust uses neither"
|
|
||||||
cmd_status
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_rollback() {
|
|
||||||
local p="${1:-}"
|
|
||||||
case "$p" in
|
|
||||||
1) c_grn "Rollback Phase 1"; flip_group django_backend "${READS[@]}"; reload_nginx ;;
|
|
||||||
2) c_grn "Rollback Phase 2"; flip_group django_backend "${WRITES[@]}"; reload_nginx ;;
|
|
||||||
3) c_grn "Rollback Phase 3: scheduler back to Django, admin back to Django"
|
|
||||||
set_env RUST_ENABLE_SCHEDULER false
|
|
||||||
$DC up -d backend-rs
|
|
||||||
$DC start beat worker flower
|
|
||||||
flip_group django_backend "${ADMIN[@]}"; reload_nginx ;;
|
|
||||||
4) c_grn "Rollback Phase 4"; flip_group django_backend "${CATCHALL[@]}"; reload_nginx ;;
|
|
||||||
*) die "usage: cutover.sh rollback {1|2|3|4}" ;;
|
|
||||||
esac
|
|
||||||
cmd_status
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_dev_mount() {
|
|
||||||
c_ylw "Add this under the 'frontend' service volumes in docker-compose.yml for live reloads:"
|
|
||||||
echo ' - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf'
|
|
||||||
echo ' - ./frontend/proxy_params.conf:/etc/nginx/proxy_params.conf'
|
|
||||||
c_ylw "Then: $DC up -d frontend"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
status) cmd_status ;;
|
|
||||||
smoke) cmd_smoke ;;
|
|
||||||
phase0) cmd_phase0 ;;
|
|
||||||
phase1) cmd_phase1 ;;
|
|
||||||
phase2) cmd_phase2 ;;
|
|
||||||
phase3) cmd_phase3 ;;
|
|
||||||
phase4) cmd_phase4 ;;
|
|
||||||
rollback) cmd_rollback "${2:-}" ;;
|
|
||||||
dev-mount) cmd_dev_mount ;;
|
|
||||||
*) cat <<EOF
|
|
||||||
Strangler cutover driver. Phases are reversible; data never forks (shared DB).
|
|
||||||
|
|
||||||
status show routing + scheduler ownership
|
|
||||||
smoke curl holy-rust /health/ directly
|
|
||||||
phase0 build + start holy-rust (shadow, no traffic)
|
|
||||||
phase1 flip reads (categories, flavors, banners)
|
|
||||||
phase2 flip auth+writes (auth, token, ratings, replies, users, notifications, tickets)
|
|
||||||
phase3 flip admin + hand scheduler Django->Rust (CAREFUL)
|
|
||||||
phase4 flip catch-all /api/ (Django -> only /admin/ + /static/)
|
|
||||||
rollback {1|2|3|4} undo a phase
|
|
||||||
dev-mount print compose lines for live nginx reloads
|
|
||||||
|
|
||||||
See MIGRATION.md for the full runbook.
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
+7
-24
@@ -3,11 +3,6 @@
|
|||||||
# Get the root directory of the project
|
# Get the root directory of the project
|
||||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
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 "========================================"
|
||||||
echo " Running Pre-Push Integrity Checks "
|
echo " Running Pre-Push Integrity Checks "
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
@@ -23,32 +18,20 @@ if ! npm run build; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Backend Check
|
# 2. Rust backend checks
|
||||||
echo ""
|
echo ""
|
||||||
echo "Step 2: Checking Backend Integrity..."
|
echo "Step 2: Checking Rust backend..."
|
||||||
cd "$ROOT_DIR/backend"
|
cd "$ROOT_DIR/backend-rs"
|
||||||
|
|
||||||
# Detect python executable (prefer venv if exists)
|
if ! cargo fmt --check; then
|
||||||
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
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "❌ ERROR: Missing migrations detected."
|
echo "❌ ERROR: Rust formatting issues. Run 'cargo fmt' in backend-rs/."
|
||||||
echo " Please run 'python manage.py makemigrations' locally."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run Django system checks
|
if ! cargo check --locked; then
|
||||||
if ! $PYTHON_EXEC manage.py check; then
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "❌ ERROR: Django system check failed."
|
echo "❌ ERROR: Rust backend does not compile."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user