3 Commits
Author SHA1 Message Date
sbstp 5e57c61e4b add seed ability 2026-08-01 16:15:55 -04:00
sbstp 2db105bd6f use autoincrement for IDs 2026-08-01 15:56:21 -04:00
sbstp 5da5fee942 simplify list ownership & invitations 2026-08-01 15:46:38 -04:00
9 changed files with 368 additions and 504 deletions
+1
View File
@@ -2,3 +2,4 @@
/sustenance.db
/sustenance.db*
/.env
/seed.json
Generated
+2 -98
View File
@@ -132,12 +132,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -314,21 +308,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"r-efi",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -445,17 +428,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -676,12 +648,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.7"
@@ -781,12 +747,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
@@ -952,13 +912,13 @@ dependencies = [
"rand 0.8.7",
"rusqlite",
"serde",
"serde_json",
"sha2",
"thiserror",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -1221,17 +1181,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -1265,51 +1214,6 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+1 -1
View File
@@ -12,10 +12,10 @@ 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"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4"] }
+17
View File
@@ -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 |
| `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
+129 -238
View File
@@ -8,7 +8,6 @@ use rand::{RngCore, rngs::OsRng};
use rusqlite::{Connection, OptionalExtension, params};
use sha2::{Digest, Sha256};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum DbError {
@@ -31,7 +30,7 @@ pub struct Database {
#[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub id: i64,
pub email: String,
pub display_name: String,
}
@@ -44,47 +43,29 @@ pub struct SessionUser {
#[derive(Clone, Debug)]
pub struct GroceryList {
pub id: String,
pub id: i64,
pub name: String,
pub owner_id: String,
pub revision: i64,
}
#[derive(Clone, Debug)]
pub struct ListAccess {
pub list: GroceryList,
pub role: String,
}
#[derive(Clone, Debug)]
pub struct ListSummary {
pub list: GroceryList,
pub role: String,
}
#[derive(Clone, Debug)]
pub struct Item {
pub id: String,
pub list_id: String,
pub id: i64,
pub list_id: i64,
pub name: String,
pub quantity: String,
pub note: String,
pub category_id: Option<String>,
pub category_id: Option<i64>,
pub checked: bool,
pub version: i64,
}
#[derive(Clone, Debug)]
pub struct Category {
pub id: String,
pub id: i64,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct InvitationInfo {
pub list_name: String,
}
impl Database {
pub fn open(path: impl AsRef<Path>) -> DbResult<Self> {
let connection = Connection::open(path).map_err(sql_error)?;
@@ -124,20 +105,21 @@ impl Database {
password_hash: String,
) -> DbResult<User> {
self.call(move |connection| {
let user = User {
id: Uuid::new_v4().to_string(),
email,
display_name,
};
let result = connection.execute(
"INSERT INTO users (id, email, display_name, password_hash, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![user.id, user.email, user.display_name, password_hash, now()],
"INSERT INTO users (email, display_name, password_hash, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![email, display_name, password_hash, now()],
);
match result {
Ok(_) => Ok(user),
Ok(_) => {
let id = connection.last_insert_rowid();
Ok(User {
id,
email,
display_name,
})
}
Err(error) if error.to_string().contains("UNIQUE") => Err(DbError::Conflict),
Err(error) => Err(sql_error(error)),
}
@@ -180,7 +162,7 @@ impl Database {
.await
}
pub async fn create_session(&self, user_id: String) -> DbResult<(String, String)> {
pub async fn create_session(&self, user_id: i64) -> DbResult<(String, String)> {
self.call(move |connection| {
let session_token = new_secret();
let csrf_token = new_secret();
@@ -240,27 +222,21 @@ impl Database {
.await
}
pub async fn list_summaries(&self, user_id: String) -> DbResult<Vec<ListSummary>> {
pub async fn list_summaries(&self) -> DbResult<Vec<GroceryList>> {
self.call(move |connection| {
let mut statement = connection
.prepare(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role
"SELECT l.id, l.name, l.revision
FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE m.user_id = ?1
ORDER BY l.created_at DESC",
)
.map_err(sql_error)?;
let rows = statement
.query_map(params![user_id], |row| {
Ok(ListSummary {
list: GroceryList {
.query_map([], |row| {
Ok(GroceryList {
id: row.get(0)?,
name: row.get(1)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
revision: row.get(2)?,
})
})
.map_err(sql_error)?;
@@ -270,72 +246,49 @@ impl Database {
.await
}
pub async fn create_list(&self, owner_id: String, name: String) -> DbResult<GroceryList> {
pub async fn create_list(&self, name: String) -> DbResult<GroceryList> {
self.call(move |connection| {
let list = GroceryList {
id: Uuid::new_v4().to_string(),
name,
owner_id: owner_id.clone(),
revision: 0,
};
let transaction = connection.transaction().map_err(sql_error)?;
transaction
.execute(
"INSERT INTO lists (id, name, owner_id, revision, created_at)
VALUES (?1, ?2, ?3, 0, ?4)",
params![list.id, list.name, owner_id, now()],
)
.map_err(sql_error)?;
transaction
.execute(
"INSERT INTO list_members (list_id, user_id, role)
VALUES (?1, ?2, 'owner')",
params![list.id, list.owner_id],
"INSERT INTO lists (name, revision, created_at)
VALUES (?1, 0, ?2)",
params![name, now()],
)
.map_err(sql_error)?;
let list_id = transaction.last_insert_rowid();
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
transaction
.execute(
"INSERT INTO categories (id, list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
Uuid::new_v4().to_string(),
list.id,
category_name,
position as i64,
now()
],
"INSERT INTO categories (list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![list_id, category_name, position as i64, now()],
)
.map_err(sql_error)?;
}
transaction.commit().map_err(sql_error)?;
Ok(list)
Ok(GroceryList {
id: list_id,
name,
revision: 0,
})
})
.await
}
pub async fn list_access(
&self,
list_id: String,
user_id: String,
) -> DbResult<Option<ListAccess>> {
pub async fn list_access(&self, list_id: i64) -> DbResult<Option<GroceryList>> {
self.call(move |connection| {
connection
.query_row(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role
"SELECT l.id, l.name, l.revision
FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE l.id = ?1 AND m.user_id = ?2",
params![list_id, user_id],
WHERE l.id = ?1",
params![list_id],
|row| {
Ok(ListAccess {
list: GroceryList {
Ok(GroceryList {
id: row.get(0)?,
name: row.get(1)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
revision: row.get(2)?,
})
},
)
@@ -345,7 +298,7 @@ impl Database {
.await
}
pub async fn items(&self, list_id: String) -> DbResult<Vec<Item>> {
pub async fn items(&self, list_id: i64) -> DbResult<Vec<Item>> {
self.call(move |connection| {
let mut statement = connection
.prepare(
@@ -374,7 +327,7 @@ impl Database {
.await
}
pub async fn categories(&self, list_id: String) -> DbResult<Vec<Category>> {
pub async fn categories(&self, list_id: i64) -> DbResult<Vec<Category>> {
self.call(move |connection| {
let mut statement = connection
.prepare(
@@ -397,7 +350,7 @@ impl Database {
.await
}
pub async fn create_category(&self, list_id: String, name: String) -> DbResult<i64> {
pub async fn create_category(&self, list_id: i64, name: String) -> DbResult<i64> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let position: i64 = transaction
@@ -409,9 +362,9 @@ impl Database {
)
.map_err(sql_error)?;
let result = transaction.execute(
"INSERT INTO categories (id, list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![Uuid::new_v4().to_string(), list_id, name, position, now()],
"INSERT INTO categories (list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![list_id, name, position, now()],
);
match result {
Ok(_) => {}
@@ -420,7 +373,7 @@ impl Database {
}
Err(error) => return Err(sql_error(error)),
}
let revision = bump_revision(&transaction, &list_id)?;
let revision = bump_revision(&transaction, list_id)?;
transaction.commit().map_err(sql_error)?;
Ok(revision)
})
@@ -429,16 +382,15 @@ impl Database {
pub async fn add_item(
&self,
list_id: String,
list_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<String>,
category_id: Option<i64>,
) -> DbResult<i64> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let category_id = category_id.filter(|category_id| !category_id.is_empty());
ensure_category(&transaction, &list_id, category_id.as_deref())?;
ensure_category(&transaction, list_id, category_id)?;
let position: i64 = transaction
.query_row(
"SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1",
@@ -449,21 +401,12 @@ impl Database {
transaction
.execute(
"INSERT INTO items
(id, list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 1, ?7, ?8, ?8)",
params![
Uuid::new_v4().to_string(),
list_id,
name,
quantity,
note,
category_id,
position,
now()
],
(list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)",
params![list_id, name, quantity, note, category_id, position, now()],
)
.map_err(sql_error)?;
let revision = bump_revision(&transaction, &list_id)?;
let revision = bump_revision(&transaction, list_id)?;
transaction.commit().map_err(sql_error)?;
Ok(revision)
})
@@ -472,8 +415,8 @@ impl Database {
pub async fn set_item_checked(
&self,
list_id: String,
item_id: String,
list_id: i64,
item_id: i64,
checked: bool,
) -> DbResult<i64> {
self.call(move |connection| {
@@ -489,7 +432,7 @@ impl Database {
if changed == 0 {
return Err(DbError::NotFound);
}
let revision = bump_revision(&transaction, &list_id)?;
let revision = bump_revision(&transaction, list_id)?;
transaction.commit().map_err(sql_error)?;
Ok(revision)
})
@@ -498,17 +441,16 @@ impl Database {
pub async fn update_item(
&self,
list_id: String,
item_id: String,
list_id: i64,
item_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<String>,
category_id: Option<i64>,
) -> DbResult<i64> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let category_id = category_id.filter(|category_id| !category_id.is_empty());
ensure_category(&transaction, &list_id, category_id.as_deref())?;
ensure_category(&transaction, list_id, category_id)?;
let changed = transaction
.execute(
"UPDATE items
@@ -521,14 +463,14 @@ impl Database {
if changed == 0 {
return Err(DbError::NotFound);
}
let revision = bump_revision(&transaction, &list_id)?;
let revision = bump_revision(&transaction, list_id)?;
transaction.commit().map_err(sql_error)?;
Ok(revision)
})
.await
}
pub async fn delete_item(&self, list_id: String, item_id: String) -> DbResult<i64> {
pub async fn delete_item(&self, list_id: i64, item_id: i64) -> DbResult<i64> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let changed = transaction
@@ -540,26 +482,21 @@ impl Database {
if changed == 0 {
return Err(DbError::NotFound);
}
let revision = bump_revision(&transaction, &list_id)?;
let revision = bump_revision(&transaction, list_id)?;
transaction.commit().map_err(sql_error)?;
Ok(revision)
})
.await
}
pub async fn create_invitation(
&self,
list_id: String,
created_by: String,
token: String,
) -> DbResult<i64> {
pub async fn create_invitation(&self, created_by: i64, token: String) -> DbResult<i64> {
self.call(move |connection| {
let expires_at = now() + 60 * 60 * 24 * 7;
connection
.execute(
"INSERT INTO invitations (token_hash, list_id, created_by, expires_at)
VALUES (?1, ?2, ?3, ?4)",
params![hash_secret(&token), list_id, created_by, expires_at],
"INSERT INTO invitations (token_hash, created_by, expires_at)
VALUES (?1, ?2, ?3)",
params![hash_secret(&token), created_by, expires_at],
)
.map_err(sql_error)?;
Ok(expires_at)
@@ -567,47 +504,39 @@ impl Database {
.await
}
pub async fn invitation(&self, token: String) -> DbResult<Option<InvitationInfo>> {
pub async fn invitation(&self, token: String) -> DbResult<bool> {
self.call(move |connection| {
connection
let valid = connection
.query_row(
"SELECT l.name
FROM invitations i
JOIN lists l ON l.id = i.list_id
WHERE i.token_hash = ?1 AND i.expires_at > ?2",
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()],
|row| {
Ok(InvitationInfo {
list_name: row.get(0)?,
})
},
|_| Ok(()),
)
.optional()
.map_err(sql_error)
.map_err(sql_error)?
.is_some();
Ok(valid)
})
.await
}
pub async fn accept_invitation(&self, token: String, user_id: String) -> DbResult<String> {
pub async fn accept_invitation(&self, token: String) -> DbResult<()> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let invitation = transaction
let valid = transaction
.query_row(
"SELECT list_id FROM invitations
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()],
|row| row.get::<_, String>(0),
|_| Ok(()),
)
.optional()
.map_err(sql_error)?
.ok_or(DbError::NotFound)?;
transaction
.execute(
"INSERT OR IGNORE INTO list_members (list_id, user_id, role)
VALUES (?1, ?2, 'member')",
params![invitation, user_id],
)
.map_err(sql_error)?;
.is_some();
if !valid {
return Err(DbError::NotFound);
}
transaction
.execute(
"DELETE FROM invitations WHERE token_hash = ?1",
@@ -615,7 +544,7 @@ impl Database {
)
.map_err(sql_error)?;
transaction.commit().map_err(sql_error)?;
Ok(invitation)
Ok(())
})
.await
}
@@ -638,7 +567,7 @@ fn migrate(connection: &Connection) -> DbResult<()> {
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
@@ -646,26 +575,19 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS list_members (
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
PRIMARY KEY (list_id, user_id)
);
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
name TEXT NOT NULL COLLATE NOCASE,
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
@@ -673,17 +595,16 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
category_id TEXT REFERENCES categories(id) ON DELETE SET NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
checked INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 1,
position INTEGER NOT NULL DEFAULT 0,
@@ -692,39 +613,17 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS list_members_user_idx ON list_members(user_id);",
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
)
.map_err(sql_error)?;
if !has_column(connection, "items", "category_id")? {
connection
.execute(
"ALTER TABLE items ADD COLUMN category_id TEXT REFERENCES categories(id) ON DELETE SET NULL",
[],
)
.map_err(sql_error)?;
}
Ok(())
}
fn has_column(connection: &Connection, table: &str, wanted: &str) -> DbResult<bool> {
let mut statement = connection
.prepare(&format!("PRAGMA table_info({table})"))
.map_err(sql_error)?;
let mut rows = statement.query([]).map_err(sql_error)?;
while let Some(row) = rows.next().map_err(sql_error)? {
if row.get::<_, String>(1).map_err(sql_error)? == wanted {
return Ok(true);
}
}
Ok(false)
}
fn ensure_category(
transaction: &rusqlite::Transaction<'_>,
list_id: &str,
category_id: Option<&str>,
list_id: i64,
category_id: Option<i64>,
) -> DbResult<()> {
let Some(category_id) = category_id else {
return Ok(());
@@ -752,7 +651,7 @@ const DEFAULT_CATEGORIES: &[&str] = &[
"Household",
];
fn bump_revision(transaction: &rusqlite::Transaction<'_>, list_id: &str) -> DbResult<i64> {
fn bump_revision(transaction: &rusqlite::Transaction<'_>, list_id: i64) -> DbResult<i64> {
transaction
.execute(
"UPDATE lists SET revision = revision + 1 WHERE id = ?1",
@@ -798,12 +697,8 @@ mod tests {
#[tokio::test]
async fn creates_a_list_and_item() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user("test@example.com".into(), "Test User".into(), "hash".into())
.await
.unwrap();
let list = database
.create_list(user.id.clone(), "Weekly shop".into())
.create_list("Weekly shop".into())
.await
.unwrap();
database
@@ -829,12 +724,8 @@ mod tests {
.create_user("owner@example.com".into(), "Owner".into(), "hash".into())
.await
.unwrap();
let member = database
.create_user("member@example.com".into(), "Member".into(), "hash".into())
.await
.unwrap();
let list = database
.create_list(owner.id.clone(), "Household".into())
.create_list("Household".into())
.await
.unwrap();
assert_eq!(database.categories(list.id.clone()).await.unwrap().len(), 6);
@@ -848,52 +739,60 @@ mod tests {
assert_eq!(session.user.id, owner.id);
assert_eq!(session.csrf_token, csrf_token);
// Any registered account can access every list.
assert_eq!(
database
.list_access(list.id.clone())
.await
.unwrap()
.unwrap()
.name,
"Household"
);
let invitation_token = "test-invitation".to_owned();
database
.create_invitation(list.id.clone(), owner.id, invitation_token.clone())
.create_invitation(owner.id.clone(), invitation_token.clone())
.await
.unwrap();
assert_eq!(
assert!(
database
.invitation(invitation_token.clone())
.await
.unwrap()
.unwrap()
.list_name,
"Household"
);
let accepted_list = database
.accept_invitation(invitation_token.clone(), member.id.clone())
database
.accept_invitation(invitation_token.clone())
.await
.unwrap();
assert_eq!(accepted_list, list.id);
assert!(
database
!database
.invitation(invitation_token)
.await
.unwrap()
.is_none()
);
// A list created later is also accessible to every account.
let future_list = database
.create_list("Future shop".into())
.await
.unwrap();
assert_eq!(
database
.list_access(list.id, member.id)
.list_access(future_list.id)
.await
.unwrap()
.unwrap()
.role,
"member"
.name,
"Future shop"
);
}
#[tokio::test]
async fn checked_state_is_set_not_toggled() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user("check@example.com".into(), "Checker".into(), "hash".into())
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
let list = database.create_list("List".into()).await.unwrap();
database
.add_item(
list.id.clone(),
@@ -923,15 +822,7 @@ mod tests {
#[tokio::test]
async fn checking_an_item_does_not_change_list_order() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user(
"order@example.com".into(),
"Order Tester".into(),
"hash".into(),
)
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
let list = database.create_list("List".into()).await.unwrap();
database
.add_item(
list.id.clone(),
+21 -20
View File
@@ -5,19 +5,19 @@ use tokio::sync::{Mutex, broadcast};
#[derive(Clone, Debug)]
pub struct PresenceUser {
pub user_id: String,
pub user_id: i64,
pub display_name: String,
}
#[derive(Clone, Debug)]
pub enum HubEvent {
ListChanged { list_id: String, revision: i64 },
PresenceChanged { list_id: String },
ListChanged { list_id: i64, revision: i64 },
PresenceChanged { list_id: i64 },
}
#[derive(Debug)]
struct ConnectionInfo {
user_id: String,
user_id: i64,
display_name: String,
}
@@ -34,18 +34,18 @@ pub struct Subscription {
#[derive(Clone, Default)]
pub struct Hub {
rooms: Arc<Mutex<HashMap<String, Room>>>,
rooms: Arc<Mutex<HashMap<i64, Room>>>,
}
impl Hub {
pub async fn join(
&self,
list_id: String,
user_id: String,
list_id: i64,
user_id: i64,
display_name: String,
) -> Subscription {
let mut rooms = self.rooms.lock().await;
let room = rooms.entry(list_id.clone()).or_insert_with(|| {
let room = rooms.entry(list_id).or_insert_with(|| {
let (sender, _) = broadcast::channel(64);
Room {
sender,
@@ -79,10 +79,10 @@ impl Hub {
}
}
pub async fn leave(&self, list_id: &str, connection_id: &str) {
pub async fn leave(&self, list_id: i64, connection_id: &str) {
let mut rooms = self.rooms.lock().await;
let mut remove_room = false;
if let Some(room) = rooms.get_mut(list_id) {
if let Some(room) = rooms.get_mut(&list_id) {
let removed = room.connections.remove(connection_id);
if let Some(removed) = removed {
let still_present = room
@@ -90,19 +90,17 @@ impl Hub {
.values()
.any(|connection| connection.user_id == removed.user_id);
if !still_present {
let _ = room.sender.send(HubEvent::PresenceChanged {
list_id: list_id.to_owned(),
});
let _ = room.sender.send(HubEvent::PresenceChanged { list_id });
}
}
remove_room = room.connections.is_empty();
}
if remove_room {
rooms.remove(list_id);
rooms.remove(&list_id);
}
}
pub async fn publish_list_changed(&self, list_id: String, revision: i64) {
pub async fn publish_list_changed(&self, list_id: i64, revision: i64) {
let rooms = self.rooms.lock().await;
if let Some(room) = rooms.get(&list_id) {
let _ = room
@@ -111,19 +109,22 @@ impl Hub {
}
}
pub async fn presence(&self, list_id: &str) -> Vec<PresenceUser> {
pub async fn presence(&self, list_id: i64) -> Vec<PresenceUser> {
let rooms = self.rooms.lock().await;
rooms.get(list_id).map(current_presence).unwrap_or_default()
rooms
.get(&list_id)
.map(current_presence)
.unwrap_or_default()
}
}
fn current_presence(room: &Room) -> Vec<PresenceUser> {
let mut users = HashMap::<String, PresenceUser>::new();
let mut users = HashMap::<i64, PresenceUser>::new();
for connection in room.connections.values() {
users
.entry(connection.user_id.clone())
.entry(connection.user_id)
.or_insert_with(|| PresenceUser {
user_id: connection.user_id.clone(),
user_id: connection.user_id,
display_name: connection.display_name.clone(),
});
}
+82 -89
View File
@@ -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<dyn std::error::Error>> {
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))
@@ -267,7 +271,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/lists/{list_id}/items/{item_id}/edit", post(edit_item))
.route("/lists/{list_id}/items/{item_id}/delete", post(delete_item))
.route("/lists/{list_id}/categories", post(create_category))
.route("/lists/{list_id}/invitations", post(create_invitation))
.route("/invitations", post(create_invitation))
.route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page))
.route("/invite/{token}/accept", post(accept_invitation))
@@ -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))
@@ -435,7 +433,7 @@ async fn lists_page(
) -> Result<Response, AppError> {
let lists = state
.db
.list_summaries(user.session.user.id.clone())
.list_summaries()
.await?;
Ok(html_response(views::lists_page(
&user.session.user,
@@ -456,19 +454,19 @@ async fn create_list(
"List names must be between 1 and 80 characters.".into(),
));
}
let list = state.db.create_list(user.session.user.id, name).await?;
let list = state.db.create_list(name).await?;
Ok(Redirect::to(&format!("/lists/{}", list.id)).into_response())
}
async fn list_page(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<String>,
Path(list_id): Path<i64>,
) -> Result<Response, AppError> {
let access = require_access(&state, &user, &list_id).await?;
let items = state.db.items(list_id.clone()).await?;
let categories = state.db.categories(list_id.clone()).await?;
let presence = state.hub.presence(&list_id).await;
let access = require_access(&state, list_id).await?;
let items = state.db.items(list_id).await?;
let categories = state.db.categories(list_id).await?;
let presence = state.hub.presence(list_id).await;
Ok(html_response(views::list_page(
&user.session.user,
&access,
@@ -482,15 +480,15 @@ async fn list_page(
async fn add_item(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<String>,
Path(list_id): Path<i64>,
LoggedForm(form): LoggedForm<ItemForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, list_id).await?;
let name = form.name.trim().to_owned();
let quantity = form.quantity.trim().to_owned();
let note = form.note.trim().to_owned();
let category_id = normalize_category_id(form.category_id);
let category_id = parse_category_id(form.category_id);
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
"Item names must be between 1 and 120 characters.".into(),
@@ -498,23 +496,23 @@ async fn add_item(
}
let revision = state
.db
.add_item(list_id.clone(), name, quantity, note, category_id)
.add_item(list_id, name, quantity, note, category_id)
.await?;
state
.hub
.publish_list_changed(list_id.clone(), revision)
.publish_list_changed(list_id, revision)
.await;
list_fragment_response(&state, &user, &list_id).await
list_fragment_response(&state, &user, list_id).await
}
async fn check_item(
State(state): State<AppState>,
user: CurrentUser,
Path((list_id, item_id)): Path<(String, String)>,
Path((list_id, item_id)): Path<(i64, i64)>,
LoggedForm(form): LoggedForm<CheckForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, list_id).await?;
let checked = match form.checked.as_str() {
"1" | "true" => true,
"0" | "false" => false,
@@ -522,23 +520,23 @@ async fn check_item(
};
let revision = state
.db
.set_item_checked(list_id.clone(), item_id, checked)
.set_item_checked(list_id, item_id, checked)
.await?;
state
.hub
.publish_list_changed(list_id.clone(), revision)
.publish_list_changed(list_id, revision)
.await;
list_fragment_response(&state, &user, &list_id).await
list_fragment_response(&state, &user, list_id).await
}
async fn edit_item(
State(state): State<AppState>,
user: CurrentUser,
Path((list_id, item_id)): Path<(String, String)>,
Path((list_id, item_id)): Path<(i64, i64)>,
LoggedForm(form): LoggedForm<ItemForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, list_id).await?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
@@ -548,59 +546,59 @@ async fn edit_item(
let revision = state
.db
.update_item(
list_id.clone(),
list_id,
item_id,
name,
form.quantity.trim().to_owned(),
form.note.trim().to_owned(),
normalize_category_id(form.category_id),
parse_category_id(form.category_id),
)
.await?;
state
.hub
.publish_list_changed(list_id.clone(), revision)
.publish_list_changed(list_id, revision)
.await;
list_fragment_response(&state, &user, &list_id).await
list_fragment_response(&state, &user, list_id).await
}
async fn delete_item(
State(state): State<AppState>,
user: CurrentUser,
Path((list_id, item_id)): Path<(String, String)>,
Path((list_id, item_id)): Path<(i64, i64)>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
let revision = state.db.delete_item(list_id.clone(), item_id).await?;
require_access(&state, list_id).await?;
let revision = state.db.delete_item(list_id, item_id).await?;
state
.hub
.publish_list_changed(list_id.clone(), revision)
.publish_list_changed(list_id, revision)
.await;
list_fragment_response(&state, &user, &list_id).await
list_fragment_response(&state, &user, list_id).await
}
async fn create_category(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<String>,
Path(list_id): Path<i64>,
LoggedForm(form): LoggedForm<CategoryForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, list_id).await?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 60 {
return Err(AppError::BadRequest(
"Category names must be between 1 and 60 characters.".into(),
));
}
let revision = state.db.create_category(list_id.clone(), name).await?;
let revision = state.db.create_category(list_id, name).await?;
state
.hub
.publish_list_changed(list_id.clone(), revision)
.publish_list_changed(list_id, revision)
.await;
let access = require_access(&state, &user, &list_id).await?;
let items = state.db.items(list_id.clone()).await?;
let access = require_access(&state, list_id).await?;
let items = state.db.items(list_id).await?;
let categories = state.db.categories(list_id).await?;
Ok(html_response(views::category_created(
&access,
@@ -613,20 +611,13 @@ async fn create_category(
async fn create_invitation(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<String>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let access = require_access(&state, &user, &list_id).await?;
if access.role != "owner" {
return Err(AppError::BadRequest(
"Only the list owner can create invitations.".into(),
));
}
let token = db::new_secret();
state
.db
.create_invitation(list_id, user.session.user.id, token.clone())
.create_invitation(user.session.user.id, token.clone())
.await?;
let url = format!(
"{}/invite/{token}",
@@ -643,11 +634,12 @@ async fn invitation_page(
let info = state
.db
.invitation(token.clone())
.await?
.ok_or(AppError::NotFound)?;
.await?;
if !info {
return Err(AppError::NotFound);
}
let user = optional_user(&state, &headers).await?;
Ok(html_response(views::invite_page(
&info,
user.as_ref().map(|current| &current.session.user),
&token,
None,
@@ -663,20 +655,20 @@ async fn accept_invitation(
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let list_id = state
state
.db
.accept_invitation(token, user.session.user.id)
.accept_invitation(token)
.await?;
Ok(Redirect::to(&format!("/lists/{list_id}")).into_response())
Ok(Redirect::to("/lists").into_response())
}
async fn list_stream(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<String>,
Path(list_id): Path<i64>,
websocket: WebSocketUpgrade,
) -> Result<Response, AppError> {
require_access(&state, &user, &list_id).await?;
require_access(&state, list_id).await?;
let state_for_socket = state.clone();
let user_for_socket = user.clone();
Ok(websocket
@@ -684,12 +676,12 @@ async fn list_stream(
.into_response())
}
async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, socket: WebSocket) {
async fn handle_socket(state: AppState, user: CurrentUser, list_id: i64, socket: WebSocket) {
let subscription = state
.hub
.join(
list_id.clone(),
user.session.user.id.clone(),
list_id,
user.session.user.id,
user.session.user.display_name.clone(),
)
.await;
@@ -698,16 +690,16 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
let mut heartbeat = tokio::time::interval(Duration::from_secs(30));
heartbeat.tick().await;
match websocket_snapshot(&state, &user, &list_id, &subscription.presence).await {
match websocket_snapshot(&state, &user, list_id, &subscription.presence).await {
Ok(snapshot) => {
if sender.send(Message::Text(snapshot.into())).await.is_err() {
state.hub.leave(&list_id, &connection_id).await;
state.hub.leave(list_id, &connection_id).await;
return;
}
}
Err(error) => {
error!(%error, "could not render websocket snapshot");
state.hub.leave(&list_id, &connection_id).await;
state.hub.leave(list_id, &connection_id).await;
return;
}
}
@@ -719,7 +711,7 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
match event {
Ok(HubEvent::ListChanged { list_id: event_list_id, revision }) if event_list_id == list_id => {
tracing::debug!(%list_id, revision, "list changed on websocket");
match websocket_list_update(&state, &user, &list_id).await {
match websocket_list_update(&state, &user, list_id).await {
Ok(update) => {
if sender.send(Message::Text(update.into())).await.is_err() {
break;
@@ -732,14 +724,14 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
}
}
Ok(HubEvent::PresenceChanged { list_id: event_list_id }) if event_list_id == list_id => {
let presence = state.hub.presence(&list_id).await;
let presence = state.hub.presence(list_id).await;
let update = views::presence_panel(&presence, true).into_string();
if sender.send(Message::Text(update.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
match websocket_snapshot(&state, &user, &list_id, &state.hub.presence(&list_id).await).await {
match websocket_snapshot(&state, &user, list_id, &state.hub.presence(list_id).await).await {
Ok(snapshot) => {
if sender.send(Message::Text(snapshot.into())).await.is_err() {
break;
@@ -775,18 +767,18 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
}
}
state.hub.leave(&list_id, &connection_id).await;
state.hub.leave(list_id, &connection_id).await;
}
async fn websocket_snapshot(
state: &AppState,
user: &CurrentUser,
list_id: &str,
list_id: i64,
presence: &[hub::PresenceUser],
) -> Result<String, AppError> {
let access = require_access(state, user, list_id).await?;
let items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?;
let access = require_access(state, list_id).await?;
let items = state.db.items(list_id).await?;
let categories = state.db.categories(list_id).await?;
Ok(
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
.into_string()
@@ -797,11 +789,11 @@ async fn websocket_snapshot(
async fn websocket_list_update(
state: &AppState,
user: &CurrentUser,
list_id: &str,
list_id: i64,
) -> Result<String, AppError> {
let access = require_access(state, user, list_id).await?;
let items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?;
let access = require_access(state, list_id).await?;
let items = state.db.items(list_id).await?;
let categories = state.db.categories(list_id).await?;
Ok(
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
.into_string(),
@@ -811,13 +803,13 @@ async fn websocket_list_update(
async fn list_fragment_response(
state: &AppState,
user: &CurrentUser,
list_id: &str,
list_id: i64,
) -> Result<Response, AppError> {
let access = require_access(state, user, list_id).await?;
let items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?;
let access = require_access(state, list_id).await?;
let items = state.db.items(list_id).await?;
let categories = state.db.categories(list_id).await?;
Ok(html_response(views::list_items_fragment(
&access.list,
&access,
&items,
&categories,
&user.session.csrf_token,
@@ -827,12 +819,11 @@ async fn list_fragment_response(
async fn require_access(
state: &AppState,
user: &CurrentUser,
list_id: &str,
) -> Result<db::ListAccess, AppError> {
list_id: i64,
) -> Result<db::GroceryList, AppError> {
state
.db
.list_access(list_id.to_owned(), user.session.user.id.clone())
.list_access(list_id)
.await?
.ok_or(AppError::NotFound)
}
@@ -863,8 +854,10 @@ fn verify_csrf(user: &CurrentUser, token: &str) -> Result<(), AppError> {
Ok(())
}
fn normalize_category_id(category_id: Option<String>) -> Option<String> {
category_id.filter(|category_id| !category_id.trim().is_empty())
fn parse_category_id(category_id: Option<String>) -> Option<i64> {
category_id
.filter(|category_id| !category_id.trim().is_empty())
.and_then(|category_id| category_id.trim().parse().ok())
}
async fn can_register(state: &AppState, invite: Option<&str>) -> Result<bool, AppError> {
@@ -877,7 +870,7 @@ async fn can_register(state: &AppState, invite: Option<&str>) -> Result<bool, Ap
let Some(invite) = invite.filter(|invite| !invite.is_empty()) else {
return Ok(false);
};
Ok(state.db.invitation(invite.to_owned()).await?.is_some())
Ok(state.db.invitation(invite.to_owned()).await?)
}
fn hash_password(password: &str) -> Result<String, String> {
+64
View File
@@ -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"),
}
}
+66 -73
View File
@@ -1,7 +1,7 @@
use maud::{DOCTYPE, Markup, html};
use crate::{
db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User},
db::{Category, GroceryList, Item, User},
hub::PresenceUser,
};
@@ -13,7 +13,7 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
div class="auth-card" {
p class="eyebrow" { "SUSTENANCE" }
h1 { "Welcome back" }
p class="lede" { "Keep the household running, one item at a time." }
p class="lede" { "Keep the shopping list in sync." }
@if let Some(error) = error {
div class="alert alert-error" role="alert" { (error) }
}
@@ -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" } }
@@ -69,23 +69,23 @@ pub fn registration_closed_page() -> Markup {
None,
html! {
div class="auth-card" {
p class="eyebrow" { "PRIVATE HOUSEHOLD" }
p class="eyebrow" { "PRIVATE LISTS" }
h1 { "Registration is invite-only" }
p class="lede" { "Ask someone who owns a list to send you an invitation link." }
p class="lede" { "Ask someone who uses the app to send you an invitation link." }
a class="button button-primary" href="/login" { "Back to sign in" }
}
},
)
}
pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Markup {
pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Markup {
page(
"Your lists",
Some(user),
html! {
div class="page-heading" {
div {
p class="eyebrow" { "YOUR HOUSEHOLD" }
p class="eyebrow" { "SHARED LISTS" }
h1 { "Grocery lists" }
p class="lede" { "Everything you need, in one place." }
}
@@ -104,12 +104,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
}
} @else {
div class="list-cards" {
@for summary in lists {
a class="list-card" href=(format!("/lists/{}", summary.list.id)) {
@for list in lists {
a class="list-card" href=(format!("/lists/{}", list.id)) {
span class="list-card-icon" { "" }
span class="list-card-copy" {
strong { (summary.list.name) }
small { @if summary.role == "owner" { "Owner" } @else { "Member" } }
strong { (list.name) }
}
span class="list-card-arrow" { "" }
}
@@ -126,53 +125,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
button class="button button-primary" type="submit" { "Create list" }
}
}
}
},
)
}
pub fn list_page(
user: &User,
access: &ListAccess,
items: &[Item],
categories: &[Category],
presence: &[PresenceUser],
csrf_token: &str,
) -> Markup {
let is_owner = access.role == "owner";
page(
&access.list.name,
Some(user),
html! {
div class="list-topbar" {
a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" }
@if is_owner {
a class="button button-small button-quiet" href="#sharing" { "Share list" }
}
}
}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
div {
p class="eyebrow" { "SHARED LIST" }
h1 { (access.list.name) }
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
}
}
(list_content_fragment(&access.list, items, categories, csrf_token, false))
}
aside class="side-column" {
(presence_panel(presence, false))
(categories_panel(&access.list, categories, csrf_token, false))
@if is_owner {
section id="sharing" class="panel sharing-panel" {
div class="panel-heading" { h2 { "Share this list" } }
p { "Create a one-time invite link for someone you shop with." }
div class="panel-heading" { h2 { "Invite someone" } }
p { "Create a one-time invite link so a new person can join." }
form
hx-post=(format!("/lists/{}/invitations", access.list.id))
hx-post="/invitations"
hx-target="#invite-result"
hx-swap="innerHTML"
class="stack"
@@ -183,13 +140,49 @@ pub fn list_page(
div id="invite-result" class="invite-result" {}
}
}
},
)
}
pub fn list_page(
user: &User,
list: &GroceryList,
items: &[Item],
categories: &[Category],
presence: &[PresenceUser],
csrf_token: &str,
) -> Markup {
page(
&list.name,
Some(user),
html! {
div class="list-topbar" {
a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" }
}
}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
div {
p class="eyebrow" { "SHARED LIST" }
h1 { (list.name) }
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
}
}
(list_content_fragment(list, items, categories, csrf_token, false))
}
aside class="side-column" {
(presence_panel(presence, false))
(categories_panel(list, categories, csrf_token, false))
section class="panel tip-panel" {
span class="tip-label" { "TIP" }
p { "Check items off as you go. Everyone viewing this list will see it instantly." }
}
}
}
div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", access.list.id)) {}
div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", list.id)) {}
},
)
}
@@ -289,7 +282,7 @@ fn item_groups<'a>(items: &'a [Item], categories: &[Category]) -> Vec<(String, V
for category in categories {
let items_in_category = items
.iter()
.filter(|item| item.category_id.as_deref() == Some(category.id.as_str()))
.filter(|item| item.category_id == Some(category.id))
.collect::<Vec<_>>();
if !items_in_category.is_empty() {
groups.push((category.name.clone(), items_in_category));
@@ -366,7 +359,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
input name="note" value=(item.note) maxlength="120";
label { "Category" }
select name="category_id" {
(category_options(categories, item.category_id.as_deref()))
(category_options(categories, item.category_id))
}
button class="button button-small button-secondary" type="submit" { "Save" }
}
@@ -384,7 +377,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
}
}
fn category_options(categories: &[Category], selected: Option<&str>) -> Markup {
fn category_options(categories: &[Category], selected: Option<i64>) -> Markup {
html! {
@if selected.is_none() {
option value="" selected { "No category" }
@@ -392,7 +385,7 @@ fn category_options(categories: &[Category], selected: Option<&str>) -> Markup {
option value="" { "No category" }
}
@for category in categories {
@if selected == Some(category.id.as_str()) {
@if selected == Some(category.id) {
option value=(category.id) selected { (category.name) }
} @else {
option value=(category.id) { (category.name) }
@@ -448,26 +441,26 @@ pub fn categories_panel(
}
pub fn live_list_fragments(
access: &ListAccess,
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
(list_content_fragment(&access.list, items, categories, csrf_token, true))
(categories_panel(&access.list, categories, csrf_token, true))
(list_content_fragment(list, items, categories, csrf_token, true))
(categories_panel(list, categories, csrf_token, true))
}
}
pub fn category_created(
access: &ListAccess,
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
p class="category-success" { "Category added." }
(live_list_fragments(access, items, categories, csrf_token))
(live_list_fragments(list, items, categories, csrf_token))
}
}
@@ -509,7 +502,6 @@ fn presence_content(presence: &[PresenceUser]) -> Markup {
}
pub fn invite_page(
info: &InvitationInfo,
user: Option<&User>,
token: &str,
error: Option<&str>,
@@ -521,8 +513,8 @@ pub fn invite_page(
html! {
div class="auth-card invite-card" {
p class="eyebrow" { "YOU'RE INVITED" }
h1 { "Join " (info.list_name) }
p class="lede" { "Shop together and keep the list in sync." }
h1 { "Join the shared lists" }
p class="lede" { "Shop together and keep all the lists in sync." }
@if let Some(error) = error {
div class="alert alert-error" role="alert" { (error) }
}
@@ -532,14 +524,15 @@ pub fn invite_page(
@if let Some(csrf_token) = csrf_token {
input type="hidden" name="csrf" value=(csrf_token);
}
button class="button button-primary" type="submit" { "Join list" }
button class="button button-primary" type="submit" { "Join lists" }
}
p class="muted" { "Want to join as a different person? Create a new account below." }
} @else {
p { "Sign in or create an account to accept this invite." }
div class="invite-actions" {
a class="button button-primary" href=(format!("/login?invite={}", token)) { "Sign in" }
a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" }
}
div class="invite-actions" {
a class="button button-primary" href=(format!("/register?invite={}", token)) { "Create account" }
a class="button button-secondary" href=(format!("/login?invite={}", token)) { "Sign in" }
}
}
},