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
+691
-902
No files matched your search
+14
-14
@@ -1,44 +1,44 @@
|
||||
# Path to the SAME sqlite file Django uses (strangler: both backends share one DB).
|
||||
# Relative paths resolve from the process CWD; absolute is safest.
|
||||
# Path to the sqlite database. Relative paths resolve from the process CWD;
|
||||
# absolute is safest. Use mode=rw in prod so a wrong path fails loudly.
|
||||
DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc
|
||||
|
||||
# MUST equal Django's SECRET_KEY so SimpleJWT tokens verify across both backends.
|
||||
SECRET_KEY=change-me-to-match-django
|
||||
# Signs the SimpleJWT-compatible tokens. Keep this CONSTANT across deploys and
|
||||
# restarts — changing it invalidates every issued access/refresh token. (Use the
|
||||
# value the retired Django app used so tokens already in the wild stay valid.)
|
||||
SECRET_KEY=change-me
|
||||
|
||||
# Where this service binds.
|
||||
BIND_ADDR=0.0.0.0:8001
|
||||
|
||||
# Media URL prefix Django serves under (matches MEDIA_URL = "media/").
|
||||
# Media URL prefix (matches the historical Django MEDIA_URL = "media/").
|
||||
MEDIA_URL=media/
|
||||
|
||||
# Filesystem root for uploaded media (avatars). Must point at the SAME directory
|
||||
# Django uses (MEDIA_ROOT) so both backends read/write the same files.
|
||||
# Filesystem root for uploaded media (avatars).
|
||||
MEDIA_ROOT=../backend/media
|
||||
|
||||
# Base URL of the frontend, used to build email links (verify-email, reset).
|
||||
# Matches Django's FRONTEND_URL.
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# --- Email (lettre) -------------------------------------------------------
|
||||
# Leave EMAIL_HOST unset to log emails to the console (dev). Set it to enable
|
||||
# real SMTP. These mirror Django's EMAIL_* settings.
|
||||
# real SMTP.
|
||||
#EMAIL_HOST=smtp.example.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=true
|
||||
EMAIL_USE_SSL=false
|
||||
#EMAIL_HOST_USER=noreply@example.com
|
||||
#EMAIL_HOST_PASSWORD=change-me
|
||||
# Accept self-signed SMTP certs (mirrors api.email_backend.InsecureEmailBackend).
|
||||
# Accept self-signed SMTP certs.
|
||||
EMAIL_SKIP_CERT_VERIFICATION=false
|
||||
# From address; falls back to EMAIL_HOST_USER then noreply@localhost.
|
||||
#DEFAULT_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# Set `Secure` on auth cookies (true in prod, like Django's JWT_AUTH_COOKIE_SECURE).
|
||||
# Set `Secure` on auth cookies (true in prod).
|
||||
JWT_AUTH_COOKIE_SECURE=false
|
||||
|
||||
# In-process job scheduler (replaces Celery beat). MUST stay false while
|
||||
# Django's beat container is still running — otherwise scheduled jobs fire twice
|
||||
# against the shared api_job table. Set true ONLY after stopping Django beat.
|
||||
# In-process job scheduler (replaces Celery beat). Exactly one running instance
|
||||
# may set this true — it owns scheduling of the api_job table. Additional
|
||||
# instances must leave it false or jobs fire twice.
|
||||
ENABLE_SCHEDULER=false
|
||||
|
||||
# tracing filter
|
||||
|
||||
+21
-83
@@ -185,94 +185,35 @@ due `sync_flavors` job which fetched `weareholy.com` and reported
|
||||
## Run
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
`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`
|
||||
image; shared proxy headers live in `frontend/proxy_params.conf`). Two upstreams
|
||||
are declared — `django_backend` (`holy-backend:8000`) and `rust_backend`
|
||||
(`holy-rust:8001`) — and each `/api/<group>/` location has its own `proxy_pass`.
|
||||
Cutting a group over is a one-line edit (`django_backend` → `rust_backend`)
|
||||
followed by a reload; rollback is the reverse edit. Because both backends share
|
||||
one SQLite DB, one media dir, and the same `SECRET_KEY`, either can serve any
|
||||
request and tokens stay valid across a flip.
|
||||
The Rust backend is the production API; Django/Celery/Redis were retired after
|
||||
the strangler migration completed. `docker compose` (`docker-compose.yml` +
|
||||
`docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`)
|
||||
and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend,
|
||||
and serves `/media` from the shared `./backend/media` bind mount; see
|
||||
`frontend/nginx.conf`.
|
||||
|
||||
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
||||
`next`/`previous`) are rebuilt from them, matching DRF's `build_absolute_uri`.
|
||||
`X-Forwarded-For` lets `/api/users/me/` log the real client IP (it falls back to
|
||||
the peer address otherwise, 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
|
||||
per-step rollback. Everything hinges on the shared substrate already in place —
|
||||
same `db.sqlite3` (WAL on), same media dir, same `SECRET_KEY` — so a request can
|
||||
be served by either backend interchangeably.
|
||||
|
||||
**Invariant (do not violate):** exactly **one** scheduler may own the `api_job`
|
||||
table at any time. Django's `beat` owns it until Phase 3; Rust's in-process
|
||||
scheduler (`ENABLE_SCHEDULER`) is **off** until then. Never both.
|
||||
|
||||
### Phase 0 — Deploy Rust in shadow (no traffic)
|
||||
1. Set `SECRET_KEY` in `.env` to the **exact** value Django already uses (else
|
||||
existing JWTs break on cutover). Optionally set `FLOWER_BASIC_AUTH`.
|
||||
2. `docker compose up -d --build backend-rs` — brings up `holy-rust` beside
|
||||
Django. `ENABLE_SCHEDULER` defaults to false; it only reads the DB.
|
||||
3. Smoke-test directly: `docker exec holy-frontend wget -qO- http://holy-rust:8001/health/`
|
||||
and spot-check a few endpoints against it. nginx still sends all prod traffic
|
||||
to Django. **Rollback:** `docker compose stop backend-rs`.
|
||||
|
||||
### Phase 1 — Reads
|
||||
4. In `frontend/nginx.conf`, flip `categories`, `flavors`, `banners` to
|
||||
`rust_backend`. Rebuild/reload the frontend (`docker compose up -d --build
|
||||
frontend`, or `docker exec holy-frontend nginx -s reload` if you bind-mount the
|
||||
conf). Watch `docker logs holy-rust` + error rates. **Rollback:** flip back, reload.
|
||||
|
||||
### Phase 2 — Auth + writes
|
||||
5. Flip `auth`, `token` (legacy alias), `ratings`, `replies`, `users`,
|
||||
`notifications`, `tickets`. JWTs and cookies are cross-valid, so in-flight
|
||||
sessions survive. Reload. **Rollback:** flip back, reload.
|
||||
|
||||
### Phase 3 — Admin + jobs (the careful one)
|
||||
6. Flip `admin-custom` to `rust_backend`, reload.
|
||||
7. **Stop Django's scheduler/workers:** `docker compose stop beat worker flower`.
|
||||
Now nobody schedules jobs.
|
||||
8. Hand scheduling to Rust: set `RUST_ENABLE_SCHEDULER=true` in `.env`, then
|
||||
`docker compose up -d backend-rs`. Confirm the log line
|
||||
`job scheduler ENABLED`. Single owner throughout → no double-run.
|
||||
**Rollback:** set `RUST_ENABLE_SCHEDULER=false` + recreate `backend-rs`, then
|
||||
`docker compose start beat worker`.
|
||||
|
||||
### Phase 4 — Retire Django
|
||||
9. Point the catch-all `location /api/` at `rust_backend` (covers `/api/schema`
|
||||
too — Rust serves OpenAPI + Swagger). Reload. Django now receives only
|
||||
`/admin/` + `/static/` (its admin site). `/media/` is already served straight
|
||||
from nginx (the `:ro` mount), independent of either backend.
|
||||
10. Decide the Django admin site's fate. The app's own admin UI uses
|
||||
`/api/admin-custom/`, so the Django `/admin/` site is usually unneeded:
|
||||
- Keeping it: leave the `backend` container up for `/admin/` + `/static/`.
|
||||
- Dropping it: `docker compose stop backend`, delete the `/admin/` + `/static/`
|
||||
locations from nginx.
|
||||
11. Once Django is fully off, `redis` (only a Celery broker + Django cache — Rust
|
||||
uses neither) can be stopped too: `docker compose stop redis`. Eventually
|
||||
remove `backend`, `worker`, `beat`, `flower`, `redis` from compose.
|
||||
|
||||
### Notes / caveats
|
||||
- **Rate-limit counters reset at cutover** — Django's `django_ratelimit` lives in
|
||||
Redis; Rust's throttle is in-process. Acceptable (windows are minutes/hours).
|
||||
- **Token blacklist is shared** (both use `token_blacklist_*` tables), so logout
|
||||
/ refresh-rotation stays consistent across a flip.
|
||||
- Reloads: the frontend image bakes the conf, so a conf change needs
|
||||
`docker compose up -d --build frontend`. For instant reloads during cutover,
|
||||
bind-mount `./frontend/nginx.conf:/etc/nginx/conf.d/default.conf` temporarily and
|
||||
use `nginx -s reload`.
|
||||
- Keep `backend/db.sqlite3` + `backend/media/` (host bind mounts) as the canonical
|
||||
data across all of this — neither is recreated by rebuilds.
|
||||
The canonical data lives in host bind mounts — `backend/db.sqlite3` (WAL on) and
|
||||
`backend/media/` — and survives image rebuilds. Keep them.
|
||||
|
||||
## Caveats / parity TODO
|
||||
|
||||
@@ -291,9 +232,7 @@ scheduler (`ENABLE_SCHEDULER`) is **off** until then. Never both.
|
||||
never used them.
|
||||
- Admin endpoints + background jobs are ported (see the Admin + jobs slice).
|
||||
The job-trigger and admin write endpoints are **not** rate-limited (those are
|
||||
staff-only; `django_ratelimit` only covered the public flows below). When Rust
|
||||
owns jobs in prod, disable the Celery `beat`/`worker` containers to avoid
|
||||
double-execution.
|
||||
staff-only; `django_ratelimit` only covered the public flows below).
|
||||
|
||||
## Security & hardening
|
||||
|
||||
@@ -338,9 +277,8 @@ in-flight requests drain. `/health/` now runs `SELECT 1` so the Docker
|
||||
healthcheck fails when SQLite is unreachable.
|
||||
|
||||
SQLite runs in **WAL** journal mode (enabled once at startup, persistent in the
|
||||
file header, so it covers the Django side too) so readers don't block behind the
|
||||
writer under the shared-DB strangler; `busy_timeout` is sqlx's default 5 s,
|
||||
matching Django's SQLite timeout. The default `DATABASE_URL` uses `mode=rw` (not
|
||||
file header) so readers don't block behind the writer; `busy_timeout` is sqlx's
|
||||
default 5 s, matching Django's SQLite timeout. The default `DATABASE_URL` uses `mode=rw` (not
|
||||
`rwc`) so a wrong path fails loudly instead of silently creating an empty DB.
|
||||
|
||||
Confirmation codes are compared in **constant time** (`subtle::ConstantTimeEq`)
|
||||
|
||||
@@ -104,11 +104,16 @@ impl Config {
|
||||
},
|
||||
email: EmailConfig {
|
||||
host: env::var("EMAIL_HOST").ok().filter(|s| !s.is_empty()),
|
||||
port: env::var("EMAIL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(587),
|
||||
port: env::var("EMAIL_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(587),
|
||||
use_tls: env_bool("EMAIL_USE_TLS", false),
|
||||
use_ssl: env_bool("EMAIL_USE_SSL", false),
|
||||
host_user: env::var("EMAIL_HOST_USER").ok().filter(|s| !s.is_empty()),
|
||||
host_password: env::var("EMAIL_HOST_PASSWORD").ok().filter(|s| !s.is_empty()),
|
||||
host_password: env::var("EMAIL_HOST_PASSWORD")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
skip_cert_verification: env_bool("EMAIL_SKIP_CERT_VERIFICATION", false),
|
||||
default_from: env::var("DEFAULT_FROM_EMAIL")
|
||||
.ok()
|
||||
@@ -120,9 +125,9 @@ impl Config {
|
||||
.unwrap_or_else(|_| "../backend/banners".to_string()),
|
||||
legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".to_string()),
|
||||
backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../backend/backups".to_string()),
|
||||
// Default OFF: a fresh deploy runs beside Django, which still owns the
|
||||
// scheduler. Operator flips ENABLE_SCHEDULER=true only after stopping
|
||||
// Django beat (see README cutover steps).
|
||||
// Default OFF so ad-hoc `cargo run` instances don't run jobs.
|
||||
// Production (docker compose) sets ENABLE_SCHEDULER=true on the one
|
||||
// instance that should own `api_job` scheduling.
|
||||
enable_scheduler: env_bool("ENABLE_SCHEDULER", false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use chrono::{NaiveDateTime, Timelike, Utc};
|
||||
/// so truncate before inserting to keep the on-disk format identical.
|
||||
pub fn now_micros() -> NaiveDateTime {
|
||||
let now = Utc::now().naive_utc();
|
||||
now.with_nanosecond(now.nanosecond() / 1000 * 1000).unwrap_or(now)
|
||||
now.with_nanosecond(now.nanosecond() / 1000 * 1000)
|
||||
.unwrap_or(now)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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);
|
||||
}
|
||||
let message = builder
|
||||
.body(body.to_string())
|
||||
.map_err(|e| format!("build email: {e}"))?;
|
||||
|
||||
let mut transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(cfg.port);
|
||||
let mut transport =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(cfg.port);
|
||||
|
||||
if cfg.use_ssl || cfg.use_tls {
|
||||
let tls = TlsParameters::builder(host.to_string())
|
||||
|
||||
+10
-5
@@ -56,7 +56,8 @@ impl IntoResponse for ApiError {
|
||||
)
|
||||
.into_response();
|
||||
if let Ok(hv) = axum::http::HeaderValue::from_str(&retry_after.to_string()) {
|
||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, hv);
|
||||
resp.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, hv);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
@@ -73,11 +74,15 @@ impl IntoResponse for ApiError {
|
||||
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||
ApiError::Db(e) => {
|
||||
tracing::error!(error = %e, "database error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Server error.".to_string())
|
||||
}
|
||||
ApiError::Internal => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Server error.".to_string())
|
||||
(
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ impl BackgroundJob for BackupDb {
|
||||
})
|
||||
.await??;
|
||||
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);
|
||||
|
||||
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();
|
||||
if let Some(mtime) = modified {
|
||||
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) {
|
||||
Ok(_) => log.push(format!(" -> Deleted old backup: {name}")),
|
||||
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
|
||||
/// sequence of whitespace-separated tokens; the match requires a preceding
|
||||
/// token (Django's leading `\s+`), so a name that is *only* the phrase is kept.
|
||||
const SUFFIXES: &[&[&str]] = &[
|
||||
&["energy"],
|
||||
&["hydration"],
|
||||
&["iced", "tea"],
|
||||
&["(legacy)"],
|
||||
];
|
||||
const SUFFIXES: &[&[&str]] = &[&["energy"], &["hydration"], &["iced", "tea"], &["(legacy)"]];
|
||||
|
||||
/// Lowercase, trim, and strip the known trailing suffixes (Django's
|
||||
/// `normalize_name`). The result is a grouping key, not a display value.
|
||||
|
||||
@@ -26,8 +26,8 @@ use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
use crate::entities::prelude::Job as JobEntity;
|
||||
use crate::entities::job;
|
||||
use crate::entities::prelude::Job as JobEntity;
|
||||
use crate::state::AppState;
|
||||
|
||||
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
|
||||
/// rows are reused, not duplicated.
|
||||
pub fn slugify(value: &str) -> String {
|
||||
let ascii: String = value.nfkd().filter(|c| c.is_ascii()).collect::<String>().to_lowercase();
|
||||
let ascii: String = value
|
||||
.nfkd()
|
||||
.filter(|c| c.is_ascii())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
// Keep word chars, hyphen and whitespace; drop the rest.
|
||||
let kept: String = ascii
|
||||
.chars()
|
||||
|
||||
@@ -11,8 +11,8 @@ use std::time::Duration;
|
||||
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
use crate::entities::prelude::Job as JobEntity;
|
||||
use crate::entities::job;
|
||||
use crate::entities::prelude::Job as JobEntity;
|
||||
use crate::state::AppState;
|
||||
|
||||
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()) {
|
||||
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();
|
||||
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") {
|
||||
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 {
|
||||
log.push(format!("Failed to sync banner from {filename}: {e}"));
|
||||
}
|
||||
@@ -63,7 +67,11 @@ async fn sync_one(
|
||||
path: &std::path::Path,
|
||||
log: &mut Vec<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data: Value = serde_json::from_str(&content)?;
|
||||
|
||||
@@ -78,9 +86,16 @@ async fn sync_one(
|
||||
|
||||
match existing {
|
||||
None => {
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(slug).to_string();
|
||||
let description =
|
||||
data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let name = data
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(slug)
|
||||
.to_string();
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let settings = data.get("settings").cloned().unwrap_or_else(|| json!({}));
|
||||
// Mirror Banner.save(): become active when no other banner is active.
|
||||
let no_active = active_count(db, None).await? == 0;
|
||||
@@ -130,7 +145,10 @@ async fn sync_one(
|
||||
// Banner.save() invariant on every save.
|
||||
if was_active {
|
||||
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::Id.ne(pk))
|
||||
.exec(db)
|
||||
@@ -139,7 +157,9 @@ async fn sync_one(
|
||||
am.is_active = Set(true);
|
||||
}
|
||||
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 sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
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::{slugify, BackgroundJob};
|
||||
|
||||
const PACK_KEYWORDS: &[&str] =
|
||||
&["bundle", "set", "box", "probe", "sample", "taster", "shaker", "starter"];
|
||||
const PACK_KEYWORDS: &[&str] = &[
|
||||
"bundle", "set", "box", "probe", "sample", "taster", "shaker", "starter",
|
||||
];
|
||||
|
||||
pub struct SeedLegacy;
|
||||
|
||||
@@ -61,7 +60,11 @@ impl BackgroundJob for SeedLegacy {
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or("").to_lowercase();
|
||||
let fname = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
// Category from filename.
|
||||
let file_cat: Option<&category::Model> = if fname.contains("energy") {
|
||||
@@ -78,7 +81,9 @@ impl BackgroundJob for SeedLegacy {
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
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 {
|
||||
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_NAME: &str = "Packs and other";
|
||||
|
||||
const FORM_WORDS: &[&str] =
|
||||
&["bundle", "sachet", "sachetbox", "bottle", "box", "pack", "set", "sachets"];
|
||||
const FORM_WORDS: &[&str] = &[
|
||||
"bundle",
|
||||
"sachet",
|
||||
"sachetbox",
|
||||
"bottle",
|
||||
"box",
|
||||
"pack",
|
||||
"set",
|
||||
"sachets",
|
||||
];
|
||||
const NON_FLAVOR_TOKENS: &[&str] = &[
|
||||
"value", "shaker", "merch", "sticker", "packaging", "material", "other", "items", "mixed",
|
||||
"collector", "collector's", "free", "products", "naturalrabatt", "sample", "samplebox",
|
||||
"value",
|
||||
"shaker",
|
||||
"merch",
|
||||
"sticker",
|
||||
"packaging",
|
||||
"material",
|
||||
"other",
|
||||
"items",
|
||||
"mixed",
|
||||
"collector",
|
||||
"collector's",
|
||||
"free",
|
||||
"products",
|
||||
"naturalrabatt",
|
||||
"sample",
|
||||
"samplebox",
|
||||
];
|
||||
const PACK_TITLE_KEYWORDS: &[&str] = &[
|
||||
"bundle", "set", "box", "probe", "sample", "taster", "starter", "collection", "probier",
|
||||
"bundle",
|
||||
"set",
|
||||
"box",
|
||||
"probe",
|
||||
"sample",
|
||||
"taster",
|
||||
"starter",
|
||||
"collection",
|
||||
"probier",
|
||||
];
|
||||
const ACCESSORY_TITLE_KEYWORDS: &[&str] = &["bürste", "reinigung", "ersatzteil", "zubehör"];
|
||||
const TAG_FAMILY: &[(&[&str], &str)] = &[
|
||||
@@ -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()));
|
||||
|
||||
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();
|
||||
|
||||
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_lower: Vec<String> = tags.iter().map(|t| t.to_lowercase()).collect();
|
||||
let title_lower = title.to_lowercase();
|
||||
|
||||
// Syrup ships as one article whose variants are the flavors.
|
||||
if is_syrup_variant_product(p) {
|
||||
let (c, u) = sync_syrup_variants(db, &client, media_root, p, &mut synced_ids).await?;
|
||||
let (c, u) =
|
||||
sync_syrup_variants(db, &client, media_root, p, &mut synced_ids).await?;
|
||||
created += c;
|
||||
updated += u;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(cat) =
|
||||
resolve_category(db, p, &title_lower, &tags_lower, &packs).await?
|
||||
let Some(cat) = resolve_category(db, p, &title_lower, &tags_lower, &packs).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let variants = p.get("variants").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let is_available = variants.iter().any(|v| v.get("available").and_then(|a| a.as_bool()).unwrap_or(false));
|
||||
let images = p.get("images").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let image_url = images.first().and_then(|i| i.get("src")).and_then(|s| s.as_str()).map(String::from);
|
||||
let image_urls: Vec<String> =
|
||||
images.iter().filter_map(|i| i.get("src").and_then(|s| s.as_str()).map(String::from)).collect();
|
||||
let variants = p
|
||||
.get("variants")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let is_available = variants.iter().any(|v| {
|
||||
v.get("available")
|
||||
.and_then(|a| a.as_bool())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let images = p
|
||||
.get("images")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let image_url = images
|
||||
.first()
|
||||
.and_then(|i| i.get("src"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
let image_urls: Vec<String> = images
|
||||
.iter()
|
||||
.filter_map(|i| i.get("src").and_then(|s| s.as_str()).map(String::from))
|
||||
.collect();
|
||||
let description = clean_html(p.get("body_html").and_then(|v| v.as_str()).unwrap_or(""));
|
||||
let shop_url = p.get("handle").and_then(|v| v.as_str()).map(shop_url_for);
|
||||
let Some(pid) = p.get("id").and_then(|v| v.as_i64()) else {
|
||||
@@ -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.
|
||||
fn parse_tags(p: &Value) -> Vec<String> {
|
||||
match p.get("tags") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|t| t.as_str().map(String::from)).collect(),
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str().map(String::from))
|
||||
.collect(),
|
||||
Some(Value::String(s)) => s.split(',').map(|t| t.trim().to_string()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -209,10 +269,16 @@ fn family_from_product_type(product_type: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let mut tokens: Vec<&str> = stripped.split_whitespace().collect();
|
||||
if tokens.iter().any(|t| NON_FLAVOR_TOKENS.contains(&t.to_lowercase().as_str())) {
|
||||
if tokens
|
||||
.iter()
|
||||
.any(|t| NON_FLAVOR_TOKENS.contains(&t.to_lowercase().as_str()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
while tokens.last().is_some_and(|t| FORM_WORDS.contains(&t.to_lowercase().as_str())) {
|
||||
while tokens
|
||||
.last()
|
||||
.is_some_and(|t| FORM_WORDS.contains(&t.to_lowercase().as_str()))
|
||||
{
|
||||
tokens.pop();
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
@@ -254,11 +320,20 @@ fn is_syrup_variant_product(p: &Value) -> bool {
|
||||
.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|opts| {
|
||||
opts.iter()
|
||||
.any(|o| o.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase() == "flavor")
|
||||
opts.iter().any(|o| {
|
||||
o.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
== "flavor"
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let variant_count = p.get("variants").and_then(|v| v.as_array()).map(|v| v.len()).unwrap_or(0);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -273,7 +348,10 @@ async fn resolve_category(
|
||||
if is_pack(title_lower, tags_lower) {
|
||||
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()));
|
||||
}
|
||||
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)`.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -333,7 +414,11 @@ async fn sync_syrup_variants(
|
||||
|
||||
let mut created = 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 {
|
||||
let name = v
|
||||
.get("option1")
|
||||
@@ -354,7 +439,10 @@ async fn sync_syrup_variants(
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
let image_urls: Vec<String> = image_url.clone().into_iter().collect();
|
||||
let available = v.get("available").and_then(|a| a.as_bool()).unwrap_or(false);
|
||||
let available = v
|
||||
.get("available")
|
||||
.and_then(|a| a.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let was_created = upsert_flavor(
|
||||
db,
|
||||
@@ -412,7 +500,9 @@ async fn upsert_flavor(
|
||||
}
|
||||
if existing.is_none() {
|
||||
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))
|
||||
.one(db)
|
||||
.await?;
|
||||
@@ -510,8 +600,8 @@ async fn download_gallery(
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let succeeded: HashMap<usize, String> = futures::stream::iter(targets.clone().into_iter().map(
|
||||
|(i, u, rel)| {
|
||||
let succeeded: HashMap<usize, String> =
|
||||
futures::stream::iter(targets.clone().into_iter().map(|(i, u, rel)| {
|
||||
let client = client.clone();
|
||||
let media_root = media_root.to_string();
|
||||
async move {
|
||||
@@ -519,22 +609,31 @@ async fn download_gallery(
|
||||
let ok = download_image(&client, &u, &abs).await;
|
||||
(i, ok, rel)
|
||||
}
|
||||
},
|
||||
))
|
||||
.buffer_unordered(DOWNLOAD_WORKERS)
|
||||
.filter_map(|(i, ok, rel)| async move { if ok { Some((i, rel)) } else { None } })
|
||||
.collect()
|
||||
.await;
|
||||
}))
|
||||
.buffer_unordered(DOWNLOAD_WORKERS)
|
||||
.filter_map(|(i, ok, rel)| async move {
|
||||
if ok {
|
||||
Some((i, rel))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
targets.iter().filter_map(|(i, _, _)| succeeded.get(i).cloned()).collect()
|
||||
targets
|
||||
.iter()
|
||||
.filter_map(|(i, _, _)| succeeded.get(i).cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Django JSONField (list) stored as TEXT → Vec<String>.
|
||||
fn json_str_list(v: &Option<Value>) -> Vec<String> {
|
||||
match v {
|
||||
Some(Value::Array(arr)) => {
|
||||
arr.iter().filter_map(|x| x.as_str().map(String::from)).collect()
|
||||
}
|
||||
Some(Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ mod entities;
|
||||
mod error;
|
||||
mod jobs;
|
||||
mod media;
|
||||
mod merge;
|
||||
mod mentions;
|
||||
mod merge;
|
||||
mod openapi;
|
||||
mod pagination;
|
||||
mod password;
|
||||
@@ -63,21 +63,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
let state = AppState {
|
||||
db,
|
||||
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()),
|
||||
};
|
||||
|
||||
// Background scheduler: runs jobs whose `interval_hours` has elapsed,
|
||||
// replacing Celery beat. Restart-safe (last_run persists in the DB).
|
||||
// Gated so it never double-schedules against a still-running Django beat
|
||||
// during the strangler cutover — exactly one scheduler may own `api_job`.
|
||||
// Gated by ENABLE_SCHEDULER — exactly one process may own `api_job`, so
|
||||
// enable the scheduler on at most one running instance.
|
||||
if state.config.enable_scheduler {
|
||||
tracing::info!("job scheduler ENABLED (this process owns api_job scheduling)");
|
||||
jobs::spawn_scheduler(state.clone());
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"job scheduler DISABLED (ENABLE_SCHEDULER!=true) — Django beat assumed to own jobs"
|
||||
);
|
||||
tracing::warn!("job scheduler DISABLED (ENABLE_SCHEDULER!=true)");
|
||||
}
|
||||
|
||||
// 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
|
||||
/// ties (mirrors Python's tuple comparison `(len(comment), created_at)`).
|
||||
fn quality(r: &rating::Model) -> (usize, chrono::NaiveDateTime) {
|
||||
(r.comment.as_deref().unwrap_or("").chars().count(), r.created_at)
|
||||
(
|
||||
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
|
||||
|
||||
@@ -47,13 +47,7 @@ fn with_page(ctx: &RequestCtx, page: u64) -> String {
|
||||
}
|
||||
|
||||
impl<T: Serialize> Paginated<T> {
|
||||
pub fn build(
|
||||
ctx: &RequestCtx,
|
||||
results: Vec<T>,
|
||||
count: u64,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> Self {
|
||||
pub fn build(ctx: &RequestCtx, results: Vec<T>, count: u64, page: u64, page_size: u64) -> Self {
|
||||
let total_pages = count.div_ceil(page_size.max(1));
|
||||
let next = if page < total_pages {
|
||||
Some(with_page(ctx, page + 1))
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! (argon2/bcrypt) would need their own branch — login would fall through to a
|
||||
//! rejection for those, so keep issuance on Django if the hasher ever changes.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
@@ -45,7 +45,12 @@ pub fn verify_password(password: &str, encoded: &str) -> bool {
|
||||
if iterations == 0 {
|
||||
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.
|
||||
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 {
|
||||
let salt = uuid::Uuid::new_v4().simple().to_string();
|
||||
let mut derived = [0u8; DK_LEN];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), ITERATIONS, &mut derived);
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt.as_bytes(),
|
||||
ITERATIONS,
|
||||
&mut derived,
|
||||
);
|
||||
let b64 = B64.encode(derived);
|
||||
format!("{ALGORITHM}${ITERATIONS}${salt}${b64}")
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ fn too_similar(password: &str, attrs: &UserAttrs) -> Result<(), String> {
|
||||
continue;
|
||||
}
|
||||
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/users/", get(users_list))
|
||||
.route("/admin-custom/{pk}/trigger_job/", post(trigger_job))
|
||||
.route("/admin-custom/{pk}/update_job_schedule/", patch(update_job_schedule))
|
||||
.route(
|
||||
"/admin-custom/{pk}/update_job_schedule/",
|
||||
patch(update_job_schedule),
|
||||
)
|
||||
.route(
|
||||
"/admin-custom/{pk}/user_detail/",
|
||||
get(user_detail_get).patch(user_detail_patch).delete(user_detail_delete),
|
||||
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) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
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(_) => {
|
||||
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")? {
|
||||
@@ -218,11 +227,14 @@ async fn trigger_job(
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let row = Job::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
||||
let row = Job::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let Some(job_impl) = jobs::lookup(&row.name) else {
|
||||
return Err(ApiError::JsonStatus(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({ "error": format!("No celery task registered for {}", row.name) }),
|
||||
json!({ "error": format!("No job registered for {}", row.name) }),
|
||||
));
|
||||
};
|
||||
|
||||
@@ -245,7 +257,10 @@ async fn update_job_schedule(
|
||||
Path(pk): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let row = Job::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
||||
let row = Job::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let data = parse_body(&body);
|
||||
|
||||
let interval = match data.get("interval_hours") {
|
||||
@@ -381,8 +396,10 @@ async fn users_list(State(state): State<AppState>, _admin: AdminUser) -> ApiResu
|
||||
let out: Vec<Value> = users
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let ips: Vec<String> =
|
||||
ip_map.get(&u.id).map(|v| v.iter().take(3).cloned().collect()).unwrap_or_default();
|
||||
let ips: Vec<String> = ip_map
|
||||
.get(&u.id)
|
||||
.map(|v| v.iter().take(3).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
admin_user_json(u, &ips)
|
||||
})
|
||||
.collect();
|
||||
@@ -395,7 +412,10 @@ async fn build_user_detail(
|
||||
ctx: &RequestCtx,
|
||||
u: &user::Model,
|
||||
) -> 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()
|
||||
.filter(rating::Column::UserId.eq(u.id))
|
||||
.order_by_desc(rating::Column::CreatedAt)
|
||||
@@ -413,7 +433,10 @@ async fn user_detail_get(
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
) -> 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?))
|
||||
}
|
||||
|
||||
@@ -424,7 +447,10 @@ async fn user_detail_patch(
|
||||
Path(pk): Path<i32>,
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let u = User::find_by_id(pk).one(&state.db).await?.ok_or(ApiError::NotFound)?;
|
||||
let u = User::find_by_id(pk)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let data = parse_body(&body);
|
||||
let u = match data.get("is_active") {
|
||||
Some(Value::Bool(b)) => {
|
||||
@@ -442,7 +468,10 @@ async fn user_detail_delete(
|
||||
_admin: AdminUser,
|
||||
Path(pk): Path<i32>,
|
||||
) -> 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?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -20,9 +20,7 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::blacklist::{blacklist, is_blacklisted, record_outstanding};
|
||||
use crate::cookies::{
|
||||
clear_cookie, cookie_value, set_cookie, COOKIE_ACCESS, COOKIE_REFRESH,
|
||||
};
|
||||
use crate::cookies::{clear_cookie, cookie_value, set_cookie, COOKIE_ACCESS, COOKIE_REFRESH};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::user;
|
||||
use crate::error::ApiResult;
|
||||
@@ -102,7 +100,11 @@ async fn login(
|
||||
)
|
||||
.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)]
|
||||
@@ -225,7 +227,12 @@ fn ok_with_cookies(secure: bool, access: &Minted, refresh: &Minted) -> Response
|
||||
);
|
||||
append_cookie(
|
||||
&mut resp,
|
||||
&set_cookie(COOKIE_REFRESH, &refresh.token, REFRESH_LIFETIME_SECS, secure),
|
||||
&set_cookie(
|
||||
COOKIE_REFRESH,
|
||||
&refresh.token,
|
||||
REFRESH_LIFETIME_SECS,
|
||||
secure,
|
||||
),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ async fn top(
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.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.
|
||||
@@ -156,7 +158,9 @@ async fn newest(
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.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
|
||||
@@ -189,7 +193,9 @@ async fn followed_top(
|
||||
.limit(10)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(build_flavors(&state, &ctx, models, Some(uid), &[]).await?))
|
||||
Ok(Json(
|
||||
build_flavors(&state, &ctx, models, Some(uid), &[]).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -98,9 +98,10 @@ async fn create(
|
||||
body: Bytes,
|
||||
) -> ApiResult<(StatusCode, Json<RatingOut>)> {
|
||||
// django_ratelimit(key="user", rate="10/m") on RatingViewSet.create.
|
||||
state
|
||||
.security
|
||||
.check_rate(&format!("rating:user:{uid}"), crate::throttle::RATING_CREATE)?;
|
||||
state.security.check_rate(
|
||||
&format!("rating:user:{uid}"),
|
||||
crate::throttle::RATING_CREATE,
|
||||
)?;
|
||||
let data: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
|
||||
// DRF validation, aggregated and field-keyed, in serializer field order.
|
||||
@@ -398,10 +399,7 @@ async fn feed(
|
||||
}
|
||||
|
||||
/// GET /api/ratings/recent/ — 10 newest with a non-empty comment (AllowAny).
|
||||
async fn recent(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
) -> ApiResult<Json<Vec<RatingOut>>> {
|
||||
async fn recent(State(state): State<AppState>, ctx: RequestCtx) -> ApiResult<Json<Vec<RatingOut>>> {
|
||||
let models = Rating::find()
|
||||
.filter(rating::Column::Comment.is_not_null())
|
||||
.filter(rating::Column::Comment.ne(""))
|
||||
|
||||
@@ -28,12 +28,10 @@ use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/replies/", get(list))
|
||||
.route(
|
||||
"/replies/{id}/",
|
||||
get(retrieve).put(update).patch(update).delete(destroy),
|
||||
)
|
||||
Router::new().route("/replies/", get(list)).route(
|
||||
"/replies/{id}/",
|
||||
get(retrieve).put(update).patch(update).delete(destroy),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -124,7 +122,14 @@ async fn update(
|
||||
}
|
||||
|
||||
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?;
|
||||
dtos.pop().map(Json).ok_or(ApiError::Internal)
|
||||
|
||||
@@ -6,15 +6,13 @@ use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::dto::NotificationOut;
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::notification;
|
||||
use crate::entities::prelude::*;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::service::build_notifications;
|
||||
use crate::state::AppState;
|
||||
@@ -64,7 +62,10 @@ async fn mark_all_read(
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
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::IsRead.eq(false))
|
||||
.exec(&state.db)
|
||||
|
||||
@@ -97,7 +97,12 @@ async fn retrieve(
|
||||
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) {
|
||||
Some(Value::String(s)) if !s.is_empty() => {
|
||||
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(
|
||||
json!({ field: ["This field may not be blank."] }),
|
||||
)),
|
||||
Some(v) if !v.is_null() => {
|
||||
Err(ApiError::Validation(json!({ field: ["Not a valid string."] })))
|
||||
}
|
||||
Some(v) if !v.is_null() => Err(ApiError::Validation(
|
||||
json!({ field: ["Not a valid string."] }),
|
||||
)),
|
||||
_ if partial => Ok(None),
|
||||
_ => Err(ApiError::Validation(
|
||||
json!({ field: ["This field is required."] }),
|
||||
@@ -168,7 +173,10 @@ async fn create(
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -211,7 +219,9 @@ async fn destroy(
|
||||
) -> ApiResult<StatusCode> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
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.
|
||||
Ticket::find_by_id(id)
|
||||
|
||||
@@ -39,9 +39,23 @@ use crate::throttle;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
const THEMES: &[&str] = &[
|
||||
"holy_light", "holy_dark", "latte", "frappe", "macchiato", "mocha", "pink_pastel",
|
||||
"mint_pastel", "lavender_pastel", "dracula", "nord", "gruvbox", "oceanic", "t0p_sai",
|
||||
"t0p_trench", "t0p_blurryface", "t0p_clancy",
|
||||
"holy_light",
|
||||
"holy_dark",
|
||||
"latte",
|
||||
"frappe",
|
||||
"macchiato",
|
||||
"mocha",
|
||||
"pink_pastel",
|
||||
"mint_pastel",
|
||||
"lavender_pastel",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox",
|
||||
"oceanic",
|
||||
"t0p_sai",
|
||||
"t0p_trench",
|
||||
"t0p_blurryface",
|
||||
"t0p_clancy",
|
||||
];
|
||||
const LANGUAGES: &[&str] = &["en", "de"];
|
||||
const DRAWER_ANCHORS: &[&str] = &["left", "right"];
|
||||
@@ -60,10 +74,22 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/users/change_password/", post(change_password))
|
||||
.route("/users/update_profile/", patch(update_profile))
|
||||
.route("/users/confirm_email/", post(confirm_email))
|
||||
.route("/users/request_password_reset/", post(request_password_reset))
|
||||
.route("/users/complete_password_reset/", post(complete_password_reset))
|
||||
.route("/users/request_account_deletion/", post(request_account_deletion))
|
||||
.route("/users/confirm_account_deletion/", post(confirm_account_deletion))
|
||||
.route(
|
||||
"/users/request_password_reset/",
|
||||
post(request_password_reset),
|
||||
)
|
||||
.route(
|
||||
"/users/complete_password_reset/",
|
||||
post(complete_password_reset),
|
||||
)
|
||||
.route(
|
||||
"/users/request_account_deletion/",
|
||||
post(request_account_deletion),
|
||||
)
|
||||
.route(
|
||||
"/users/confirm_account_deletion/",
|
||||
post(confirm_account_deletion),
|
||||
)
|
||||
.route("/users/dashboard/", get(dashboard))
|
||||
.route("/users/{id}/", get(retrieve))
|
||||
.route("/users/{id}/follow/", post(follow))
|
||||
@@ -207,7 +233,9 @@ async fn following_ids(state: &AppState, from: i32) -> ApiResult<Vec<i32>> {
|
||||
[from.into()],
|
||||
))
|
||||
.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).
|
||||
@@ -220,7 +248,9 @@ async fn follower_ids(state: &AppState, to: i32) -> ApiResult<Vec<i32>> {
|
||||
[to.into()],
|
||||
))
|
||||
.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>> {
|
||||
@@ -368,9 +398,10 @@ async fn update_preferences(
|
||||
updated = true;
|
||||
}
|
||||
Some(v) => {
|
||||
let banner_id = v.as_i64().map(|n| n as i32).or_else(|| {
|
||||
v.as_str().and_then(|s| s.parse::<i32>().ok())
|
||||
});
|
||||
let banner_id = v
|
||||
.as_i64()
|
||||
.map(|n| n as i32)
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse::<i32>().ok()));
|
||||
match banner_id {
|
||||
Some(bid) => {
|
||||
let banner = Banner::find_by_id(bid)
|
||||
@@ -472,8 +503,14 @@ async fn change_password(
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let u = load_user(&state, uid).await?;
|
||||
let data = parse_body(&body);
|
||||
let old = data.get("old_password").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = data.get("new_password").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let old = data
|
||||
.get("old_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let new = data
|
||||
.get("new_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if !crate::password::verify_password(old, &u.password) {
|
||||
return Err(err_json(
|
||||
@@ -594,7 +631,9 @@ async fn confirm_email(
|
||||
active.email_confirmation_code = Set(None);
|
||||
active.update(&state.db).await?;
|
||||
state.security.clear_code(&code_key);
|
||||
Ok(Json(json!({ "status": "Email confirmed", "email": pending })))
|
||||
Ok(Json(
|
||||
json!({ "status": "Email confirmed", "email": pending }),
|
||||
))
|
||||
}
|
||||
|
||||
// ---- signup / verification ----------------------------------------------
|
||||
@@ -607,7 +646,9 @@ async fn signup(
|
||||
body: Bytes,
|
||||
) -> ApiResult<(StatusCode, Json<Value>)> {
|
||||
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 (Some(username), Some(email), Some(password)) = (
|
||||
field_str(&data, "username"),
|
||||
@@ -835,9 +876,10 @@ async fn request_password_reset(
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let ip = crate::web::client_ip(&headers, peer);
|
||||
state
|
||||
.security
|
||||
.check_rate(&format!("pwreset_req:ip:{ip}"), throttle::PASSWORD_RESET_REQUEST)?;
|
||||
state.security.check_rate(
|
||||
&format!("pwreset_req:ip:{ip}"),
|
||||
throttle::PASSWORD_RESET_REQUEST,
|
||||
)?;
|
||||
let generic = json!({
|
||||
"status": "If an account exists with this email, a reset code has been sent."
|
||||
});
|
||||
@@ -878,12 +920,16 @@ async fn complete_password_reset(
|
||||
body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let ip = crate::web::client_ip(&headers, peer);
|
||||
state
|
||||
.security
|
||||
.check_rate(&format!("pwreset_done:ip:{ip}"), throttle::PASSWORD_RESET_COMPLETE)?;
|
||||
state.security.check_rate(
|
||||
&format!("pwreset_done:ip:{ip}"),
|
||||
throttle::PASSWORD_RESET_COMPLETE,
|
||||
)?;
|
||||
let data = parse_body(&body);
|
||||
let email = field_str(&data, "email").unwrap_or_default();
|
||||
let code = data.get("code").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let code = data
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let new_password = data.get("password").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let code_key = pwreset_code_key(&email);
|
||||
@@ -935,9 +981,10 @@ async fn request_account_deletion(
|
||||
AuthUser(uid): AuthUser,
|
||||
_body: Bytes,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
state
|
||||
.security
|
||||
.check_rate(&format!("deletion_req:user:{uid}"), throttle::ACCOUNT_DELETION_REQUEST)?;
|
||||
state.security.check_rate(
|
||||
&format!("deletion_req:user:{uid}"),
|
||||
throttle::ACCOUNT_DELETION_REQUEST,
|
||||
)?;
|
||||
let user = load_user(&state, uid).await?;
|
||||
let code = gen_code();
|
||||
let uname = user.username.clone();
|
||||
@@ -994,9 +1041,8 @@ async fn confirm_account_deletion(
|
||||
/// in dependency order, inside one transaction.
|
||||
pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult<()> {
|
||||
let txn = state.db.begin().await?;
|
||||
let exec = |sql: &'static str| {
|
||||
Statement::from_sql_and_values(DbBackend::Sqlite, sql, [uid.into()])
|
||||
};
|
||||
let exec =
|
||||
|sql: &'static str| Statement::from_sql_and_values(DbBackend::Sqlite, sql, [uid.into()]);
|
||||
|
||||
// Notifications referencing the user directly (recipient/actor) or via the
|
||||
// user's ratings/replies/tickets/profile-comments.
|
||||
@@ -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)",
|
||||
))
|
||||
.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(
|
||||
"DELETE FROM api_ticketmessage WHERE user_id = ?1 \
|
||||
OR ticket_id IN (SELECT id FROM api_ticket WHERE user_id = ?1)",
|
||||
))
|
||||
.await?;
|
||||
txn.execute(exec("DELETE FROM api_ticket WHERE user_id = ?")).await?;
|
||||
txn.execute(exec("DELETE FROM api_ticket WHERE user_id = ?"))
|
||||
.await?;
|
||||
txn.execute(exec(
|
||||
"DELETE FROM api_profilecomment WHERE author_id = ?1 OR profile_owner_id = ?1",
|
||||
))
|
||||
.await?;
|
||||
txn.execute(exec("DELETE FROM api_userip WHERE user_id = ?")).await?;
|
||||
txn.execute(exec("DELETE FROM api_userip WHERE user_id = ?"))
|
||||
.await?;
|
||||
txn.execute(exec(
|
||||
"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 = ?",
|
||||
))
|
||||
.await?;
|
||||
txn.execute(exec("DELETE FROM api_user WHERE id = ?")).await?;
|
||||
txn.execute(exec("DELETE FROM api_user WHERE id = ?"))
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1144,7 +1194,10 @@ async fn add_comment(
|
||||
}
|
||||
|
||||
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}/
|
||||
@@ -1298,8 +1351,14 @@ async fn dashboard(
|
||||
.map(|c| (c.id, c.name))
|
||||
.collect();
|
||||
all_flavors.sort_by(|a, b| {
|
||||
let ca = cat_names.get(&a.category_id).map(String::as_str).unwrap_or("");
|
||||
let cb = cat_names.get(&b.category_id).map(String::as_str).unwrap_or("");
|
||||
let ca = cat_names
|
||||
.get(&a.category_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
let cb = cat_names
|
||||
.get(&b.category_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
|
||||
+62
-29
@@ -10,11 +10,13 @@ use sea_orm::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut, TicketOut,
|
||||
UserOut,
|
||||
FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut,
|
||||
TicketOut, UserOut,
|
||||
};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{category, flavor, notification, profile_comment, rating, reply, ticket, ticket_message, user};
|
||||
use crate::entities::{
|
||||
category, flavor, notification, profile_comment, rating, reply, ticket, ticket_message, user,
|
||||
};
|
||||
use crate::error::ApiResult;
|
||||
use crate::media::{flavor_image_list, flavor_primary_image, image_field_url};
|
||||
use crate::state::AppState;
|
||||
@@ -22,7 +24,10 @@ use crate::web::RequestCtx;
|
||||
|
||||
/// Comma-join i32 ids for an inlined SQL `IN (...)` list. Safe: ints only.
|
||||
fn id_list(ids: &[i32]) -> String {
|
||||
ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",")
|
||||
ids.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// 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))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let cat_map: HashMap<i32, (String, String)> = cats
|
||||
.into_iter()
|
||||
.map(|c| (c.id, (c.name, c.slug)))
|
||||
.collect();
|
||||
let cat_map: HashMap<i32, (String, String)> =
|
||||
cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect();
|
||||
|
||||
// Ratings for these flavors (RatingSerializer order: -created_at)
|
||||
let ratings = Rating::find()
|
||||
@@ -241,8 +244,7 @@ pub async fn build_flavors(
|
||||
.all(&state.db)
|
||||
.await?
|
||||
};
|
||||
let user_map: HashMap<i32, user::Model> =
|
||||
users.into_iter().map(|u| (u.id, u)).collect();
|
||||
let user_map: HashMap<i32, user::Model> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
|
||||
// Replies grouped by rating id
|
||||
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
|
||||
@@ -264,8 +266,7 @@ pub async fn build_flavors(
|
||||
}
|
||||
|
||||
// Flavor lookup for per-rating flavor fields
|
||||
let flavor_map: HashMap<i32, &flavor::Model> =
|
||||
flavors.iter().map(|f| (f.id, f)).collect();
|
||||
let flavor_map: HashMap<i32, &flavor::Model> = flavors.iter().map(|f| (f.id, f)).collect();
|
||||
|
||||
// Ratings grouped by flavor id
|
||||
let mut ratings_by_flavor: HashMap<i32, Vec<RatingOut>> = HashMap::new();
|
||||
@@ -334,9 +335,22 @@ pub async fn build_flavors(
|
||||
fn_ += 1;
|
||||
}
|
||||
}
|
||||
avg_by_flavor.insert(*fid, if n == 0 { None } else { Some(sum as f64 / n as f64) });
|
||||
followed_avg_by_flavor
|
||||
.insert(*fid, if fn_ == 0 { None } else { Some(fsum as f64 / fn_ as f64) });
|
||||
avg_by_flavor.insert(
|
||||
*fid,
|
||||
if n == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(sum as f64 / n as f64)
|
||||
},
|
||||
);
|
||||
followed_avg_by_flavor.insert(
|
||||
*fid,
|
||||
if fn_ == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(fsum as f64 / fn_ as f64)
|
||||
},
|
||||
);
|
||||
dist_by_flavor.insert(*fid, dist);
|
||||
}
|
||||
|
||||
@@ -370,11 +384,19 @@ pub async fn build_flavors(
|
||||
f.image.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_legacy: f.is_legacy,
|
||||
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)
|
||||
@@ -432,7 +454,9 @@ async fn social_counts(
|
||||
|
||||
/// Set of user ids that `viewer` follows, restricted to `ids`.
|
||||
async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> ApiResult<Vec<i32>> {
|
||||
let Some(v) = viewer else { return Ok(Vec::new()) };
|
||||
let Some(v) = viewer else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -447,7 +471,9 @@ async fn following_of(state: &AppState, viewer: Option<i32>, ids: &[i32]) -> Api
|
||||
),
|
||||
))
|
||||
.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
|
||||
@@ -464,8 +490,10 @@ pub async fn build_users(
|
||||
}
|
||||
let ids: Vec<i32> = users.iter().map(|u| u.id).collect();
|
||||
let (following, followers, unread) = social_counts(state, &ids).await?;
|
||||
let followed: std::collections::HashSet<i32> =
|
||||
following_of(state, viewer, &ids).await?.into_iter().collect();
|
||||
let followed: std::collections::HashSet<i32> = following_of(state, viewer, &ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let banner_ids: Vec<i32> = users.iter().filter_map(|u| u.selected_banner_id).collect();
|
||||
let banner_slugs: HashMap<i32, String> = if banner_ids.is_empty() {
|
||||
@@ -636,7 +664,9 @@ pub async fn build_notifications(
|
||||
}
|
||||
if let Some(rep_id) = n.reply_id {
|
||||
let rep = replies.get(&rep_id)?;
|
||||
return ratings.get(&rep.rating_id).and_then(|r| flavors.get(&r.flavor_id));
|
||||
return ratings
|
||||
.get(&rep.rating_id)
|
||||
.and_then(|r| flavors.get(&r.flavor_id));
|
||||
}
|
||||
None
|
||||
};
|
||||
@@ -713,13 +743,16 @@ pub async fn build_tickets(
|
||||
let mut msgs_by_ticket: HashMap<i32, Vec<TicketMessageOut>> = HashMap::new();
|
||||
for m in messages {
|
||||
let u = users.get(&m.user_id);
|
||||
msgs_by_ticket.entry(m.ticket_id).or_default().push(TicketMessageOut {
|
||||
id: m.id,
|
||||
username: u.map(|x| x.username.clone()).unwrap_or_default(),
|
||||
text: m.text,
|
||||
created_at: crate::datetime::drf_iso(&m.created_at),
|
||||
is_admin: u.map(|x| x.is_superuser).unwrap_or(false),
|
||||
});
|
||||
msgs_by_ticket
|
||||
.entry(m.ticket_id)
|
||||
.or_default()
|
||||
.push(TicketMessageOut {
|
||||
id: m.id,
|
||||
username: u.map(|x| x.username.clone()).unwrap_or_default(),
|
||||
text: m.text,
|
||||
created_at: crate::datetime::drf_iso(&m.created_at),
|
||||
is_admin: u.map(|x| x.is_superuser).unwrap_or(false),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tickets
|
||||
|
||||
@@ -30,10 +30,16 @@ pub struct Rate {
|
||||
|
||||
impl Rate {
|
||||
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 {
|
||||
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`.
|
||||
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`.
|
||||
|
||||
Reference in new issue
Block a user