Colour theme is configurable at runtime from two seed colours (brand + accent); neutrals stay fixed for contrast safety. Tailwind v4 var()-based tokens let a :root:root override recolour everything with no rebuild; the 50->950 ramps are derived via a color-mix ladder. Config lives in the DB config table (admin UI: Config > Farbschema, presets + custom pickers + live preview), served on the public /event endpoint with env defaults (THEME_PRESET/PRIMARY/ACCENT), propagated live via event-updated SSE, and cached in localStorage for a no-flash boot. The keepsake export mirrors the same ladder in Rust so offline archives match the event theme. COMMENTS_ENABLED (env, default true) is a boot-time kill-switch: the backend rejects new comments with 403 and the frontend hides the comment button (feed card) and panel/composer (lightbox). Existing comments stay in the DB, hidden, and return when re-enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
8.6 KiB
Rust
204 lines
8.6 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
|
|
/// 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";
|
|
|
|
/// A secret is "placeholder-ish" if it's the shipped dev sentinel or still carries
|
|
/// the tell-tale scaffolding substrings from `.env.example`. Length alone is not
|
|
/// enough — the shipped `change_me_...` placeholder is >32 chars.
|
|
fn looks_placeholder(s: &str) -> bool {
|
|
let lower = s.to_ascii_lowercase();
|
|
s == DEV_JWT_SECRET_SENTINEL
|
|
|| lower.contains("change_me")
|
|
|| lower.contains("dev_secret")
|
|
|| lower.contains("placeholder")
|
|
}
|
|
|
|
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
|
|
/// with a publicly-known signing key is worse than one that refuses to start.
|
|
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
|
|
fn validate_secrets(is_prod: bool, jwt_secret: &str, admin_password_hash: &str) -> Result<()> {
|
|
if is_prod {
|
|
if looks_placeholder(jwt_secret) {
|
|
return Err(anyhow!(
|
|
"Refusing to start in production with a placeholder JWT_SECRET — \
|
|
rotate it (openssl rand -hex 64)."
|
|
));
|
|
}
|
|
if jwt_secret.len() < 32 {
|
|
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
|
|
}
|
|
if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
|
|
return Err(anyhow!(
|
|
"Refusing to start in production without a real ADMIN_PASSWORD_HASH — \
|
|
generate one (htpasswd -bnBC 12 '' <password> | tr -d ':\\n')."
|
|
));
|
|
}
|
|
} else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
|
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(())
|
|
}
|
|
|
|
#[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,
|
|
/// Where export archives are written. MUST be outside `media_path` — that
|
|
/// directory is served by a public `ServeDir`, so a predictable archive name
|
|
/// under it would leak the whole gallery to anonymous visitors.
|
|
pub export_path: PathBuf,
|
|
pub app_port: u16,
|
|
/// Number of concurrent media compression workers (read once at boot).
|
|
pub compression_concurrency: usize,
|
|
/// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true).
|
|
/// When false the backend rejects new comments and the frontend hides the whole
|
|
/// comment UI. Existing comments stay in the DB (hidden), so flipping it back
|
|
/// restores them. Boot-time immutable, like `compression_concurrency`.
|
|
pub comments_enabled: bool,
|
|
/// Default colour theme, used as the fallback when the DB config keys are unset.
|
|
/// Runtime overrides live in the `config` table (admin UI); these env vars only
|
|
/// seed the initial default. `preset` is an id the frontend knows (e.g.
|
|
/// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand +
|
|
/// accent colours the whole palette is derived from.
|
|
pub default_theme_preset: String,
|
|
pub default_theme_primary: String,
|
|
pub default_theme_accent: String,
|
|
}
|
|
|
|
/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned
|
|
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
|
|
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
|
|
|
|
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")?;
|
|
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
|
|
|
|
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
|
|
|
|
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,
|
|
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()),
|
|
),
|
|
export_path: PathBuf::from(
|
|
std::env::var("EXPORT_PATH").unwrap_or_else(|_| "/exports".to_string()),
|
|
),
|
|
app_port: std::env::var("APP_PORT")
|
|
.unwrap_or_else(|_| "3000".to_string())
|
|
.parse()
|
|
.context("APP_PORT must be a number")?,
|
|
compression_concurrency: std::env::var("COMPRESSION_WORKER_CONCURRENCY")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.filter(|&n| n >= 1)
|
|
.unwrap_or(2),
|
|
comments_enabled: std::env::var("COMMENTS_ENABLED")
|
|
.map(|v| {
|
|
!matches!(
|
|
v.trim().to_ascii_lowercase().as_str(),
|
|
"false" | "0" | "no" | "off"
|
|
)
|
|
})
|
|
.unwrap_or(true),
|
|
default_theme_preset: std::env::var("THEME_PRESET")
|
|
.unwrap_or_else(|_| "champagne-gold".to_string()),
|
|
default_theme_primary: std::env::var("THEME_PRIMARY")
|
|
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
|
|
default_theme_accent: std::env::var("THEME_ACCENT")
|
|
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
|
|
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
|
|
|
|
#[test]
|
|
fn prod_rejects_shipped_placeholder_secret() {
|
|
// The exact string shipped in `.env` — >32 chars, so it must be caught by
|
|
// the substring guard, not the length check.
|
|
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
|
|
assert!(
|
|
err.is_err(),
|
|
"placeholder JWT_SECRET must be rejected in prod"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prod_rejects_dev_sentinel_and_short_secret() {
|
|
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH).is_err());
|
|
assert!(validate_secrets(true, "tooshort", REAL_HASH).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn prod_rejects_missing_or_placeholder_admin_hash() {
|
|
assert!(validate_secrets(true, REAL_SECRET, "").is_err());
|
|
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$placeholder_replace_me").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn prod_accepts_real_secrets() {
|
|
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn non_prod_tolerates_dev_sentinel() {
|
|
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn non_prod_still_rejects_short_non_sentinel_secret() {
|
|
assert!(validate_secrets(false, "tooshort", "").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn placeholder_detection_is_case_insensitive() {
|
|
// looks_placeholder lowercases before matching — an upper/mixed-case
|
|
// placeholder must still be rejected in prod.
|
|
assert!(
|
|
validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err()
|
|
);
|
|
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn prod_len_boundary_at_32() {
|
|
// Exactly 32 non-placeholder chars is the minimum accepted; 31 is rejected.
|
|
const LEN_32: &str = "abcdefghijklmnopqrstuvwxyz012345";
|
|
const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
|
|
assert_eq!(LEN_32.len(), 32);
|
|
assert_eq!(LEN_31.len(), 31);
|
|
assert!(validate_secrets(true, LEN_32, REAL_HASH).is_ok());
|
|
assert!(validate_secrets(true, LEN_31, REAL_HASH).is_err());
|
|
}
|
|
}
|