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