add seed ability
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
/sustenance.db
|
/sustenance.db
|
||||||
/sustenance.db*
|
/sustenance.db*
|
||||||
/.env
|
/.env
|
||||||
|
/seed.json
|
||||||
|
|||||||
Generated
+1
@@ -912,6 +912,7 @@ dependencies = [
|
|||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ maud = "0.27"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -19,8 +19,25 @@ Open <http://127.0.0.1:3000>. The application creates `sustenance.db` in the wor
|
|||||||
| `PUBLIC_BASE_URL` | derived from `BIND_ADDRESS` | Base URL used in invitation links |
|
| `PUBLIC_BASE_URL` | derived from `BIND_ADDRESS` | Base URL used in invitation links |
|
||||||
| `COOKIE_SECURE` | `false` | Add the `Secure` attribute to session cookies |
|
| `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 |
|
| `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 |
|
| `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
|
## Current features
|
||||||
|
|
||||||
- Email/password accounts with Argon2 password hashes
|
- Email/password accounts with Argon2 password hashes
|
||||||
|
|||||||
+5
-7
@@ -1,9 +1,10 @@
|
|||||||
mod db;
|
mod db;
|
||||||
mod hub;
|
mod hub;
|
||||||
|
mod seed;
|
||||||
mod views;
|
mod views;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{env, future::Future, sync::Arc};
|
use std::{env, future::Future, path::Path as FilePath, sync::Arc};
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
@@ -255,6 +256,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
registration_mode,
|
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()
|
let app = Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
.route("/login", get(login_page).post(login))
|
.route("/login", get(login_page).post(login))
|
||||||
@@ -349,12 +353,6 @@ async fn register(
|
|||||||
form.invite.as_deref(),
|
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 = form.password;
|
||||||
let password_hash = tokio::task::spawn_blocking(move || hash_password(&password))
|
let password_hash = tokio::task::spawn_blocking(move || hash_password(&password))
|
||||||
|
|||||||
+64
@@ -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<SeedUser>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -54,7 +54,7 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
|||||||
label for="email" { "Email" }
|
label for="email" { "Email" }
|
||||||
input id="email" name="email" type="email" autocomplete="email" required;
|
input id="email" name="email" type="email" autocomplete="email" required;
|
||||||
label for="password" { "Password" }
|
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" }
|
button class="button button-primary" type="submit" { "Create account" }
|
||||||
}
|
}
|
||||||
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
||||||
|
|||||||
Reference in New Issue
Block a user