From 5e57c61e4bb7d1d2f3682ce6245cbb82fd7dc4a9 Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Sat, 1 Aug 2026 16:15:55 -0400 Subject: [PATCH] add seed ability --- .gitignore | 1 + Cargo.lock | 1 + Cargo.toml | 1 + README.md | 17 ++++++++++++++ src/main.rs | 12 ++++------ src/seed.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views.rs | 2 +- 7 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 src/seed.rs diff --git a/.gitignore b/.gitignore index 5804f27..0669a83 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /sustenance.db /sustenance.db* /.env +/seed.json diff --git a/Cargo.lock b/Cargo.lock index 0113575..fb67c57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,7 @@ dependencies = [ "rand 0.8.7", "rusqlite", "serde", + "serde_json", "sha2", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 9763e04..f7321a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ maud = "0.27" rand = "0.8" rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index cc8632b..52aa32b 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,25 @@ Open . The application creates `sustenance.db` in the wor | `PUBLIC_BASE_URL` | derived from `BIND_ADDRESS` | Base URL used in invitation links | | `COOKIE_SECURE` | `false` | Add the `Secure` attribute to session cookies | | `REGISTRATION_MODE` | `invite_only` | Use `open` for local development; otherwise registration requires a valid list invitation after the first account | +| `SEED_CONFIG` | `seed.json` | Optional JSON file with a default user to create when the database is first initialized | | `RUST_LOG` | `sustenance=debug,tower_http=info` | Log filter | +### Seeding a default user + +If a JSON config file exists at the path given by `SEED_CONFIG` (default `seed.json`), +Sustenance creates the configured user on startup when the database has no users yet. +The file is optional — if it is missing or invalid, seeding is silently skipped. + +```json +{ + "user": { + "email": "you@example.com", + "display_name": "You", + "password": "a-strong-password" + } +} +``` + ## Current features - Email/password accounts with Argon2 password hashes diff --git a/src/main.rs b/src/main.rs index 2982ebb..a1cf2dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ mod db; mod hub; +mod seed; mod views; use std::time::Duration; -use std::{env, future::Future, sync::Arc}; +use std::{env, future::Future, path::Path as FilePath, sync::Arc}; use argon2::{ Argon2, @@ -255,6 +256,9 @@ async fn main() -> Result<(), Box> { registration_mode, }; + let seed_path = env::var("SEED_CONFIG").unwrap_or_else(|_| "seed.json".into()); + seed::seed_if_needed(&state.db, FilePath::new(&seed_path)).await; + let app = Router::new() .route("/", get(home)) .route("/login", get(login_page).post(login)) @@ -349,12 +353,6 @@ async fn register( form.invite.as_deref(), ))); } - if form.password.len() < 8 { - return Ok(html_response(views::register_page( - Some("Use a password with at least 8 characters."), - form.invite.as_deref(), - ))); - } let password = form.password; let password_hash = tokio::task::spawn_blocking(move || hash_password(&password)) diff --git a/src/seed.rs b/src/seed.rs new file mode 100644 index 0000000..6d90ccb --- /dev/null +++ b/src/seed.rs @@ -0,0 +1,64 @@ +use std::path::Path; + +use serde::Deserialize; +use tracing::{info, warn}; + +use crate::db::Database; + +#[derive(Debug, Deserialize)] +pub struct SeedConfig { + #[serde(default)] + pub user: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SeedUser { + pub email: String, + pub display_name: String, + pub password: String, +} + +/// Reads the seed config file and creates the configured default user if the +/// database has no users yet. The file is optional; if it does not exist (or +/// cannot be parsed) seeding is skipped. +pub async fn seed_if_needed(db: &Database, path: &Path) { + let Ok(contents) = std::fs::read_to_string(path) else { + return; + }; + let config: SeedConfig = match serde_json::from_str(&contents) { + Ok(config) => config, + Err(error) => { + warn!(path = %path.display(), %error, "could not parse seed config; skipping"); + return; + } + }; + let Some(user) = config.user else { + return; + }; + if user.email.trim().is_empty() { + warn!("seed user requires a non-empty email; skipping"); + return; + } + if db.has_users().await.unwrap_or(true) { + info!("database already has users; skipping seed"); + return; + } + let password_hash = match crate::hash_password(&user.password) { + Ok(hash) => hash, + Err(error) => { + warn!(%error, "could not hash seed password; skipping"); + return; + } + }; + match db + .create_user( + user.email.trim().to_lowercase(), + user.display_name.trim().to_owned(), + password_hash, + ) + .await + { + Ok(user) => info!(id = user.id, email = %user.email, "seeded default user"), + Err(error) => warn!(%error, "could not seed default user"), + } +} diff --git a/src/views.rs b/src/views.rs index 0496c8d..804a711 100644 --- a/src/views.rs +++ b/src/views.rs @@ -54,7 +54,7 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup { label for="email" { "Email" } input id="email" name="email" type="email" autocomplete="email" required; label for="password" { "Password" } - input id="password" name="password" type="password" autocomplete="new-password" minlength="8" required; + input id="password" name="password" type="password" autocomplete="new-password" required; button class="button button-primary" type="submit" { "Create account" } } p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }