commit 65886903cdaf041779fa197d27c7eb782820f8a8 Author: Simon Bernier St-Pierre Date: Sat Aug 1 14:50:03 2026 -0400 initial vibe coded app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5804f27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/sustenance.db +/sustenance.db* +/.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ed92449 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1358 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sustenance" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "futures-util", + "hex", + "maud", + "rand 0.8.7", + "rusqlite", + "serde", + "sha2", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b559911 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sustenance" +version = "0.1.0" +edition = "2024" + +[dependencies] +argon2 = "0.5" +axum = { version = "0.8", features = ["ws"] } +futures-util = "0.3" +hex = "0.4" +maud = "0.27" +rand = "0.8" +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["fs", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc8632b --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Sustenance + +A small shared grocery list built with Rust, Axum, Maud, htmx, WebSockets, and SQLite. + +## Run locally + +```sh +cargo run +``` + +Open . The application creates `sustenance.db` in the working directory on first start. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `DATABASE_PATH` | `sustenance.db` | SQLite database path | +| `BIND_ADDRESS` | `127.0.0.1:3000` | Listen address | +| `PUBLIC_BASE_URL` | derived from `BIND_ADDRESS` | Base URL used in invitation links | +| `COOKIE_SECURE` | `false` | Add the `Secure` attribute to session cookies | +| `REGISTRATION_MODE` | `invite_only` | Use `open` for local development; otherwise registration requires a valid list invitation after the first account | +| `RUST_LOG` | `sustenance=debug,tower_http=info` | Log filter | + +## Current features + +- Email/password accounts with Argon2 password hashes +- Cookie-backed sessions and CSRF tokens for list mutations +- Shared lists with one-time, seven-day invitation links +- Invite-only registration by default after the first account +- Add, edit, check, and delete grocery items +- List-scoped categories with common defaults and custom category creation +- Items grouped by category and assigned from the add/edit forms +- Server-authoritative last-write-wins updates +- Per-list WebSocket updates with server-rendered htmx fragments +- In-memory presence for members currently viewing a list +- Responsive layout for phone, tablet, and desktop + +The htmx scripts are currently loaded from unpkg. They can be vendored into `static/` before production deployment. + +## Verification + +```sh +cargo fmt --all -- --check +cargo check +cargo test +``` diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..ad24cf0 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,967 @@ +use std::{ + path::Path, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use rand::{RngCore, rngs::OsRng}; +use rusqlite::{Connection, OptionalExtension, params}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum DbError { + #[error("database error: {0}")] + Message(String), + #[error("record not found")] + NotFound, + #[error("record already exists")] + Conflict, + #[error("database worker failed: {0}")] + Worker(String), +} + +pub type DbResult = Result; + +#[derive(Clone)] +pub struct Database { + connection: Arc>, +} + +#[derive(Clone, Debug)] +pub struct User { + pub id: String, + pub email: String, + pub display_name: String, +} + +#[derive(Clone, Debug)] +pub struct SessionUser { + pub user: User, + pub csrf_token: String, +} + +#[derive(Clone, Debug)] +pub struct GroceryList { + pub id: String, + pub name: String, + pub owner_id: String, + pub revision: i64, +} + +#[derive(Clone, Debug)] +pub struct ListAccess { + pub list: GroceryList, + pub role: String, +} + +#[derive(Clone, Debug)] +pub struct ListSummary { + pub list: GroceryList, + pub role: String, +} + +#[derive(Clone, Debug)] +pub struct Item { + pub id: String, + pub list_id: String, + pub name: String, + pub quantity: String, + pub note: String, + pub category_id: Option, + pub checked: bool, + pub version: i64, +} + +#[derive(Clone, Debug)] +pub struct Category { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct InvitationInfo { + pub list_name: String, +} + +impl Database { + pub fn open(path: impl AsRef) -> DbResult { + let connection = Connection::open(path).map_err(sql_error)?; + configure(&connection)?; + migrate(&connection)?; + + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + #[cfg(test)] + pub fn open_in_memory() -> DbResult { + Self::open(":memory:") + } + + async fn call(&self, operation: F) -> DbResult + where + T: Send + 'static, + F: FnOnce(&mut Connection) -> DbResult + Send + 'static, + { + let connection = Arc::clone(&self.connection); + tokio::task::spawn_blocking(move || { + let mut connection = connection + .lock() + .map_err(|error| DbError::Worker(error.to_string()))?; + operation(&mut connection) + }) + .await + .map_err(|error| DbError::Worker(error.to_string()))? + } + + pub async fn create_user( + &self, + email: String, + display_name: String, + password_hash: String, + ) -> DbResult { + self.call(move |connection| { + let user = User { + id: Uuid::new_v4().to_string(), + email, + display_name, + }; + + let result = connection.execute( + "INSERT INTO users (id, email, display_name, password_hash, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![user.id, user.email, user.display_name, password_hash, now()], + ); + + match result { + Ok(_) => Ok(user), + Err(error) if error.to_string().contains("UNIQUE") => Err(DbError::Conflict), + Err(error) => Err(sql_error(error)), + } + }) + .await + } + + pub async fn find_user_by_email(&self, email: String) -> DbResult> { + self.call(move |connection| { + connection + .query_row( + "SELECT id, email, display_name, password_hash + FROM users WHERE email = ?1 COLLATE NOCASE", + params![email], + |row| { + Ok(( + User { + id: row.get(0)?, + email: row.get(1)?, + display_name: row.get(2)?, + }, + row.get(3)?, + )) + }, + ) + .optional() + .map_err(sql_error) + }) + .await + } + + pub async fn has_users(&self) -> DbResult { + self.call(|connection| { + connection + .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |row| { + Ok(row.get::<_, i64>(0)? != 0) + }) + .map_err(sql_error) + }) + .await + } + + pub async fn create_session(&self, user_id: String) -> DbResult<(String, String)> { + self.call(move |connection| { + let session_token = new_secret(); + let csrf_token = new_secret(); + connection + .execute( + "INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + hash_secret(&session_token), + user_id, + csrf_token, + now() + 60 * 60 * 24 * 30 + ], + ) + .map_err(sql_error)?; + Ok((session_token, csrf_token)) + }) + .await + } + + pub async fn session_user(&self, session_token: String) -> DbResult> { + self.call(move |connection| { + connection + .query_row( + "SELECT u.id, u.email, u.display_name, s.csrf_token + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?1 AND s.expires_at > ?2", + params![hash_secret(&session_token), now()], + |row| { + Ok(SessionUser { + user: User { + id: row.get(0)?, + email: row.get(1)?, + display_name: row.get(2)?, + }, + csrf_token: row.get(3)?, + }) + }, + ) + .optional() + .map_err(sql_error) + }) + .await + } + + pub async fn delete_session(&self, session_token: String) -> DbResult<()> { + self.call(move |connection| { + connection + .execute( + "DELETE FROM sessions WHERE token_hash = ?1", + params![hash_secret(&session_token)], + ) + .map_err(sql_error)?; + Ok(()) + }) + .await + } + + pub async fn list_summaries(&self, user_id: String) -> DbResult> { + self.call(move |connection| { + let mut statement = connection + .prepare( + "SELECT l.id, l.name, l.owner_id, l.revision, m.role + FROM lists l + JOIN list_members m ON m.list_id = l.id + WHERE m.user_id = ?1 + ORDER BY l.created_at DESC", + ) + .map_err(sql_error)?; + let rows = statement + .query_map(params![user_id], |row| { + Ok(ListSummary { + list: GroceryList { + id: row.get(0)?, + name: row.get(1)?, + owner_id: row.get(2)?, + revision: row.get(3)?, + }, + role: row.get(4)?, + }) + }) + .map_err(sql_error)?; + + rows.collect::, _>>().map_err(sql_error) + }) + .await + } + + pub async fn create_list(&self, owner_id: String, name: String) -> DbResult { + self.call(move |connection| { + let list = GroceryList { + id: Uuid::new_v4().to_string(), + name, + owner_id: owner_id.clone(), + revision: 0, + }; + let transaction = connection.transaction().map_err(sql_error)?; + transaction + .execute( + "INSERT INTO lists (id, name, owner_id, revision, created_at) + VALUES (?1, ?2, ?3, 0, ?4)", + params![list.id, list.name, owner_id, now()], + ) + .map_err(sql_error)?; + transaction + .execute( + "INSERT INTO list_members (list_id, user_id, role) + VALUES (?1, ?2, 'owner')", + params![list.id, list.owner_id], + ) + .map_err(sql_error)?; + for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() { + transaction + .execute( + "INSERT INTO categories (id, list_id, name, position, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + Uuid::new_v4().to_string(), + list.id, + category_name, + position as i64, + now() + ], + ) + .map_err(sql_error)?; + } + transaction.commit().map_err(sql_error)?; + Ok(list) + }) + .await + } + + pub async fn list_access( + &self, + list_id: String, + user_id: String, + ) -> DbResult> { + self.call(move |connection| { + connection + .query_row( + "SELECT l.id, l.name, l.owner_id, l.revision, m.role + FROM lists l + JOIN list_members m ON m.list_id = l.id + WHERE l.id = ?1 AND m.user_id = ?2", + params![list_id, user_id], + |row| { + Ok(ListAccess { + list: GroceryList { + id: row.get(0)?, + name: row.get(1)?, + owner_id: row.get(2)?, + revision: row.get(3)?, + }, + role: row.get(4)?, + }) + }, + ) + .optional() + .map_err(sql_error) + }) + .await + } + + pub async fn items(&self, list_id: String) -> DbResult> { + self.call(move |connection| { + let mut statement = connection + .prepare( + "SELECT id, list_id, name, quantity, note, category_id, checked, version + FROM items + WHERE list_id = ?1 + ORDER BY position ASC, created_at ASC", + ) + .map_err(sql_error)?; + let rows = statement + .query_map(params![list_id], |row| { + Ok(Item { + id: row.get(0)?, + list_id: row.get(1)?, + name: row.get(2)?, + quantity: row.get(3)?, + note: row.get(4)?, + category_id: row.get(5)?, + checked: row.get::<_, i64>(6)? != 0, + version: row.get(7)?, + }) + }) + .map_err(sql_error)?; + rows.collect::, _>>().map_err(sql_error) + }) + .await + } + + pub async fn categories(&self, list_id: String) -> DbResult> { + self.call(move |connection| { + let mut statement = connection + .prepare( + "SELECT id, name + FROM categories + WHERE list_id = ?1 + ORDER BY position ASC, name COLLATE NOCASE ASC", + ) + .map_err(sql_error)?; + let rows = statement + .query_map(params![list_id], |row| { + Ok(Category { + id: row.get(0)?, + name: row.get(1)?, + }) + }) + .map_err(sql_error)?; + rows.collect::, _>>().map_err(sql_error) + }) + .await + } + + pub async fn create_category(&self, list_id: String, name: String) -> DbResult { + self.call(move |connection| { + let transaction = connection.transaction().map_err(sql_error)?; + let position: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(position), -1) + 1 + FROM categories WHERE list_id = ?1", + params![list_id], + |row| row.get(0), + ) + .map_err(sql_error)?; + let result = transaction.execute( + "INSERT INTO categories (id, list_id, name, position, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![Uuid::new_v4().to_string(), list_id, name, position, now()], + ); + match result { + Ok(_) => {} + Err(error) if error.to_string().contains("UNIQUE") => { + return Err(DbError::Conflict); + } + Err(error) => return Err(sql_error(error)), + } + let revision = bump_revision(&transaction, &list_id)?; + transaction.commit().map_err(sql_error)?; + Ok(revision) + }) + .await + } + + pub async fn add_item( + &self, + list_id: String, + name: String, + quantity: String, + note: String, + category_id: Option, + ) -> DbResult { + self.call(move |connection| { + let transaction = connection.transaction().map_err(sql_error)?; + let category_id = category_id.filter(|category_id| !category_id.is_empty()); + ensure_category(&transaction, &list_id, category_id.as_deref())?; + let position: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1", + params![list_id], + |row| row.get(0), + ) + .map_err(sql_error)?; + transaction + .execute( + "INSERT INTO items + (id, list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 1, ?7, ?8, ?8)", + params![ + Uuid::new_v4().to_string(), + list_id, + name, + quantity, + note, + category_id, + position, + now() + ], + ) + .map_err(sql_error)?; + let revision = bump_revision(&transaction, &list_id)?; + transaction.commit().map_err(sql_error)?; + Ok(revision) + }) + .await + } + + pub async fn set_item_checked( + &self, + list_id: String, + item_id: String, + checked: bool, + ) -> DbResult { + self.call(move |connection| { + let transaction = connection.transaction().map_err(sql_error)?; + let changed = transaction + .execute( + "UPDATE items + SET checked = ?1, version = version + 1, updated_at = ?2 + WHERE id = ?3 AND list_id = ?4", + params![checked as i64, now(), item_id, list_id], + ) + .map_err(sql_error)?; + if changed == 0 { + return Err(DbError::NotFound); + } + let revision = bump_revision(&transaction, &list_id)?; + transaction.commit().map_err(sql_error)?; + Ok(revision) + }) + .await + } + + pub async fn update_item( + &self, + list_id: String, + item_id: String, + name: String, + quantity: String, + note: String, + category_id: Option, + ) -> DbResult { + self.call(move |connection| { + let transaction = connection.transaction().map_err(sql_error)?; + let category_id = category_id.filter(|category_id| !category_id.is_empty()); + ensure_category(&transaction, &list_id, category_id.as_deref())?; + let changed = transaction + .execute( + "UPDATE items + SET name = ?1, quantity = ?2, note = ?3, category_id = ?4, + version = version + 1, updated_at = ?5 + WHERE id = ?6 AND list_id = ?7", + params![name, quantity, note, category_id, now(), item_id, list_id], + ) + .map_err(sql_error)?; + if changed == 0 { + return Err(DbError::NotFound); + } + let revision = bump_revision(&transaction, &list_id)?; + transaction.commit().map_err(sql_error)?; + Ok(revision) + }) + .await + } + + pub async fn delete_item(&self, list_id: String, item_id: String) -> DbResult { + self.call(move |connection| { + let transaction = connection.transaction().map_err(sql_error)?; + let changed = transaction + .execute( + "DELETE FROM items WHERE id = ?1 AND list_id = ?2", + params![item_id, list_id], + ) + .map_err(sql_error)?; + if changed == 0 { + return Err(DbError::NotFound); + } + let revision = bump_revision(&transaction, &list_id)?; + transaction.commit().map_err(sql_error)?; + Ok(revision) + }) + .await + } + + pub async fn create_invitation( + &self, + list_id: String, + created_by: String, + token: String, + ) -> DbResult { + self.call(move |connection| { + let expires_at = now() + 60 * 60 * 24 * 7; + connection + .execute( + "INSERT INTO invitations (token_hash, list_id, created_by, expires_at) + VALUES (?1, ?2, ?3, ?4)", + params![hash_secret(&token), list_id, created_by, expires_at], + ) + .map_err(sql_error)?; + Ok(expires_at) + }) + .await + } + + pub async fn invitation(&self, token: String) -> DbResult> { + self.call(move |connection| { + connection + .query_row( + "SELECT l.name + FROM invitations i + JOIN lists l ON l.id = i.list_id + WHERE i.token_hash = ?1 AND i.expires_at > ?2", + params![hash_secret(&token), now()], + |row| { + Ok(InvitationInfo { + list_name: row.get(0)?, + }) + }, + ) + .optional() + .map_err(sql_error) + }) + .await + } + + pub async fn accept_invitation(&self, token: String, user_id: String) -> DbResult { + self.call(move |connection| { + let transaction = connection.transaction().map_err(sql_error)?; + let invitation = transaction + .query_row( + "SELECT list_id FROM invitations + WHERE token_hash = ?1 AND expires_at > ?2", + params![hash_secret(&token), now()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(sql_error)? + .ok_or(DbError::NotFound)?; + transaction + .execute( + "INSERT OR IGNORE INTO list_members (list_id, user_id, role) + VALUES (?1, ?2, 'member')", + params![invitation, user_id], + ) + .map_err(sql_error)?; + transaction + .execute( + "DELETE FROM invitations WHERE token_hash = ?1", + params![hash_secret(&token)], + ) + .map_err(sql_error)?; + transaction.commit().map_err(sql_error)?; + Ok(invitation) + }) + .await + } +} + +fn configure(connection: &Connection) -> DbResult<()> { + connection + .pragma_update(None, "foreign_keys", true) + .map_err(sql_error)?; + connection + .pragma_update(None, "journal_mode", "WAL") + .map_err(sql_error)?; + connection + .busy_timeout(std::time::Duration::from_secs(5)) + .map_err(sql_error)?; + Ok(()) +} + +fn migrate(connection: &Connection) -> DbResult<()> { + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + revision INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS list_members ( + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + PRIMARY KEY (list_id, user_id) + ); + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + name TEXT NOT NULL COLLATE NOCASE, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + UNIQUE (list_id, name) + ); + CREATE TABLE IF NOT EXISTS invitations ( + token_hash TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + name TEXT NOT NULL, + quantity TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + category_id TEXT REFERENCES categories(id) ON DELETE SET NULL, + checked INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id); + CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id); + CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id); + CREATE INDEX IF NOT EXISTS list_members_user_idx ON list_members(user_id);", + ) + .map_err(sql_error)?; + + if !has_column(connection, "items", "category_id")? { + connection + .execute( + "ALTER TABLE items ADD COLUMN category_id TEXT REFERENCES categories(id) ON DELETE SET NULL", + [], + ) + .map_err(sql_error)?; + } + Ok(()) +} + +fn has_column(connection: &Connection, table: &str, wanted: &str) -> DbResult { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(sql_error)?; + let mut rows = statement.query([]).map_err(sql_error)?; + while let Some(row) = rows.next().map_err(sql_error)? { + if row.get::<_, String>(1).map_err(sql_error)? == wanted { + return Ok(true); + } + } + Ok(false) +} + +fn ensure_category( + transaction: &rusqlite::Transaction<'_>, + list_id: &str, + category_id: Option<&str>, +) -> DbResult<()> { + let Some(category_id) = category_id else { + return Ok(()); + }; + let exists = transaction + .query_row( + "SELECT 1 FROM categories WHERE id = ?1 AND list_id = ?2", + params![category_id, list_id], + |_| Ok(()), + ) + .optional() + .map_err(sql_error)?; + if exists.is_none() { + return Err(DbError::NotFound); + } + Ok(()) +} + +const DEFAULT_CATEGORIES: &[&str] = &[ + "Produce", + "Meat & seafood", + "Dairy & eggs", + "Pantry", + "Frozen", + "Household", +]; + +fn bump_revision(transaction: &rusqlite::Transaction<'_>, list_id: &str) -> DbResult { + transaction + .execute( + "UPDATE lists SET revision = revision + 1 WHERE id = ?1", + params![list_id], + ) + .map_err(sql_error)?; + transaction + .query_row( + "SELECT revision FROM lists WHERE id = ?1", + params![list_id], + |row| row.get(0), + ) + .map_err(sql_error) +} + +fn sql_error(error: impl std::fmt::Display) -> DbError { + DbError::Message(error.to_string()) +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +pub fn new_secret() -> String { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +pub fn hash_secret(secret: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn creates_a_list_and_item() { + let database = Database::open_in_memory().unwrap(); + let user = database + .create_user("test@example.com".into(), "Test User".into(), "hash".into()) + .await + .unwrap(); + let list = database + .create_list(user.id.clone(), "Weekly shop".into()) + .await + .unwrap(); + database + .add_item( + list.id.clone(), + "Milk".into(), + "2 litres".into(), + String::new(), + None, + ) + .await + .unwrap(); + + let items = database.items(list.id).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "Milk"); + } + + #[tokio::test] + async fn sessions_and_invitations_are_scoped_to_users() { + let database = Database::open_in_memory().unwrap(); + let owner = database + .create_user("owner@example.com".into(), "Owner".into(), "hash".into()) + .await + .unwrap(); + let member = database + .create_user("member@example.com".into(), "Member".into(), "hash".into()) + .await + .unwrap(); + let list = database + .create_list(owner.id.clone(), "Household".into()) + .await + .unwrap(); + assert_eq!(database.categories(list.id.clone()).await.unwrap().len(), 6); + let (session_token, csrf_token) = database.create_session(owner.id.clone()).await.unwrap(); + + let session = database + .session_user(session_token.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(session.user.id, owner.id); + assert_eq!(session.csrf_token, csrf_token); + + let invitation_token = "test-invitation".to_owned(); + database + .create_invitation(list.id.clone(), owner.id, invitation_token.clone()) + .await + .unwrap(); + assert_eq!( + database + .invitation(invitation_token.clone()) + .await + .unwrap() + .unwrap() + .list_name, + "Household" + ); + + let accepted_list = database + .accept_invitation(invitation_token.clone(), member.id.clone()) + .await + .unwrap(); + assert_eq!(accepted_list, list.id); + assert!( + database + .invitation(invitation_token) + .await + .unwrap() + .is_none() + ); + assert_eq!( + database + .list_access(list.id, member.id) + .await + .unwrap() + .unwrap() + .role, + "member" + ); + } + + #[tokio::test] + async fn checked_state_is_set_not_toggled() { + let database = Database::open_in_memory().unwrap(); + let user = database + .create_user("check@example.com".into(), "Checker".into(), "hash".into()) + .await + .unwrap(); + let list = database.create_list(user.id, "List".into()).await.unwrap(); + database + .add_item( + list.id.clone(), + "Coffee".into(), + String::new(), + String::new(), + None, + ) + .await + .unwrap(); + let item = database.items(list.id.clone()).await.unwrap().remove(0); + + database + .set_item_checked(list.id.clone(), item.id.clone(), true) + .await + .unwrap(); + database + .set_item_checked(list.id.clone(), item.id.clone(), true) + .await + .unwrap(); + + let item = database.items(list.id).await.unwrap().remove(0); + assert!(item.checked); + assert_eq!(item.version, 3); + } + + #[tokio::test] + async fn checking_an_item_does_not_change_list_order() { + let database = Database::open_in_memory().unwrap(); + let user = database + .create_user( + "order@example.com".into(), + "Order Tester".into(), + "hash".into(), + ) + .await + .unwrap(); + let list = database.create_list(user.id, "List".into()).await.unwrap(); + database + .add_item( + list.id.clone(), + "First".into(), + String::new(), + String::new(), + None, + ) + .await + .unwrap(); + database + .add_item( + list.id.clone(), + "Second".into(), + String::new(), + String::new(), + None, + ) + .await + .unwrap(); + let first_item = database.items(list.id.clone()).await.unwrap().remove(0); + + database + .set_item_checked(list.id.clone(), first_item.id, true) + .await + .unwrap(); + + let items = database.items(list.id).await.unwrap(); + assert_eq!(items[0].name, "First"); + assert!(items[0].checked); + assert_eq!(items[1].name, "Second"); + } +} diff --git a/src/hub.rs b/src/hub.rs new file mode 100644 index 0000000..ef0cc11 --- /dev/null +++ b/src/hub.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::{Mutex, broadcast}; + +#[derive(Clone, Debug)] +pub struct PresenceUser { + pub user_id: String, + pub display_name: String, +} + +#[derive(Clone, Debug)] +pub enum HubEvent { + ListChanged { list_id: String, revision: i64 }, + PresenceChanged { list_id: String }, +} + +#[derive(Debug)] +struct ConnectionInfo { + user_id: String, + display_name: String, +} + +struct Room { + sender: broadcast::Sender, + connections: HashMap, +} + +pub struct Subscription { + pub connection_id: String, + pub receiver: broadcast::Receiver, + pub presence: Vec, +} + +#[derive(Clone, Default)] +pub struct Hub { + rooms: Arc>>, +} + +impl Hub { + pub async fn join( + &self, + list_id: String, + user_id: String, + display_name: String, + ) -> Subscription { + let mut rooms = self.rooms.lock().await; + let room = rooms.entry(list_id.clone()).or_insert_with(|| { + let (sender, _) = broadcast::channel(64); + Room { + sender, + connections: HashMap::new(), + } + }); + + let connection_id = crate::db::new_secret(); + let already_present = room + .connections + .values() + .any(|connection| connection.user_id == user_id); + room.connections.insert( + connection_id.clone(), + ConnectionInfo { + user_id, + display_name, + }, + ); + let presence = current_presence(room); + + if !already_present { + let _ = room.sender.send(HubEvent::PresenceChanged { list_id }); + } + let receiver = room.sender.subscribe(); + + Subscription { + connection_id, + receiver, + presence, + } + } + + pub async fn leave(&self, list_id: &str, connection_id: &str) { + let mut rooms = self.rooms.lock().await; + let mut remove_room = false; + if let Some(room) = rooms.get_mut(list_id) { + let removed = room.connections.remove(connection_id); + if let Some(removed) = removed { + let still_present = room + .connections + .values() + .any(|connection| connection.user_id == removed.user_id); + if !still_present { + let _ = room.sender.send(HubEvent::PresenceChanged { + list_id: list_id.to_owned(), + }); + } + } + remove_room = room.connections.is_empty(); + } + if remove_room { + rooms.remove(list_id); + } + } + + pub async fn publish_list_changed(&self, list_id: String, revision: i64) { + let rooms = self.rooms.lock().await; + if let Some(room) = rooms.get(&list_id) { + let _ = room + .sender + .send(HubEvent::ListChanged { list_id, revision }); + } + } + + pub async fn presence(&self, list_id: &str) -> Vec { + let rooms = self.rooms.lock().await; + rooms.get(list_id).map(current_presence).unwrap_or_default() + } +} + +fn current_presence(room: &Room) -> Vec { + let mut users = HashMap::::new(); + for connection in room.connections.values() { + users + .entry(connection.user_id.clone()) + .or_insert_with(|| PresenceUser { + user_id: connection.user_id.clone(), + display_name: connection.display_name.clone(), + }); + } + let mut users = users.into_values().collect::>(); + users.sort_by_key(|user| user.display_name.to_lowercase()); + users +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..52d5110 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,937 @@ +mod db; +mod hub; +mod views; + +use std::time::Duration; +use std::{env, future::Future, sync::Arc}; + +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; +use axum::{ + Router, + extract::{ + Form, FromRequest, FromRequestParts, Path, Query, Request, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + http::{HeaderMap, HeaderValue, StatusCode, header, request::Parts}, + middleware::{self, Next}, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, de::DeserializeOwned}; +use thiserror::Error; +use tokio::net::TcpListener; +use tower_http::{services::ServeDir, trace::TraceLayer}; +use tracing::{error, info, warn}; + +use crate::{ + db::{Database, DbError, SessionUser}, + hub::{Hub, HubEvent}, +}; + +#[derive(Clone)] +struct AppState { + db: Database, + hub: Arc, + cookie_secure: bool, + public_base_url: String, + registration_mode: RegistrationMode, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum RegistrationMode { + Open, + InviteOnly, +} + +#[derive(Debug, Error)] +enum AppError { + #[error("database error")] + Database(#[from] DbError), + #[error("bad request: {0}")] + BadRequest(String), + #[error("not found")] + NotFound, + #[error("internal error: {0}")] + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, heading, message): (StatusCode, &str, String) = match self { + Self::Database(DbError::NotFound) | Self::NotFound => ( + StatusCode::NOT_FOUND, + "Not found", + "We could not find that page or list.".into(), + ), + Self::Database(DbError::Conflict) => ( + StatusCode::CONFLICT, + "Already exists", + "That value is already in use.".into(), + ), + Self::BadRequest(message) => { + warn!(reason = %message, "request rejected"); + (StatusCode::BAD_REQUEST, "Check that again", message) + } + Self::Database(error) => { + error!(%error, "database request failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "The request could not be completed.".into(), + ) + } + Self::Internal(error) => { + error!(%error, "request failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "The request could not be completed.".into(), + ) + } + }; + + ( + status, + Html(views::error_page(heading, &message).into_string()), + ) + .into_response() + } +} + +#[derive(Clone, Debug)] +struct CurrentUser { + session_token: String, + session: SessionUser, +} + +impl FromRequestParts for CurrentUser { + type Rejection = Response; + + fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> impl Future> + Send { + let session_token = cookie_value(&parts.headers, "session"); + async move { + let Some(session_token) = session_token else { + return Err(Redirect::to("/login").into_response()); + }; + + match state.db.session_user(session_token.clone()).await { + Ok(Some(session)) => Ok(Self { + session_token, + session, + }), + Ok(None) => Err(Redirect::to("/login").into_response()), + Err(error) => Err(AppError::Database(error).into_response()), + } + } + } +} + +struct LoggedForm(T); + +impl FromRequest for LoggedForm +where + S: Send + Sync, + T: DeserializeOwned + Send, +{ + type Rejection = Response; + + fn from_request( + request: Request, + state: &S, + ) -> impl Future> + Send { + let method = request.method().clone(); + let uri = request.uri().clone(); + async move { + match Form::::from_request(request, state).await { + Ok(Form(value)) => Ok(Self(value)), + Err(rejection) => { + warn!( + %method, + %uri, + rejection = ?rejection, + "request form deserialization failed" + ); + Err(rejection.into_response()) + } + } + } + } +} + +#[derive(Debug, Deserialize)] +struct InviteQuery { + invite: Option, +} + +#[derive(Debug, Deserialize)] +struct RegisterForm { + display_name: String, + email: String, + password: String, + invite: Option, +} + +#[derive(Debug, Deserialize)] +struct LoginForm { + email: String, + password: String, + invite: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateListForm { + name: String, + csrf: String, +} + +#[derive(Debug, Deserialize)] +struct ItemForm { + name: String, + quantity: String, + #[serde(default)] + note: String, + #[serde(default)] + category_id: Option, + csrf: String, +} + +#[derive(Debug, Deserialize)] +struct CheckForm { + checked: String, + csrf: String, +} + +#[derive(Debug, Deserialize)] +struct CsrfForm { + csrf: String, +} + +#[derive(Debug, Deserialize)] +struct CategoryForm { + name: String, + csrf: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + env::var("RUST_LOG").unwrap_or_else(|_| "sustenance=debug,tower_http=info".into()), + ) + .init(); + + let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into()); + let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into()); + let public_base_url = + env::var("PUBLIC_BASE_URL").unwrap_or_else(|_| format!("http://{}", bind_address)); + let cookie_secure = env::var("COOKIE_SECURE") + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let registration_mode = match env::var("REGISTRATION_MODE") + .unwrap_or_else(|_| "invite_only".into()) + .to_ascii_lowercase() + .as_str() + { + "open" => RegistrationMode::Open, + "invite_only" | "invite-only" => RegistrationMode::InviteOnly, + value => { + warn!(value, "unknown REGISTRATION_MODE; using invite_only"); + RegistrationMode::InviteOnly + } + }; + + let state = AppState { + db: Database::open(database_path)?, + hub: Arc::new(Hub::default()), + cookie_secure, + public_base_url, + registration_mode, + }; + + let app = Router::new() + .route("/", get(home)) + .route("/login", get(login_page).post(login)) + .route("/register", get(register_page).post(register)) + .route("/logout", post(logout)) + .route("/lists", get(lists_page).post(create_list)) + .route("/lists/{list_id}", get(list_page)) + .route("/lists/{list_id}/items", post(add_item)) + .route("/lists/{list_id}/items/{item_id}/check", post(check_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}/categories", post(create_category)) + .route("/lists/{list_id}/invitations", post(create_invitation)) + .route("/lists/{list_id}/stream", get(list_stream)) + .route("/invite/{token}", get(invitation_page)) + .route("/invite/{token}/accept", post(accept_invitation)) + .nest_service("/static", ServeDir::new("static")) + .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn(log_response_status)) + .with_state(state); + + let listener = TcpListener::bind(&bind_address).await?; + info!(address = %bind_address, "sustenance listening"); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn home() -> Redirect { + Redirect::to("/lists") +} + +async fn log_response_status(request: Request, next: Next) -> Response { + let method = request.method().clone(); + let uri = request.uri().clone(); + let response = next.run(request).await; + let status = response.status(); + + if status.is_server_error() { + error!(%method, %uri, %status, "request returned server error"); + } else if status.is_client_error() { + warn!(%method, %uri, %status, "request returned client error"); + } + + response +} + +async fn login_page(Query(query): Query) -> Result { + Ok(html_response(views::login_page( + None, + query.invite.as_deref(), + ))) +} + +async fn register_page( + State(state): State, + Query(query): Query, +) -> Result { + if can_register(&state, query.invite.as_deref()).await? { + Ok(html_response(views::register_page( + None, + query.invite.as_deref(), + ))) + } else { + Ok(status_html_response( + StatusCode::FORBIDDEN, + views::registration_closed_page(), + )) + } +} + +async fn register( + State(state): State, + LoggedForm(form): LoggedForm, +) -> Result { + if !can_register(&state, form.invite.as_deref()).await? { + return Ok(status_html_response( + StatusCode::FORBIDDEN, + views::registration_closed_page(), + )); + } + let display_name = form.display_name.trim().to_owned(); + let email = form.email.trim().to_lowercase(); + if display_name.is_empty() || display_name.chars().count() > 50 { + return Ok(html_response(views::register_page( + Some("Enter a name between 1 and 50 characters."), + form.invite.as_deref(), + ))); + } + if !email.contains('@') || email.len() > 200 { + return Ok(html_response(views::register_page( + Some("Enter a valid email address."), + form.invite.as_deref(), + ))); + } + if form.password.len() < 8 { + return Ok(html_response(views::register_page( + Some("Use a password with at least 8 characters."), + form.invite.as_deref(), + ))); + } + + let password = form.password; + let password_hash = tokio::task::spawn_blocking(move || hash_password(&password)) + .await + .map_err(|error| AppError::Internal(error.to_string()))? + .map_err(AppError::Internal)?; + let user = match state + .db + .create_user(email, display_name, password_hash) + .await + { + Ok(user) => user, + Err(DbError::Conflict) => { + return Ok(html_response(views::register_page( + Some("An account with that email already exists."), + form.invite.as_deref(), + ))); + } + Err(error) => return Err(AppError::Database(error)), + }; + + let (session_token, _) = state.db.create_session(user.id).await?; + let destination = form + .invite + .filter(|invite| !invite.is_empty()) + .map(|invite| format!("/invite/{invite}")) + .unwrap_or_else(|| "/lists".into()); + let mut response = Redirect::to(&destination).into_response(); + set_session_cookie(&mut response, &session_token, state.cookie_secure); + Ok(response) +} + +async fn login( + State(state): State, + LoggedForm(form): LoggedForm, +) -> Result { + let email = form.email.trim().to_lowercase(); + let Some((user, password_hash)) = state.db.find_user_by_email(email).await? else { + return Ok(html_response(views::login_page( + Some("Email or password is incorrect."), + form.invite.as_deref(), + ))); + }; + + let password = form.password; + let valid = tokio::task::spawn_blocking(move || verify_password(&password, &password_hash)) + .await + .map_err(|error| AppError::Internal(error.to_string()))? + .map_err(AppError::Internal)?; + if !valid { + return Ok(html_response(views::login_page( + Some("Email or password is incorrect."), + form.invite.as_deref(), + ))); + } + + let (session_token, _) = state.db.create_session(user.id).await?; + let destination = form + .invite + .filter(|invite| !invite.is_empty()) + .map(|invite| format!("/invite/{invite}")) + .unwrap_or_else(|| "/lists".into()); + let mut response = Redirect::to(&destination).into_response(); + set_session_cookie(&mut response, &session_token, state.cookie_secure); + Ok(response) +} + +async fn logout(State(state): State, user: CurrentUser) -> Result { + state.db.delete_session(user.session_token).await?; + let mut response = Redirect::to("/login").into_response(); + clear_session_cookie(&mut response, state.cookie_secure); + Ok(response) +} + +async fn lists_page( + State(state): State, + user: CurrentUser, +) -> Result { + let lists = state + .db + .list_summaries(user.session.user.id.clone()) + .await?; + Ok(html_response(views::lists_page( + &user.session.user, + &lists, + &user.session.csrf_token, + ))) +} + +async fn create_list( + State(state): State, + user: CurrentUser, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + let name = form.name.trim().to_owned(); + if name.is_empty() || name.chars().count() > 80 { + return Err(AppError::BadRequest( + "List names must be between 1 and 80 characters.".into(), + )); + } + let list = state.db.create_list(user.session.user.id, name).await?; + Ok(Redirect::to(&format!("/lists/{}", list.id)).into_response()) +} + +async fn list_page( + State(state): State, + user: CurrentUser, + Path(list_id): Path, +) -> Result { + let access = require_access(&state, &user, &list_id).await?; + let items = state.db.items(list_id.clone()).await?; + let categories = state.db.categories(list_id.clone()).await?; + let presence = state.hub.presence(&list_id).await; + Ok(html_response(views::list_page( + &user.session.user, + &access, + &items, + &categories, + &presence, + &user.session.csrf_token, + ))) +} + +async fn add_item( + State(state): State, + user: CurrentUser, + Path(list_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_access(&state, &user, &list_id).await?; + let name = form.name.trim().to_owned(); + let quantity = form.quantity.trim().to_owned(); + let note = form.note.trim().to_owned(); + let category_id = normalize_category_id(form.category_id); + if name.is_empty() || name.chars().count() > 120 { + return Err(AppError::BadRequest( + "Item names must be between 1 and 120 characters.".into(), + )); + } + let revision = state + .db + .add_item(list_id.clone(), name, quantity, note, category_id) + .await?; + state + .hub + .publish_list_changed(list_id.clone(), revision) + .await; + list_fragment_response(&state, &user, &list_id).await +} + +async fn check_item( + State(state): State, + user: CurrentUser, + Path((list_id, item_id)): Path<(String, String)>, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_access(&state, &user, &list_id).await?; + let checked = match form.checked.as_str() { + "1" | "true" => true, + "0" | "false" => false, + _ => return Err(AppError::BadRequest("Invalid checked value.".into())), + }; + let revision = state + .db + .set_item_checked(list_id.clone(), item_id, checked) + .await?; + state + .hub + .publish_list_changed(list_id.clone(), revision) + .await; + list_fragment_response(&state, &user, &list_id).await +} + +async fn edit_item( + State(state): State, + user: CurrentUser, + Path((list_id, item_id)): Path<(String, String)>, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_access(&state, &user, &list_id).await?; + let name = form.name.trim().to_owned(); + if name.is_empty() || name.chars().count() > 120 { + return Err(AppError::BadRequest( + "Item names must be between 1 and 120 characters.".into(), + )); + } + let revision = state + .db + .update_item( + list_id.clone(), + item_id, + name, + form.quantity.trim().to_owned(), + form.note.trim().to_owned(), + normalize_category_id(form.category_id), + ) + .await?; + state + .hub + .publish_list_changed(list_id.clone(), revision) + .await; + list_fragment_response(&state, &user, &list_id).await +} + +async fn delete_item( + State(state): State, + user: CurrentUser, + Path((list_id, item_id)): Path<(String, String)>, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_access(&state, &user, &list_id).await?; + let revision = state.db.delete_item(list_id.clone(), item_id).await?; + state + .hub + .publish_list_changed(list_id.clone(), revision) + .await; + list_fragment_response(&state, &user, &list_id).await +} + +async fn create_category( + State(state): State, + user: CurrentUser, + Path(list_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_access(&state, &user, &list_id).await?; + let name = form.name.trim().to_owned(); + if name.is_empty() || name.chars().count() > 60 { + return Err(AppError::BadRequest( + "Category names must be between 1 and 60 characters.".into(), + )); + } + let revision = state.db.create_category(list_id.clone(), name).await?; + state + .hub + .publish_list_changed(list_id.clone(), revision) + .await; + + let access = require_access(&state, &user, &list_id).await?; + let items = state.db.items(list_id.clone()).await?; + let categories = state.db.categories(list_id).await?; + Ok(html_response(views::category_created( + &access, + &items, + &categories, + &user.session.csrf_token, + ))) +} + +async fn create_invitation( + State(state): State, + user: CurrentUser, + Path(list_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + let access = require_access(&state, &user, &list_id).await?; + if access.role != "owner" { + return Err(AppError::BadRequest( + "Only the list owner can create invitations.".into(), + )); + } + let token = db::new_secret(); + state + .db + .create_invitation(list_id, user.session.user.id, token.clone()) + .await?; + let url = format!( + "{}/invite/{token}", + state.public_base_url.trim_end_matches('/') + ); + Ok(html_response(views::invite_result(&url))) +} + +async fn invitation_page( + State(state): State, + Path(token): Path, + headers: HeaderMap, +) -> Result { + let info = state + .db + .invitation(token.clone()) + .await? + .ok_or(AppError::NotFound)?; + let user = optional_user(&state, &headers).await?; + Ok(html_response(views::invite_page( + &info, + user.as_ref().map(|current| ¤t.session.user), + &token, + None, + user.as_ref() + .map(|current| current.session.csrf_token.as_str()), + ))) +} + +async fn accept_invitation( + State(state): State, + user: CurrentUser, + Path(token): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + let list_id = state + .db + .accept_invitation(token, user.session.user.id) + .await?; + Ok(Redirect::to(&format!("/lists/{list_id}")).into_response()) +} + +async fn list_stream( + State(state): State, + user: CurrentUser, + Path(list_id): Path, + websocket: WebSocketUpgrade, +) -> Result { + require_access(&state, &user, &list_id).await?; + let state_for_socket = state.clone(); + let user_for_socket = user.clone(); + Ok(websocket + .on_upgrade(move |socket| handle_socket(state_for_socket, user_for_socket, list_id, socket)) + .into_response()) +} + +async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, socket: WebSocket) { + let subscription = state + .hub + .join( + list_id.clone(), + user.session.user.id.clone(), + user.session.user.display_name.clone(), + ) + .await; + let connection_id = subscription.connection_id.clone(); + let (mut sender, mut receiver) = socket.split(); + let mut heartbeat = tokio::time::interval(Duration::from_secs(30)); + heartbeat.tick().await; + + match websocket_snapshot(&state, &user, &list_id, &subscription.presence).await { + Ok(snapshot) => { + if sender.send(Message::Text(snapshot.into())).await.is_err() { + state.hub.leave(&list_id, &connection_id).await; + return; + } + } + Err(error) => { + error!(%error, "could not render websocket snapshot"); + state.hub.leave(&list_id, &connection_id).await; + return; + } + } + + let mut events = subscription.receiver; + loop { + tokio::select! { + event = events.recv() => { + match event { + Ok(HubEvent::ListChanged { list_id: event_list_id, revision }) if event_list_id == list_id => { + tracing::debug!(%list_id, revision, "list changed on websocket"); + match websocket_list_update(&state, &user, &list_id).await { + Ok(update) => { + if sender.send(Message::Text(update.into())).await.is_err() { + break; + } + } + Err(error) => { + error!(%error, "could not render websocket list update"); + break; + } + } + } + Ok(HubEvent::PresenceChanged { list_id: event_list_id }) if event_list_id == list_id => { + let presence = state.hub.presence(&list_id).await; + let update = views::presence_panel(&presence, true).into_string(); + if sender.send(Message::Text(update.into())).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + match websocket_snapshot(&state, &user, &list_id, &state.hub.presence(&list_id).await).await { + Ok(snapshot) => { + if sender.send(Message::Text(snapshot.into())).await.is_err() { + break; + } + } + Err(error) => { + error!(%error, "could not resync websocket"); + break; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Ok(_) => {} + } + } + _ = heartbeat.tick() => { + if sender.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + } + incoming = receiver.next() => { + match incoming { + Some(Ok(Message::Ping(payload))) => { + if sender.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(_)) => break, + } + } + } + } + + state.hub.leave(&list_id, &connection_id).await; +} + +async fn websocket_snapshot( + state: &AppState, + user: &CurrentUser, + list_id: &str, + presence: &[hub::PresenceUser], +) -> Result { + let access = require_access(state, user, list_id).await?; + let items = state.db.items(list_id.to_owned()).await?; + let categories = state.db.categories(list_id.to_owned()).await?; + Ok( + views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) + .into_string() + + &views::presence_panel(presence, true).into_string(), + ) +} + +async fn websocket_list_update( + state: &AppState, + user: &CurrentUser, + list_id: &str, +) -> Result { + let access = require_access(state, user, list_id).await?; + let items = state.db.items(list_id.to_owned()).await?; + let categories = state.db.categories(list_id.to_owned()).await?; + Ok( + views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) + .into_string(), + ) +} + +async fn list_fragment_response( + state: &AppState, + user: &CurrentUser, + list_id: &str, +) -> Result { + let access = require_access(state, user, list_id).await?; + let items = state.db.items(list_id.to_owned()).await?; + let categories = state.db.categories(list_id.to_owned()).await?; + Ok(html_response(views::list_items_fragment( + &access.list, + &items, + &categories, + &user.session.csrf_token, + false, + ))) +} + +async fn require_access( + state: &AppState, + user: &CurrentUser, + list_id: &str, +) -> Result { + state + .db + .list_access(list_id.to_owned(), user.session.user.id.clone()) + .await? + .ok_or(AppError::NotFound) +} + +async fn optional_user( + state: &AppState, + headers: &HeaderMap, +) -> Result, AppError> { + let Some(session_token) = cookie_value(headers, "session") else { + return Ok(None); + }; + Ok(state + .db + .session_user(session_token.clone()) + .await? + .map(|session| CurrentUser { + session_token, + session, + })) +} + +fn verify_csrf(user: &CurrentUser, token: &str) -> Result<(), AppError> { + if token.is_empty() || token != user.session.csrf_token { + return Err(AppError::BadRequest( + "Your form has expired. Refresh and try again.".into(), + )); + } + Ok(()) +} + +fn normalize_category_id(category_id: Option) -> Option { + category_id.filter(|category_id| !category_id.trim().is_empty()) +} + +async fn can_register(state: &AppState, invite: Option<&str>) -> Result { + if state.registration_mode == RegistrationMode::Open { + return Ok(true); + } + if !state.db.has_users().await? { + return Ok(true); + } + let Some(invite) = invite.filter(|invite| !invite.is_empty()) else { + return Ok(false); + }; + Ok(state.db.invitation(invite.to_owned()).await?.is_some()) +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| error.to_string()) +} + +fn verify_password(password: &str, encoded_hash: &str) -> Result { + let hash = PasswordHash::new(encoded_hash).map_err(|error| error.to_string())?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok()) +} + +fn html_response(markup: maud::Markup) -> Response { + Html(markup.into_string()).into_response() +} + +fn status_html_response(status: StatusCode, markup: maud::Markup) -> Response { + (status, Html(markup.into_string())).into_response() +} + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .map(str::trim) + .find_map(|cookie| { + let (key, value) = cookie.split_once('=')?; + (key == name).then(|| value.to_owned()) + }) +} + +fn set_session_cookie(response: &mut Response, token: &str, secure: bool) { + let secure_attribute = if secure { "; Secure" } else { "" }; + let cookie = format!( + "session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure_attribute}" + ); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).expect("session cookie is valid"), + ); +} + +fn clear_session_cookie(response: &mut Response, secure: bool) { + let secure_attribute = if secure { "; Secure" } else { "" }; + let cookie = format!("session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_attribute}"); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).expect("session cookie is valid"), + ); +} diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..8f0bdbd --- /dev/null +++ b/src/views.rs @@ -0,0 +1,610 @@ +use maud::{DOCTYPE, Markup, html}; + +use crate::{ + db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User}, + hub::PresenceUser, +}; + +pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { + page( + "Sign in", + None, + html! { + div class="auth-card" { + p class="eyebrow" { "SUSTENANCE" } + h1 { "Welcome back" } + p class="lede" { "Keep the household running, one item at a time." } + @if let Some(error) = error { + div class="alert alert-error" role="alert" { (error) } + } + form method="post" action="/login" class="stack" { + @if let Some(invite) = invite { + input type="hidden" name="invite" value=(invite); + } + label for="email" { "Email" } + input id="email" name="email" type="email" autocomplete="email" required autofocus; + label for="password" { "Password" } + input id="password" name="password" type="password" autocomplete="current-password" required; + button class="button button-primary" type="submit" { "Sign in" } + } + p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } } + } + }, + ) +} + +pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup { + page( + "Create account", + None, + html! { + div class="auth-card" { + p class="eyebrow" { "SUSTENANCE" } + h1 { "Make a shared list" } + p class="lede" { "A simple grocery list for the people you shop with." } + @if let Some(error) = error { + div class="alert alert-error" role="alert" { (error) } + } + form method="post" action="/register" class="stack" { + @if let Some(invite) = invite { + input type="hidden" name="invite" value=(invite); + } + label for="display-name" { "Your name" } + input id="display-name" name="display_name" type="text" autocomplete="name" maxlength="50" required autofocus; + label for="email" { "Email" } + input id="email" name="email" type="email" autocomplete="email" required; + label for="password" { "Password" } + input id="password" name="password" type="password" autocomplete="new-password" minlength="8" required; + button class="button button-primary" type="submit" { "Create account" } + } + p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } } + } + }, + ) +} + +pub fn registration_closed_page() -> Markup { + page( + "Registration closed", + None, + html! { + div class="auth-card" { + p class="eyebrow" { "PRIVATE HOUSEHOLD" } + h1 { "Registration is invite-only" } + p class="lede" { "Ask someone who owns a list to send you an invitation link." } + a class="button button-primary" href="/login" { "Back to sign in" } + } + }, + ) +} + +pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Markup { + page( + "Your lists", + Some(user), + html! { + div class="page-heading" { + div { + p class="eyebrow" { "YOUR HOUSEHOLD" } + h1 { "Grocery lists" } + p class="lede" { "Everything you need, in one place." } + } + } + div class="dashboard-grid" { + section class="panel" { + div class="panel-heading" { + h2 { "Lists" } + span class="count-badge" { (lists.len()) } + } + @if lists.is_empty() { + div class="empty-state" { + div class="empty-mark" { "+" } + h3 { "Start your first list" } + p { "Create a list for the weekly shop, a party, or anything else." } + } + } @else { + div class="list-cards" { + @for summary in lists { + a class="list-card" href=(format!("/lists/{}", summary.list.id)) { + span class="list-card-icon" { "✓" } + span class="list-card-copy" { + strong { (summary.list.name) } + small { @if summary.role == "owner" { "Owner" } @else { "Member" } } + } + span class="list-card-arrow" { "→" } + } + } + } + } + } + section class="panel create-panel" { + div class="panel-heading" { h2 { "New list" } } + form method="post" action="/lists" class="stack" { + input type="hidden" name="csrf" value=(csrf_token); + label for="list-name" { "List name" } + input id="list-name" name="name" type="text" maxlength="80" placeholder="Weekly shop" required; + button class="button button-primary" type="submit" { "Create list" } + } + } + } + }, + ) +} + +pub fn list_page( + user: &User, + access: &ListAccess, + items: &[Item], + categories: &[Category], + presence: &[PresenceUser], + csrf_token: &str, +) -> Markup { + let is_owner = access.role == "owner"; + page( + &access.list.name, + Some(user), + html! { + div class="list-topbar" { + a class="back-link" href="/lists" { "← All lists" } + div class="list-topbar-actions" { + span class="live-pill" { span class="live-dot" {} "Live" } + @if is_owner { + a class="button button-small button-quiet" href="#sharing" { "Share list" } + } + } + } + div class="list-layout" { + section class="panel list-panel" { + div class="list-heading" { + div { + p class="eyebrow" { "SHARED LIST" } + h1 { (access.list.name) } + p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" } + } + } + (list_content_fragment(&access.list, items, categories, csrf_token, false)) + } + aside class="side-column" { + (presence_panel(presence, false)) + (categories_panel(&access.list, categories, csrf_token, false)) + @if is_owner { + section id="sharing" class="panel sharing-panel" { + div class="panel-heading" { h2 { "Share this list" } } + p { "Create a one-time invite link for someone you shop with." } + form + hx-post=(format!("/lists/{}/invitations", access.list.id)) + hx-target="#invite-result" + hx-swap="innerHTML" + class="stack" + { + input type="hidden" name="csrf" value=(csrf_token); + button class="button button-secondary" type="submit" { "Create invite link" } + } + div id="invite-result" class="invite-result" {} + } + } + section class="panel tip-panel" { + span class="tip-label" { "TIP" } + p { "Check items off as you go. Everyone viewing this list will see it instantly." } + } + } + } + div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", access.list.id)) {} + }, + ) +} + +pub fn list_content_fragment( + list: &GroceryList, + items: &[Item], + categories: &[Category], + csrf_token: &str, + out_of_band: bool, +) -> Markup { + if out_of_band { + html! { + div id="list-content" hx-swap-oob="outerHTML" { + (list_content(list, items, categories, csrf_token)) + } + } + } else { + html! { + div id="list-content" { + (list_content(list, items, categories, csrf_token)) + } + } + } +} + +fn list_content( + list: &GroceryList, + items: &[Item], + categories: &[Category], + csrf_token: &str, +) -> Markup { + html! { + form + id="add-item-form" + class="add-item-form" + hx-post=(format!("/lists/{}/items", list.id)) + hx-target="#list-items" + hx-swap="outerHTML" + hx-on::after-request="if (event.detail.successful) this.reset()" + { + input type="hidden" name="csrf" value=(csrf_token); + label class="sr-only" for="item-name" { "Item name" } + input id="item-name" name="name" type="text" maxlength="120" placeholder="Add an item..." autocomplete="off" required; + input name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity"; + select name="category_id" aria-label="Category" { + (category_options(categories, None)) + } + button class="button button-primary add-button" type="submit" { "+ Add" } + } + (list_items_fragment(list, items, categories, csrf_token, false)) + } +} + +pub fn list_items_fragment( + list: &GroceryList, + items: &[Item], + categories: &[Category], + csrf_token: &str, + out_of_band: bool, +) -> Markup { + if out_of_band { + html! { + div id="list-items" class="items" data-revision=(list.revision) hx-swap-oob="outerHTML" { + (list_items_content(items, categories, csrf_token)) + } + } + } else { + html! { + div id="list-items" class="items" data-revision=(list.revision) { + (list_items_content(items, categories, csrf_token)) + } + } + } +} + +fn list_items_content(items: &[Item], categories: &[Category], csrf_token: &str) -> Markup { + html! { + @if items.is_empty() { + div class="empty-items" { + span class="empty-items-icon" { "✦" } + p { "Your list is clear." } + small { "Add the first thing you need above." } + } + } @else { + div class="item-list" { + @for group in item_groups(items, categories) { + (category_group(&group.0, &group.1, categories, csrf_token)) + } + } + } + } +} + +fn item_groups<'a>(items: &'a [Item], categories: &[Category]) -> Vec<(String, Vec<&'a Item>)> { + let mut groups = Vec::new(); + for category in categories { + let items_in_category = items + .iter() + .filter(|item| item.category_id.as_deref() == Some(category.id.as_str())) + .collect::>(); + if !items_in_category.is_empty() { + groups.push((category.name.clone(), items_in_category)); + } + } + + let uncategorized = items + .iter() + .filter(|item| item.category_id.is_none()) + .collect::>(); + if !uncategorized.is_empty() { + groups.push(("Uncategorized".into(), uncategorized)); + } + groups +} + +fn category_group( + name: &str, + items: &[&Item], + categories: &[Category], + csrf_token: &str, +) -> Markup { + html! { + section class="category-group" { + h2 class="category-heading" { (name) } + @for item in items { + (item_row(item, categories, csrf_token)) + } + } + } +} + +fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup { + let next_checked = if item.checked { "0" } else { "1" }; + html! { + article class=(if item.checked { "item-row is-checked" } else { "item-row" }) id=(format!("item-{}", item.id)) data-version=(item.version) { + form + class="check-form" + hx-post=(format!("/lists/{}/items/{}/check", item.list_id, item.id)) + hx-target="#list-items" + hx-swap="outerHTML" + { + input type="hidden" name="csrf" value=(csrf_token); + input type="hidden" name="checked" value=(next_checked); + button class="check-button" type="submit" aria-label=(if item.checked { "Mark unchecked" } else { "Mark complete" }) { + @if item.checked { "✓" } @else { "" } + } + } + div class="item-copy" { + strong { (item.name) } + @if !item.quantity.is_empty() || !item.note.is_empty() { + small { + @if !item.quantity.is_empty() { (item.quantity) } + @if !item.quantity.is_empty() && !item.note.is_empty() { " · " } + @if !item.note.is_empty() { (item.note) } + } + } + } + details class="item-actions" { + summary aria-label="Item actions" { "•••" } + div class="item-menu" { + form + hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id)) + hx-target="#list-items" + hx-swap="outerHTML" + class="edit-form stack" + { + input type="hidden" name="csrf" value=(csrf_token); + label { "Name" } + input name="name" value=(item.name) maxlength="120" required; + label { "Quantity" } + input name="quantity" value=(item.quantity) maxlength="40"; + label { "Note" } + input name="note" value=(item.note) maxlength="120"; + label { "Category" } + select name="category_id" { + (category_options(categories, item.category_id.as_deref())) + } + button class="button button-small button-secondary" type="submit" { "Save" } + } + form + hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) + hx-target="#list-items" + hx-swap="outerHTML" + { + input type="hidden" name="csrf" value=(csrf_token); + button class="danger-link" type="submit" { "Remove item" } + } + } + } + } + } +} + +fn category_options(categories: &[Category], selected: Option<&str>) -> Markup { + html! { + @if selected.is_none() { + option value="" selected { "No category" } + } @else { + option value="" { "No category" } + } + @for category in categories { + @if selected == Some(category.id.as_str()) { + option value=(category.id) selected { (category.name) } + } @else { + option value=(category.id) { (category.name) } + } + } + } +} + +pub fn categories_panel( + list: &GroceryList, + categories: &[Category], + csrf_token: &str, + out_of_band: bool, +) -> Markup { + let panel = html! { + div class="panel-heading" { + h2 { "Categories" } + span class="count-badge" { (categories.len()) } + } + p { "Organize items by aisle or shopping area." } + form + hx-post=(format!("/lists/{}/categories", list.id)) + hx-target="#category-result" + hx-swap="innerHTML" + hx-on::after-request="if (event.detail.successful) this.reset()" + class="category-form" + { + input type="hidden" name="csrf" value=(csrf_token); + input name="name" type="text" maxlength="60" placeholder="Add a category" required; + button class="button button-small button-secondary" type="submit" { "Add" } + } + @if categories.is_empty() { + p class="muted category-empty" { "No categories yet." } + } @else { + div class="category-list" { + @for category in categories { + span class="category-chip" { (category.name) } + } + } + } + div id="category-result" class="category-result" {} + }; + + if out_of_band { + html! { + section id="categories-panel" class="panel categories-panel" hx-swap-oob="outerHTML" { (panel) } + } + } else { + html! { + section id="categories-panel" class="panel categories-panel" { (panel) } + } + } +} + +pub fn live_list_fragments( + access: &ListAccess, + items: &[Item], + categories: &[Category], + csrf_token: &str, +) -> Markup { + html! { + (list_content_fragment(&access.list, items, categories, csrf_token, true)) + (categories_panel(&access.list, categories, csrf_token, true)) + } +} + +pub fn category_created( + access: &ListAccess, + items: &[Item], + categories: &[Category], + csrf_token: &str, +) -> Markup { + html! { + p class="category-success" { "Category added." } + (live_list_fragments(access, items, categories, csrf_token)) + } +} + +pub fn presence_panel(presence: &[PresenceUser], out_of_band: bool) -> Markup { + if out_of_band { + html! { + section id="presence" class="panel presence-panel" hx-swap-oob="outerHTML" { + (presence_content(presence)) + } + } + } else { + html! { + section id="presence" class="panel presence-panel" { + (presence_content(presence)) + } + } + } +} + +fn presence_content(presence: &[PresenceUser]) -> Markup { + html! { + div class="panel-heading" { + h2 { "Viewing now" } + span class="count-badge" { (presence.len()) } + } + @if presence.is_empty() { + p class="muted" { "Just you for now." } + } @else { + div class="presence-list" { + @for person in presence { + div class="presence-person" data-user-id=(person.user_id) { + span class="avatar" { (initials(&person.display_name)) } + span { (person.display_name) } + } + } + } + } + } +} + +pub fn invite_page( + info: &InvitationInfo, + user: Option<&User>, + token: &str, + error: Option<&str>, + csrf_token: Option<&str>, +) -> Markup { + page( + "Join a list", + user, + html! { + div class="auth-card invite-card" { + p class="eyebrow" { "YOU'RE INVITED" } + h1 { "Join " (info.list_name) } + p class="lede" { "Shop together and keep the list in sync." } + @if let Some(error) = error { + div class="alert alert-error" role="alert" { (error) } + } + @if let Some(user) = user { + p { "Signed in as " strong { (user.display_name) } "." } + form method="post" action=(format!("/invite/{}/accept", token)) class="stack" { + @if let Some(csrf_token) = csrf_token { + input type="hidden" name="csrf" value=(csrf_token); + } + button class="button button-primary" type="submit" { "Join list" } + } + } @else { + p { "Sign in or create an account to accept this invite." } + div class="invite-actions" { + a class="button button-primary" href=(format!("/login?invite={}", token)) { "Sign in" } + a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" } + } + } + } + }, + ) +} + +pub fn invite_result(url: &str) -> Markup { + html! { + div class="invite-link-result" { + p { "Copy this invite link:" } + input readonly value=(url) aria-label="Invitation URL"; + } + } +} + +pub fn error_page(status: &str, message: &str) -> Markup { + page( + status, + None, + html! { + div class="auth-card" { + p class="eyebrow" { "SOMETHING WENT WRONG" } + h1 { (status) } + p class="lede" { (message) } + a class="button button-primary" href="/lists" { "Back to lists" } + } + }, + ) +} + +fn page(title: &str, user: Option<&User>, content: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) " · Sustenance" } + link rel="stylesheet" href="/static/style.css"; + script src="https://unpkg.com/htmx.org@2.0.4" {} + script src="https://unpkg.com/htmx-ext-ws@2.0.2/ws.js" {} + } + body { + header class="site-header" { + a class="brand" href="/lists" { span class="brand-mark" { "S" } "Sustenance" } + @if let Some(user) = user { + div class="account-nav" { + span class="user-name" { (user.display_name) } + form method="post" action="/logout" { + button class="text-button" type="submit" { "Sign out" } + } + } + } + } + main class="site-main" { (content) } + footer class="site-footer" { "A small, shared grocery list." } + } + } + } +} + +fn initials(name: &str) -> String { + name.split_whitespace() + .filter_map(|part| part.chars().next()) + .take(2) + .collect::() + .to_uppercase() +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..2d234f9 --- /dev/null +++ b/static/style.css @@ -0,0 +1,177 @@ +:root { + color-scheme: light; + --ink: #25352e; + --muted: #718077; + --paper: #f8f6ef; + --card: #fffdf8; + --line: #e5e6d9; + --sage: #b7c9a8; + --deep-sage: #55715d; + --yellow: #f3c969; + --coral: #db8068; + --shadow: 0 18px 50px rgba(52, 68, 52, .08); + font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + color: var(--ink); + background: radial-gradient(circle at 15% -10%, #fff7d8 0, transparent 32rem), var(--paper); + font-size: 16px; + line-height: 1.45; +} + +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: inherit; } + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; + padding: 28px 0; +} + +.brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 800; letter-spacing: -.03em; } +.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 11px 11px 11px 3px; background: var(--deep-sage); color: white; transform: rotate(-6deg); } +.account-nav { display: flex; align-items: center; gap: 16px; color: var(--muted); font-size: .9rem; } +.user-name { color: var(--ink); font-weight: 700; } +.text-button { border: 0; padding: 0; color: var(--deep-sage); background: transparent; cursor: pointer; font-weight: 700; } + +.site-main { width: min(1120px, calc(100% - 40px)); margin: 30px auto 80px; } +.site-footer { width: min(1120px, calc(100% - 40px)); margin: 0 auto 28px; color: var(--muted); font-size: .78rem; text-align: center; } + +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 10px; font-size: clamp(2rem, 5vw, 3.7rem); line-height: 1.02; letter-spacing: -.065em; } +h2 { margin-bottom: 0; font-size: 1.05rem; letter-spacing: -.02em; } +h3 { margin-bottom: 6px; font-size: 1rem; } +.lede { max-width: 500px; margin-bottom: 0; color: var(--muted); font-size: 1.05rem; } +.eyebrow { margin-bottom: 10px; color: var(--deep-sage); font-size: .72rem; font-weight: 800; letter-spacing: .15em; } +.muted { color: var(--muted); } + +.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 34px; } +.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, .8fr); gap: 22px; align-items: start; } +.panel { padding: 26px; border: 1px solid rgba(221, 225, 210, .9); border-radius: 24px; background: rgba(255, 253, 248, .88); box-shadow: var(--shadow); } +.panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 22px; } +.count-badge { display: inline-grid; place-items: center; min-width: 27px; height: 27px; padding: 0 8px; border-radius: 99px; color: var(--deep-sage); background: #e8f0e1; font-size: .78rem; font-weight: 800; } +.stack { display: grid; gap: 9px; } +.stack label { color: var(--muted); font-size: .82rem; font-weight: 700; } +input { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; } +input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); } +.button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 10px 17px; border: 0; border-radius: 12px; cursor: pointer; text-decoration: none; font-weight: 800; transition: transform .16s ease, box-shadow .16s ease, background .16s ease; } +.button:hover { transform: translateY(-1px); } +.button-primary { color: #fff; background: var(--deep-sage); box-shadow: 0 8px 18px rgba(85, 113, 93, .2); } +.button-secondary { color: var(--deep-sage); background: #e7f0e1; } +.button-quiet { color: var(--deep-sage); background: transparent; border: 1px solid var(--line); } +.button-small { min-height: 35px; padding: 7px 11px; font-size: .8rem; } + +.list-cards { display: grid; gap: 10px; } +.list-card { display: flex; align-items: center; gap: 13px; padding: 14px; border: 1px solid var(--line); border-radius: 16px; text-decoration: none; background: #fff; transition: border-color .16s ease, transform .16s ease; } +.list-card:hover { border-color: var(--sage); transform: translateX(2px); } +.list-card-icon { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 13px; color: var(--deep-sage); background: #eef4e9; font-weight: 900; } +.list-card-copy { display: grid; flex: 1; gap: 2px; } +.list-card-copy small { color: var(--muted); font-size: .75rem; } +.list-card-arrow { color: var(--muted); font-size: 1.25rem; } +.empty-state { padding: 35px 18px 24px; text-align: center; color: var(--muted); } +.empty-mark { display: grid; place-items: center; width: 50px; height: 50px; margin: 0 auto 15px; border-radius: 18px; color: var(--deep-sage); background: #edf3e8; font-size: 1.8rem; } +.empty-state h3 { color: var(--ink); } + +.auth-card { width: min(100%, 480px); margin: 7vh auto 0; padding: clamp(27px, 6vw, 54px); border: 1px solid var(--line); border-radius: 28px; background: rgba(255, 253, 248, .9); box-shadow: var(--shadow); } +.auth-card .button { margin-top: 11px; } +.auth-switch { margin: 25px 0 0; color: var(--muted); font-size: .9rem; text-align: center; } +.auth-switch a { color: var(--deep-sage); font-weight: 800; } +.alert { margin-bottom: 18px; padding: 12px 14px; border-radius: 12px; font-size: .9rem; } +.alert-error { color: #874d40; background: #fbe7e0; } + +.list-topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 27px; } +.back-link { color: var(--muted); font-size: .85rem; font-weight: 700; text-decoration: none; } +.back-link:hover { color: var(--deep-sage); } +.list-topbar-actions { display: flex; align-items: center; gap: 12px; } +.live-pill { display: inline-flex; align-items: center; gap: 7px; color: var(--deep-sage); font-size: .78rem; font-weight: 800; } +.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #75ae6e; box-shadow: 0 0 0 4px rgba(117, 174, 110, .15); } +.list-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(265px, .72fr); gap: 22px; align-items: start; } +.list-panel { min-width: 0; } +.list-heading { display: flex; justify-content: space-between; margin-bottom: 25px; } +.list-heading h1 { max-width: 100%; margin-bottom: 5px; overflow-wrap: anywhere; font-size: clamp(1.45rem, 2.8vw, 2.05rem); } +.list-meta { margin: 0; color: var(--muted); font-size: .85rem; } +.add-item-form { display: grid; grid-template-columns: minmax(0, 1fr) 90px 145px auto; gap: 8px; margin-bottom: 19px; } +.add-item-form input { min-height: 50px; } +.add-item-form select { min-height: 50px; } +.add-button { min-height: 50px; } +.items { min-height: 90px; } +.item-list { display: grid; gap: 6px; } +.category-group + .category-group { margin-top: 18px; } +.category-heading { margin: 0 7px 4px; color: var(--deep-sage); font-size: .72rem; letter-spacing: .12em; text-transform: uppercase; } +.item-row { display: flex; align-items: center; gap: 12px; min-height: 66px; padding: 9px 7px 9px 10px; border-bottom: 1px solid #edf0e6; } +.item-row:last-child { border-bottom: 0; } +.check-form { flex: 0 0 auto; } +.check-button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 2px solid #c8d6c1; border-radius: 9px; color: #fff; background: transparent; cursor: pointer; font-size: .88rem; font-weight: 900; } +.is-checked .check-button { border-color: var(--deep-sage); background: var(--deep-sage); } +.item-copy { display: grid; flex: 1; min-width: 0; gap: 2px; } +.item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item-copy small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: .78rem; } +.is-checked .item-copy strong { color: var(--muted); text-decoration: line-through; } +.item-actions { position: relative; } +.item-actions summary { padding: 7px 5px; color: var(--muted); cursor: pointer; list-style: none; font-size: .78rem; letter-spacing: 2px; } +.item-actions summary::-webkit-details-marker { display: none; } +.item-menu { position: absolute; z-index: 2; right: 0; width: min(265px, 80vw); padding: 14px; border: 1px solid var(--line); border-radius: 15px; background: var(--card); box-shadow: var(--shadow); } +.edit-form { margin-bottom: 12px; } +.edit-form input { min-height: 38px; padding: 7px 10px; font-size: .85rem; } +.danger-link { padding: 0; border: 0; color: var(--coral); background: none; cursor: pointer; font-size: .8rem; font-weight: 800; } +.empty-items { padding: 34px 10px 18px; color: var(--muted); text-align: center; } +.empty-items-icon { display: block; margin-bottom: 7px; color: var(--yellow); font-size: 1.7rem; } +.empty-items p { margin-bottom: 2px; color: var(--ink); font-weight: 800; } +.empty-items small { font-size: .8rem; } +.side-column { display: grid; gap: 22px; } +.categories-panel > p { margin-bottom: 14px; color: var(--muted); font-size: .82rem; } +.category-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } +.category-form input { min-height: 38px; padding: 7px 10px; font-size: .84rem; } +.category-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } +.category-chip { padding: 5px 9px; border-radius: 99px; color: var(--deep-sage); background: #edf3e8; font-size: .72rem; font-weight: 800; } +.category-empty { margin: 13px 0 0; font-size: .8rem; } +.category-result { margin-top: 10px; } +.category-success { margin: 0; color: var(--deep-sage); font-size: .76rem; font-weight: 800; } +.presence-list { display: grid; gap: 12px; } +.presence-person { display: flex; align-items: center; gap: 10px; font-size: .9rem; font-weight: 700; } +.avatar { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 11px; color: var(--deep-sage); background: #e6f0df; font-size: .7rem; font-weight: 900; } +.sharing-panel p, .tip-panel p { color: var(--muted); font-size: .86rem; } +.invite-result { margin-top: 15px; } +.invite-link-result { padding: 11px; border-radius: 12px; background: #f1f5ec; } +.invite-link-result p { margin-bottom: 7px; color: var(--deep-sage); font-size: .76rem; font-weight: 800; } +.invite-link-result input { min-height: 37px; padding: 7px; font-size: .72rem; } +.tip-panel { border-color: #eee3bf; background: #fff9df; box-shadow: none; } +.tip-label { display: inline-block; margin-bottom: 11px; color: #a27c25; font-size: .67rem; font-weight: 900; letter-spacing: .15em; } +.tip-panel p { margin-bottom: 0; color: #806a35; } +.live-stream { display: none; } +.invite-actions { display: grid; gap: 10px; margin-top: 20px; } +.invite-card p:last-of-type { margin-bottom: 0; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 780px) { + .site-header, .site-main, .site-footer { width: min(100% - 28px, 600px); } + .site-header { padding: 20px 0; } + .site-main { margin-top: 20px; } + .dashboard-grid, .list-layout { grid-template-columns: 1fr; } + .side-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .tip-panel { display: none; } +} + +@media (max-width: 500px) { + .account-nav { gap: 9px; } + .user-name { max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .panel { padding: 20px 16px; border-radius: 20px; } + .page-heading { margin-bottom: 24px; } + .list-topbar { margin-bottom: 20px; } + .list-topbar-actions .button { display: none; } + .list-heading h1 { font-size: clamp(1.45rem, 7vw, 1.75rem); } + .add-item-form { grid-template-columns: minmax(0, 1fr) 75px; } + .add-button { grid-column: 1 / -1; } + .side-column { grid-template-columns: 1fr; } + .site-footer { margin-bottom: 20px; } +}