Compare commits
3
Commits
65886903cd
...
5e57c61e4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e57c61e4b | ||
|
|
2db105bd6f | ||
|
|
5da5fee942 |
@@ -2,3 +2,4 @@
|
|||||||
/sustenance.db
|
/sustenance.db
|
||||||
/sustenance.db*
|
/sustenance.db*
|
||||||
/.env
|
/.env
|
||||||
|
/seed.json
|
||||||
|
|||||||
Generated
+2
-98
@@ -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
@@ -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"] }
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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| ¤t.session.user),
|
user.as_ref().map(|current| ¤t.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
@@ -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
@@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user