Follow-up to the comprehensive code review. Five batches: 1. Cross-event authorization: host_delete_upload, unban_user, and host_delete_comment now scope by auth.event_id. Adds Upload::find_by_id_and_event / soft_delete_in_event and a Comment::soft_delete_in_event variant that joins through upload. 2. Token exposure: SSE auth no longer puts the JWT in the URL. New /api/v1/stream/ticket endpoint mints a short-lived single-use ticket bound to the session; the EventSource passes ?ticket=... instead. Refuse to start in APP_ENV=production with the dev JWT sentinel; warn loudly otherwise. 3. Account hardening: per-IP+name rate limit on /recover (mitigates targeted lockout DoS), per-IP rate limit on /admin/login, random 32-char admin recovery PIN (replaces "0000"), structured tracing events for wrong PIN, lockout, failed admin login, ban/unban/role change/pin-reset/host-delete. 4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before= cursor), hashtag extraction whitelisted to ASCII alnum+underscore (≤40 chars) with unit tests, display_name / caption / comment body length validated in chars rather than bytes. 5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS env var (default 10). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
66 lines
2.5 KiB
Rust
66 lines
2.5 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
|
|
/// we refuse to start with this value; otherwise we warn loudly.
|
|
const DEV_JWT_SECRET_SENTINEL: &str = "dev_secret_do_not_use_in_production_32byteslong_aaaa";
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct AppConfig {
|
|
pub database_url: String,
|
|
pub jwt_secret: String,
|
|
pub session_expiry_days: i64,
|
|
pub admin_password_hash: String,
|
|
pub event_name: String,
|
|
pub event_slug: String,
|
|
pub media_path: PathBuf,
|
|
pub app_port: u16,
|
|
}
|
|
|
|
impl AppConfig {
|
|
pub fn from_env() -> Result<Self> {
|
|
let app_env =
|
|
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
|
let is_prod = app_env.eq_ignore_ascii_case("production");
|
|
|
|
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
|
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
|
if is_prod {
|
|
return Err(anyhow!(
|
|
"Refusing to start in production with the well-known dev JWT_SECRET — \
|
|
rotate it (openssl rand -hex 64)."
|
|
));
|
|
}
|
|
tracing::warn!(
|
|
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
|
|
);
|
|
} else if jwt_secret.len() < 32 {
|
|
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
|
|
}
|
|
|
|
Ok(Self {
|
|
database_url: std::env::var("DATABASE_URL")
|
|
.context("DATABASE_URL must be set")?,
|
|
jwt_secret,
|
|
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
|
.unwrap_or_else(|_| "30".to_string())
|
|
.parse()
|
|
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
|
admin_password_hash: std::env::var("ADMIN_PASSWORD_HASH")
|
|
.unwrap_or_default(),
|
|
event_name: std::env::var("EVENT_NAME")
|
|
.unwrap_or_else(|_| "EventSnap".to_string()),
|
|
event_slug: std::env::var("EVENT_SLUG")
|
|
.context("EVENT_SLUG must be set")?,
|
|
media_path: PathBuf::from(
|
|
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
|
|
),
|
|
app_port: std::env::var("APP_PORT")
|
|
.unwrap_or_else(|_| "3000".to_string())
|
|
.parse()
|
|
.context("APP_PORT must be a number")?,
|
|
})
|
|
}
|
|
}
|