From ec125b93662d703c6ea62f4c6a3f24bc5389e472 Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sat, 7 Feb 2026 12:57:50 -0800 Subject: [PATCH] saving --- .gitignore | 1 + Cargo.lock | 151 ++++++++ Cargo.toml | 2 + data/app.db | Bin 32768 -> 73728 bytes src/config.rs | 50 +++ src/db.rs | 366 ++++++++++++++----- src/handlers.rs | 653 +++++++++++++++++++++++++++++----- src/main.rs | 9 + src/views.rs | 82 +++++ templates/login.html | 21 ++ templates/planning.html | 20 ++ templates/public_profile.html | 62 ++++ templates/signup.html | 22 ++ 13 files changed, 1252 insertions(+), 187 deletions(-) create mode 100644 src/config.rs create mode 100644 templates/login.html create mode 100644 templates/public_profile.html create mode 100644 templates/signup.html diff --git a/.gitignore b/.gitignore index 8662415..2b87f3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +config/ tmp/ diff --git a/Cargo.lock b/Cargo.lock index 90f4b04..5a10a1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,9 +6,11 @@ version = 4 name = "CurseTechnique" version = "0.1.0" dependencies = [ + "argon2", "askama", "axum", "chrono", + "rand", "rusqlite", "tokio", ] @@ -34,6 +36,18 @@ dependencies = [ "libc", ] +[[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 = "askama" version = "0.12.1" @@ -142,6 +156,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -157,6 +177,24 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[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.19.1" @@ -204,6 +242,36 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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 = "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" @@ -274,6 +342,27 @@ dependencies = [ "pin-utils", ] +[[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 = "hashbrown" version = "0.14.5" @@ -553,6 +642,17 @@ dependencies = [ "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", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -577,6 +677,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[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.106" @@ -595,6 +704,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -734,6 +873,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.114" @@ -827,6 +972,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index 23d2174..fc74340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +argon2 = "0.5.3" askama = "0.12.1" axum = "0.8.1" chrono = { version = "0.4.40", features = ["clock"] } +rand = "0.8.5" rusqlite = { version = "0.31.0", features = ["bundled"] } tokio = { version = "1.44.0", features = ["full"] } diff --git a/data/app.db b/data/app.db index 18461fa2781717f71faa74cfe065bad095bf55bb..f5a3fc9ebfab629593779e8258217085bb80fa95 100644 GIT binary patch literal 73728 zcmeI*&2QW09S3ldl0`9&%p`-$lQ9}`5Nmd3Nz{ww!I?crX`NWIPY+{de?6@K4+y*dGTz7}*Ux_W#~o z1O);wMc~Psms#QZb^519ODv1&wR(k^xU#aDS;qV8$RUcv4h zMb@g1d+vratJUp%qV969(^qr1G7rS(wRd0GN{01ZH%W-~P4{wRR>e{7R?VYOL2kDi_O#Pi-tLPI^IYnDGsFsa=jhHovg6B*hT`P8 zv#wshd#lEd?so4Y{nqZLdwbox=#c>*7#CS#ZjS!Rc)!!VjqISjGeW~%?yVl^@uPP? z;__C0eKlvUDVxdV$$sr+=Ft0!TuRyS0`&>D5ac^oB-ow)O-h* znhYGxUW7Ny1%yT6I9?m6VDF}k3!NXb-3%PHVV%R7^B5#}ow+9y?j7Sz|3xu6a3b(B zPBq-O)vV~8XM{Ve0XuIF9@^nm8gkXJ?^q%*d)6x+Rk-slzkOIt>vlmnJrWSI*N;mpdSUtX!iz2r^uDn7IYX??TQkscUwx<65AVx%Q^UJGayRB42*2(lrFBuy z@NZN6@A$X*H?8zQfdB*`009U<00Izz00bZa0SG|g00Izz00bZa0SG_< z0uX?}t1d9^^UcxrchbCHabSXsPvGotCtad_*Jy7Yd%=u};XkJMFZhr7KfdY#V*4Nf z0SG_<0uX=z1Rwwb2tWV=5a2){)jzk?z5d^)`2F+Fg7_f-0SG_<0uX=z1Rwwb2tWV=FGHZ?qgiUAv(8CPwWaD> zy)h;0l}0_LDN}DPM$=O@^Ddr9&bAg~v(3fm)PDO;dbiw^=1Tck?)C3)&SmudwMSET zZpd$JX7B9FSxIWRU;9sqvB-jyT8JgB z-~ZG6zbNyD0s#m>00Izz00bZa0SG_<0uX?}84?)vO>oX<4&CwpcPakeGZY{~fdB*` z009U<00Izz00bZa0SKHnfgjO!QgOaqPDGSqDISRwlks>; zQlwO=7>_D)LXuP^mQZ5FWUQ!`q(odU%k$dL|2%%|`2YJ9|Nd!%!S+G`0uX=z1Rwwb z2tWV=5P$##&V|4&zxlzyi+(I%jsG+JFDU+*d4~c42tWV=5P$##AOHafKmY;|fWX%% zkYHGfo19@Jcf>wf+R;k8YF(_iD;2e6_@25|&X4)y>8SZH3UHHGz0GPRrO_@{RdG-9 zzcbWCN?X7GXZXKR{1@gO3Irek0SG_<0uX=z1Rwwb2tWV=r&ECUbCa}t;h$v2%xSJa z-5vk`km5h(KRlfQVyhtl0SG_<0uX=z1Rwwb2tWV==TG1@#=O2~-Rt|*lK$B*&GM_) zXkvbHNk5dVg7N?Jmm`Q60uX=z1Rwwb2tWV=5P$##&Y%D}{+}8g|Ht+J8SHa}1OW&@ z00Izz00bZa0SG_<0_RWQIoJQ)|NrlEivRrl(I8?7KmY;|fB*y_009U<00Izzz!??b TX^vtThU2&(^&2)A{LlXY7Rldt delta 84 zcmZoTz|zpbG(lQWjDdlH1BhXOZK95`v>1b4SqCrgYX)}i2nN3We7U@zd6{`P@R)N) hY!(!_$F(_vdyNPiBmX-F{&$-N6Q1yIey1-K006@j6nX#v diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..86fbef7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,50 @@ +use std::fs; +use std::path::Path; + +#[derive(Clone, Copy)] +pub struct AppConfig { + pub allow_signup: bool, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { allow_signup: true } + } +} + +pub fn load_config() -> AppConfig { + let path = "config/app.conf"; + ensure_default_config_file(path); + let content = match fs::read_to_string(path) { + Ok(text) => text, + Err(_) => return AppConfig::default(), + }; + + let mut cfg = AppConfig::default(); + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + if key.trim() == "allow_signup" { + let v = value.trim(); + cfg.allow_signup = v.eq_ignore_ascii_case("true") || v == "1"; + } + } + } + cfg +} + +fn ensure_default_config_file(path: &str) { + if Path::new(path).exists() { + return; + } + + if let Some(parent) = Path::new(path).parent() { + let _ = fs::create_dir_all(parent); + } + + let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n"; + let _ = fs::write(path, default); +} diff --git a/src/db.rs b/src/db.rs index 9e8339f..e8eaf77 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use rusqlite::{Connection, OptionalExtension, params}; #[derive(Clone)] -/// A single food entry row from `food_entries`. pub struct FoodEntry { pub id: i64, pub name: String, @@ -11,70 +10,216 @@ pub struct FoodEntry { } #[derive(Clone, Copy)] -/// Aggregated daily values used by the calendar grid. pub struct DaySummary { pub entry_count: i64, pub total_calories: i64, } +#[derive(Clone)] +pub struct UserRecord { + pub id: i64, + pub username: String, + pub password_hash: String, + #[allow(dead_code)] + pub is_admin: bool, +} + +#[derive(Clone, Copy)] +pub struct SessionRecord { + pub user_id: i64, + pub expires_at_unix: i64, +} + #[derive(Clone, Copy)] pub struct PlanningConfig { pub target_weight: Option, pub target_calories: Option, pub bmr: Option, + pub public_entries: bool, + pub public_weights: bool, + pub public_reports: bool, + pub public_planning: bool, } -/// Creates tables/indexes if they do not already exist. pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch( - "CREATE TABLE IF NOT EXISTS food_entries ( + "CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at_unix INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS user_food_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, entry_date TEXT NOT NULL, name TEXT NOT NULL, calories INTEGER NOT NULL CHECK (calories >= 0), created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); - CREATE INDEX IF NOT EXISTS idx_food_entries_entry_date ON food_entries(entry_date); + CREATE INDEX IF NOT EXISTS idx_user_food_entries_user_date + ON user_food_entries(user_id, entry_date); - CREATE TABLE IF NOT EXISTS daily_weights ( - entry_date TEXT PRIMARY KEY, - weight REAL NOT NULL CHECK (weight > 0) + CREATE TABLE IF NOT EXISTS user_daily_weights ( + user_id INTEGER NOT NULL, + entry_date TEXT NOT NULL, + weight REAL NOT NULL CHECK (weight > 0), + PRIMARY KEY (user_id, entry_date) ); + CREATE INDEX IF NOT EXISTS idx_user_daily_weights_user_date + ON user_daily_weights(user_id, entry_date); - CREATE TABLE IF NOT EXISTS planning ( - id INTEGER PRIMARY KEY CHECK (id = 1), + CREATE TABLE IF NOT EXISTS user_planning ( + user_id INTEGER PRIMARY KEY, target_weight REAL, - target_calories INTEGER CHECK (target_calories >= 0) + target_calories INTEGER CHECK (target_calories >= 0), + bmr REAL CHECK (bmr > 0), + public_entries INTEGER NOT NULL DEFAULT 0, + public_weights INTEGER NOT NULL DEFAULT 0, + public_reports INTEGER NOT NULL DEFAULT 0, + public_planning INTEGER NOT NULL DEFAULT 0 );", )?; - ensure_planning_bmr_column(conn)?; - Ok(()) } -/// Placeholder seeding hook. Currently no-op unless you add seed inserts. pub fn seed_db_if_empty(conn: &Connection) -> Result<(), rusqlite::Error> { - let count: i64 = conn.query_row("SELECT COUNT(*) FROM food_entries", [], |row| row.get(0))?; - if count > 0 { - return Ok(()); - } - + let _count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; + Ok(()) +} + +pub fn count_users(conn: &Connection) -> Result { + conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0)) +} + +pub fn create_user( + conn: &Connection, + username: &str, + password_hash: &str, + is_admin: bool, +) -> Result { + conn.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (?1, ?2, ?3)", + params![username, password_hash, if is_admin { 1 } else { 0 }], + )?; + Ok(conn.last_insert_rowid()) +} + +pub fn fetch_user_by_username( + conn: &Connection, + username: &str, +) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT id, username, password_hash, is_admin + FROM users + WHERE username = ?1", + params![username], + |row| { + Ok(UserRecord { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + is_admin: row.get::<_, i64>(3)? == 1, + }) + }, + ) + .optional() +} + +pub fn fetch_user_by_id( + conn: &Connection, + user_id: i64, +) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT id, username, password_hash, is_admin + FROM users + WHERE id = ?1", + params![user_id], + |row| { + Ok(UserRecord { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + is_admin: row.get::<_, i64>(3)? == 1, + }) + }, + ) + .optional() +} + +pub fn create_or_replace_session( + conn: &Connection, + token: &str, + user_id: i64, + expires_at_unix: i64, +) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO sessions (token, user_id, expires_at_unix) + VALUES (?1, ?2, ?3) + ON CONFLICT(token) DO UPDATE SET + user_id = excluded.user_id, + expires_at_unix = excluded.expires_at_unix", + params![token, user_id, expires_at_unix], + )?; + Ok(()) +} + +pub fn fetch_session( + conn: &Connection, + token: &str, +) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT user_id, expires_at_unix FROM sessions WHERE token = ?1", + params![token], + |row| { + Ok(SessionRecord { + user_id: row.get(0)?, + expires_at_unix: row.get(1)?, + }) + }, + ) + .optional() +} + +pub fn touch_session( + conn: &Connection, + token: &str, + expires_at_unix: i64, +) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE sessions SET expires_at_unix = ?1 WHERE token = ?2", + params![expires_at_unix, token], + )?; + Ok(()) +} + +pub fn delete_session(conn: &Connection, token: &str) -> Result<(), rusqlite::Error> { + conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; Ok(()) } -/// Returns one summary per date for the given half-open range [start_date, end_date). pub fn fetch_month_summaries( conn: &Connection, + user_id: i64, start_date: &str, end_date: &str, ) -> Result, rusqlite::Error> { let mut statement = conn.prepare( "SELECT entry_date, COUNT(*) as entry_count, SUM(calories) as total - FROM food_entries - WHERE entry_date >= ?1 AND entry_date < ?2 + FROM user_food_entries + WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3 GROUP BY entry_date", )?; - let mut rows = statement.query(params![start_date, end_date])?; + let mut rows = statement.query(params![user_id, start_date, end_date])?; let mut totals = HashMap::::new(); while let Some(row) = rows.next()? { @@ -89,20 +234,21 @@ pub fn fetch_month_summaries( }, ); } - Ok(totals) } -/// Returns all entries for one day. -pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result, rusqlite::Error> { +pub fn fetch_day_entries( + conn: &Connection, + user_id: i64, + date: &str, +) -> Result, rusqlite::Error> { let mut statement = conn.prepare( "SELECT id, name, calories - FROM food_entries - WHERE entry_date = ?1 + FROM user_food_entries + WHERE user_id = ?1 AND entry_date = ?2 ORDER BY id", )?; - - let iter = statement.query_map(params![date], |row| { + let iter = statement.query_map(params![user_id, date], |row| { Ok(FoodEntry { id: row.get(0)?, name: row.get(1)?, @@ -114,53 +260,53 @@ pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result for row in iter { entries.push(row?); } - Ok(entries) } -/// Returns total calories in the half-open range [start_date, end_date). pub fn fetch_total_calories_for_range( conn: &Connection, + user_id: i64, start_date: &str, end_date: &str, ) -> Result { conn.query_row( "SELECT COALESCE(SUM(calories), 0) - FROM food_entries - WHERE entry_date >= ?1 AND entry_date < ?2", - params![start_date, end_date], + FROM user_food_entries + WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3", + params![user_id, start_date, end_date], |row| row.get(0), ) } -/// Returns the earliest date with either calories or weight data. -pub fn fetch_first_activity_date(conn: &Connection) -> Result, rusqlite::Error> { +pub fn fetch_first_activity_date( + conn: &Connection, + user_id: i64, +) -> Result, rusqlite::Error> { conn.query_row( "SELECT MIN(entry_date) FROM ( - SELECT entry_date FROM food_entries + SELECT entry_date FROM user_food_entries WHERE user_id = ?1 UNION ALL - SELECT entry_date FROM daily_weights + SELECT entry_date FROM user_daily_weights WHERE user_id = ?1 )", - [], + params![user_id], |row| row.get(0), ) } -/// Returns a map date -> total calories for the half-open range [start_date, end_date). pub fn fetch_daily_calorie_totals_for_range( conn: &Connection, + user_id: i64, start_date: &str, end_date: &str, ) -> Result, rusqlite::Error> { let mut statement = conn.prepare( "SELECT entry_date, COALESCE(SUM(calories), 0) - FROM food_entries - WHERE entry_date >= ?1 AND entry_date < ?2 + FROM user_food_entries + WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3 GROUP BY entry_date", )?; - let mut rows = statement.query(params![start_date, end_date])?; - + let mut rows = statement.query(params![user_id, start_date, end_date])?; let mut out = HashMap::new(); while let Some(row) = rows.next()? { let date: String = row.get(0)?; @@ -170,19 +316,18 @@ pub fn fetch_daily_calorie_totals_for_range( Ok(out) } -/// Returns a map date -> weight for the half-open range [start_date, end_date). pub fn fetch_daily_weights_for_range( conn: &Connection, + user_id: i64, start_date: &str, end_date: &str, ) -> Result, rusqlite::Error> { let mut statement = conn.prepare( "SELECT entry_date, weight - FROM daily_weights - WHERE entry_date >= ?1 AND entry_date < ?2", + FROM user_daily_weights + WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3", )?; - let mut rows = statement.query(params![start_date, end_date])?; - + let mut rows = statement.query(params![user_id, start_date, end_date])?; let mut out = HashMap::new(); while let Some(row) = rows.next()? { let date: String = row.get(0)?; @@ -192,10 +337,14 @@ pub fn fetch_daily_weights_for_range( Ok(out) } -pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result, rusqlite::Error> { +pub fn fetch_weight_for_day( + conn: &Connection, + user_id: i64, + date: &str, +) -> Result, rusqlite::Error> { conn.query_row( - "SELECT weight FROM daily_weights WHERE entry_date = ?1", - params![date], + "SELECT weight FROM user_daily_weights WHERE user_id = ?1 AND entry_date = ?2", + params![user_id, date], |row| row.get(0), ) .optional() @@ -203,112 +352,133 @@ pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result pub fn upsert_weight_for_day( conn: &Connection, + user_id: i64, date: &str, weight: f64, ) -> Result<(), rusqlite::Error> { conn.execute( - "INSERT INTO daily_weights (entry_date, weight) - VALUES (?1, ?2) - ON CONFLICT(entry_date) DO UPDATE SET weight = excluded.weight", - params![date, weight], + "INSERT INTO user_daily_weights (user_id, entry_date, weight) + VALUES (?1, ?2, ?3) + ON CONFLICT(user_id, entry_date) DO UPDATE SET weight = excluded.weight", + params![user_id, date, weight], )?; Ok(()) } -pub fn fetch_planning(conn: &Connection) -> Result { - let row: Option<(Option, Option, Option)> = conn +pub fn fetch_planning(conn: &Connection, user_id: i64) -> Result { + let row: Option<(Option, Option, Option, i64, i64, i64, i64)> = conn .query_row( - "SELECT target_weight, target_calories, bmr FROM planning WHERE id = 1", - [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + "SELECT target_weight, target_calories, bmr, public_entries, public_weights, public_reports, public_planning + FROM user_planning + WHERE user_id = ?1", + params![user_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), ) .optional()?; match row { - Some((target_weight, target_calories, bmr)) => Ok(PlanningConfig { + Some((target_weight, target_calories, bmr, pe, pw, pr, pp)) => Ok(PlanningConfig { target_weight, target_calories, bmr, + public_entries: pe == 1, + public_weights: pw == 1, + public_reports: pr == 1, + public_planning: pp == 1, }), None => Ok(PlanningConfig { target_weight: None, target_calories: None, bmr: None, + public_entries: false, + public_weights: false, + public_reports: false, + public_planning: false, }), } } pub fn upsert_planning( conn: &Connection, + user_id: i64, target_weight: Option, target_calories: Option, bmr: Option, + public_entries: bool, + public_weights: bool, + public_reports: bool, + public_planning: bool, ) -> Result<(), rusqlite::Error> { conn.execute( - "INSERT INTO planning (id, target_weight, target_calories, bmr) - VALUES (1, ?1, ?2, ?3) - ON CONFLICT(id) DO UPDATE - SET target_weight = excluded.target_weight, - target_calories = excluded.target_calories, - bmr = excluded.bmr", - params![target_weight, target_calories, bmr], + "INSERT INTO user_planning ( + user_id, target_weight, target_calories, bmr, + public_entries, public_weights, public_reports, public_planning + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(user_id) DO UPDATE SET + target_weight = excluded.target_weight, + target_calories = excluded.target_calories, + bmr = excluded.bmr, + public_entries = excluded.public_entries, + public_weights = excluded.public_weights, + public_reports = excluded.public_reports, + public_planning = excluded.public_planning", + params![ + user_id, + target_weight, + target_calories, + bmr, + if public_entries { 1 } else { 0 }, + if public_weights { 1 } else { 0 }, + if public_reports { 1 } else { 0 }, + if public_planning { 1 } else { 0 }, + ], )?; Ok(()) } -fn ensure_planning_bmr_column(conn: &Connection) -> Result<(), rusqlite::Error> { - let mut stmt = conn.prepare("PRAGMA table_info(planning)")?; - let mut rows = stmt.query([])?; - let mut has_bmr = false; - while let Some(row) = rows.next()? { - let name: String = row.get(1)?; - if name == "bmr" { - has_bmr = true; - break; - } - } - if !has_bmr { - conn.execute("ALTER TABLE planning ADD COLUMN bmr REAL", [])?; - } - Ok(()) -} - -/// Inserts one entry for a date. pub fn insert_entry( conn: &Connection, + user_id: i64, date: &str, name: &str, calories: i64, ) -> Result<(), rusqlite::Error> { conn.execute( - "INSERT INTO food_entries (entry_date, name, calories) VALUES (?1, ?2, ?3)", - params![date, name, calories], + "INSERT INTO user_food_entries (user_id, entry_date, name, calories) + VALUES (?1, ?2, ?3, ?4)", + params![user_id, date, name, calories], )?; Ok(()) } -/// Updates one entry if both `id` and `date` match. pub fn update_entry( conn: &Connection, + user_id: i64, date: &str, id: i64, name: &str, calories: i64, ) -> Result<(), rusqlite::Error> { conn.execute( - "UPDATE food_entries + "UPDATE user_food_entries SET name = ?1, calories = ?2 - WHERE id = ?3 AND entry_date = ?4", - params![name, calories, id, date], + WHERE user_id = ?3 AND id = ?4 AND entry_date = ?5", + params![name, calories, user_id, id, date], )?; Ok(()) } -/// Deletes one entry if both `id` and `date` match. -pub fn delete_entry(conn: &Connection, date: &str, id: i64) -> Result<(), rusqlite::Error> { +pub fn delete_entry( + conn: &Connection, + user_id: i64, + date: &str, + id: i64, +) -> Result<(), rusqlite::Error> { conn.execute( - "DELETE FROM food_entries WHERE id = ?1 AND entry_date = ?2", - params![id, date], + "DELETE FROM user_food_entries + WHERE user_id = ?1 AND id = ?2 AND entry_date = ?3", + params![user_id, id, date], )?; Ok(()) } diff --git a/src/handlers.rs b/src/handlers.rs index ff22e2c..437346e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,75 +1,355 @@ use std::collections::HashMap; use std::sync::Arc; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::{Argon2, password_hash::rand_core::OsRng as ArgonOsRng}; use axum::extract::{Form, Path, State}; -use axum::http::StatusCode; -use axum::response::{Html, Redirect}; -use chrono::{Datelike, Days, Duration, Local, NaiveDate}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use chrono::{Datelike, Days, Duration, Local, NaiveDate, Utc}; +use rand::RngCore; use rusqlite::Connection; use tokio::sync::Mutex; +use crate::config::AppConfig; use crate::db; use crate::views::{ - self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, PlanningPageView, - ReportCardView, ReportsPageView, + self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView, + PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView, + SignupPageView, }; type AppError = (StatusCode, String); +const SESSION_COOKIE: &str = "session_token"; +const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60; #[derive(Clone)] -/// Shared application state injected into each handler. pub struct AppState { pub db: Arc>, + pub config: AppConfig, } -/// GET `/` - calendar overview for the current month. -pub async fn show_calendar(State(state): State) -> Result, AppError> { - render_calendar_for_month(state, Local::now().date_naive()).await +#[derive(Clone)] +struct AuthUser { + user: db::UserRecord, + token: String, +} + +pub async fn show_calendar( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + render_calendar_for_month(&state, &auth, Local::now().date_naive()).await } -/// GET `/calendar/{year}/{month}` - calendar overview for a specific month. pub async fn show_calendar_for_month( State(state): State, + headers: HeaderMap, Path((year, month)): Path<(i32, u32)>, -) -> Result, AppError> { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; let focus_date = NaiveDate::from_ymd_opt(year, month, 1).ok_or(( StatusCode::BAD_REQUEST, "Month route must use /calendar/{year}/{month} with month 1-12".to_string(), ))?; - render_calendar_for_month(state, focus_date).await + render_calendar_for_month(&state, &auth, focus_date).await } -/// GET `/reports` - summary cards + rolling trend charts. -pub async fn show_reports(State(state): State) -> Result, AppError> { +pub async fn show_login( + State(state): State, + headers: HeaderMap, +) -> Result { + if get_auth_user(&state, &headers).await?.is_some() { + return Ok(Redirect::to("/").into_response()); + } + let page = LoginPageView { + allow_signup: signup_allowed(&state).await?, + error: String::new(), + }; + Ok(Html(views::render_login_page(&page).map_err(internal_template_error)?).into_response()) +} + +pub async fn show_signup( + State(state): State, + headers: HeaderMap, +) -> Result { + if get_auth_user(&state, &headers).await?.is_some() { + return Ok(Redirect::to("/").into_response()); + } + let allowed = signup_allowed(&state).await?; + let page = SignupPageView { + allow_signup: allowed, + error: String::new(), + }; + Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response()) +} + +pub async fn login( + State(state): State, + Form(form): Form>, +) -> Result { + let username = form.get("username").map(|v| v.trim()).unwrap_or(""); + let password = form.get("password").map(|v| v.as_str()).unwrap_or(""); + if username.is_empty() || password.is_empty() { + return render_login_with_error(&state, "Username and password are required").await; + } + + let user = { + let db_conn = state.db.lock().await; + db::fetch_user_by_username(&db_conn, username).map_err(internal_db_error)? + }; + let Some(user) = user else { + return render_login_with_error(&state, "Invalid username or password").await; + }; + + if !verify_password(&user.password_hash, password) { + return render_login_with_error(&state, "Invalid username or password").await; + } + + let token = generate_session_token(); + let expires = session_expiry_unix(); + { + let db_conn = state.db.lock().await; + db::create_or_replace_session(&db_conn, &token, user.id, expires) + .map_err(internal_db_error)?; + } + Ok(with_session_cookie( + Redirect::to("/").into_response(), + &token, + )) +} + +pub async fn signup( + State(state): State, + Form(form): Form>, +) -> Result { + if !signup_allowed(&state).await? { + return render_signup_with_error(&state, "Sign-up is disabled").await; + } + + let username = form.get("username").map(|v| v.trim()).unwrap_or(""); + let password = form.get("password").map(|v| v.as_str()).unwrap_or(""); + if username.len() < 2 { + return render_signup_with_error(&state, "Username must be at least 2 chars").await; + } + if password.len() < 4 { + return render_signup_with_error(&state, "Password must be at least 4 chars").await; + } + + let (user_count, existing) = { + let db_conn = state.db.lock().await; + let count = db::count_users(&db_conn).map_err(internal_db_error)?; + let existing = db::fetch_user_by_username(&db_conn, username).map_err(internal_db_error)?; + (count, existing) + }; + if existing.is_some() { + return render_signup_with_error(&state, "Username already taken").await; + } + + let password_hash = hash_password(password)?; + let user_id = { + let db_conn = state.db.lock().await; + db::create_user(&db_conn, username, &password_hash, user_count == 0) + .map_err(internal_db_error)? + }; + + let token = generate_session_token(); + let expires = session_expiry_unix(); + { + let db_conn = state.db.lock().await; + db::create_or_replace_session(&db_conn, &token, user_id, expires) + .map_err(internal_db_error)?; + } + Ok(with_session_cookie( + Redirect::to("/").into_response(), + &token, + )) +} + +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + if let Some(token) = session_token_from_headers(&headers) { + let db_conn = state.db.lock().await; + let _ = db::delete_session(&db_conn, &token); + } + Ok(clear_session_cookie(Redirect::to("/login").into_response())) +} + +pub async fn show_public_profile( + State(state): State, + Path(username): Path, +) -> Result { + let user = { + let db_conn = state.db.lock().await; + db::fetch_user_by_username(&db_conn, &username).map_err(internal_db_error)? + }; + let Some(user) = user else { + return Err((StatusCode::NOT_FOUND, "User not found".to_string())); + }; + + let planning = { + let db_conn = state.db.lock().await; + db::fetch_planning(&db_conn, user.id).map_err(internal_db_error)? + }; + + let today = Local::now().date_naive(); + let start = today - Days::new(13); + let (calorie_map, weight_map) = { + let db_conn = state.db.lock().await; + let c = db::fetch_daily_calorie_totals_for_range( + &db_conn, + user.id, + &start.format("%Y-%m-%d").to_string(), + &(today + Days::new(1)).format("%Y-%m-%d").to_string(), + ) + .map_err(internal_db_error)?; + let w = db::fetch_daily_weights_for_range( + &db_conn, + user.id, + &start.format("%Y-%m-%d").to_string(), + &(today + Days::new(1)).format("%Y-%m-%d").to_string(), + ) + .map_err(internal_db_error)?; + (c, w) + }; + + let mut recent_days = Vec::new(); + let mut day = start; + while day <= today { + let key = day.format("%Y-%m-%d").to_string(); + recent_days.push(PublicDaySummaryView { + date_text: key.clone(), + calories: *calorie_map.get(&key).unwrap_or(&0), + weight_label: weight_map + .get(&key) + .map(|w| format!("{w:.1} lbs")) + .unwrap_or_else(|| "-".to_string()), + }); + day += Duration::days(1); + } + + let report_cards = if planning.public_reports { + let first_activity_date = { + let db_conn = state.db.lock().await; + db::fetch_first_activity_date(&db_conn, user.id) + .map_err(internal_db_error)? + .and_then(|raw| NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()) + }; + vec![ + build_report_card_for_user(&state, user.id, "Daily (Today)", today, 1, None).await?, + build_report_card_for_user( + &state, + user.id, + "Rolling 7-Day", + today, + 7, + first_activity_date, + ) + .await?, + build_report_card_for_user( + &state, + user.id, + "Rolling 30-Day", + today, + 30, + first_activity_date, + ) + .await?, + ] + } else { + Vec::new() + }; + + let page = PublicProfilePageView { + username: user.username, + show_entries: planning.public_entries, + show_weights: planning.public_weights, + show_reports: planning.public_reports, + show_planning: planning.public_planning, + recent_days, + report_cards, + target_weight_label: planning + .target_weight + .map(|v| format!("{v:.1} lbs")) + .unwrap_or_else(|| "Not set".to_string()), + target_calories_label: planning + .target_calories + .map(|v| v.to_string()) + .unwrap_or_else(|| "Not set".to_string()), + bmr_label: planning + .bmr + .map(|v| format!("{v:.0} cal/day")) + .unwrap_or_else(|| "Not set".to_string()), + }; + Ok( + Html(views::render_public_profile_page(&page).map_err(internal_template_error)?) + .into_response(), + ) +} + +pub async fn show_reports( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + let user_id = auth.user.id; let today = Local::now().date_naive(); let planning = { let db_conn = state.db.lock().await; - db::fetch_planning(&db_conn).map_err(internal_db_error)? + db::fetch_planning(&db_conn, user_id).map_err(internal_db_error)? }; let first_activity_date = { let db_conn = state.db.lock().await; - db::fetch_first_activity_date(&db_conn) + db::fetch_first_activity_date(&db_conn, user_id) .map_err(internal_db_error)? .and_then(|raw| NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()) }; - let daily = build_report_card(&state, "Daily (Today)", today, 1, None).await?; - let rolling_7 = - build_report_card(&state, "Rolling 7-Day", today, 7, first_activity_date).await?; - let rolling_monthly = build_report_card( + let daily = + build_report_card_for_user(&state, user_id, "Daily (Today)", today, 1, None).await?; + let rolling_7 = build_report_card_for_user( &state, + user_id, + "Rolling 7-Day", + today, + 7, + first_activity_date, + ) + .await?; + let rolling_monthly = build_report_card_for_user( + &state, + user_id, "Rolling Monthly (Past 30 Days)", today, 30, first_activity_date, ) .await?; - let rolling_30 = - build_report_card(&state, "Rolling 30-Day", today, 30, first_activity_date).await?; + let rolling_30 = build_report_card_for_user( + &state, + user_id, + "Rolling 30-Day", + today, + 30, + first_activity_date, + ) + .await?; let chart_start = first_activity_date.unwrap_or(today - Days::new(29)); let (labels, rolling_weight, rolling_calories) = - build_rolling_3day_chart_series(&state, chart_start, today).await?; + build_rolling_3day_chart_series(&state, user_id, chart_start, today).await?; let rolling_loss = if let Some(bmr) = planning.bmr { rolling_calories .iter() @@ -92,15 +372,22 @@ pub async fn show_reports(State(state): State) -> Result, .unwrap_or_else(|| "Not set".to_string()), }; - let html = views::render_reports_page(&page).map_err(internal_template_error)?; - Ok(Html(html)) + let resp = + Html(views::render_reports_page(&page).map_err(internal_template_error)?).into_response(); + Ok(with_session_cookie(resp, &auth.token)) } -/// GET `/planning` - target weight/calorie/BMR configuration. -pub async fn show_planning(State(state): State) -> Result, AppError> { +pub async fn show_planning( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; let planning = { let db_conn = state.db.lock().await; - db::fetch_planning(&db_conn).map_err(internal_db_error)? + db::fetch_planning(&db_conn, auth.user.id).map_err(internal_db_error)? }; let page = PlanningPageView { @@ -113,37 +400,70 @@ pub async fn show_planning(State(state): State) -> Result .map(|v| v.to_string()) .unwrap_or_default(), bmr_value: planning.bmr.map(|v| format!("{v:.0}")).unwrap_or_default(), + public_entries: planning.public_entries, + public_weights: planning.public_weights, + public_reports: planning.public_reports, + public_planning: planning.public_planning, }; - let html = views::render_planning_page(&page).map_err(internal_template_error)?; - Ok(Html(html)) + let resp = + Html(views::render_planning_page(&page).map_err(internal_template_error)?).into_response(); + Ok(with_session_cookie(resp, &auth.token)) } -/// POST `/planning` - save target weight/calorie/BMR configuration. pub async fn update_planning( State(state): State, + headers: HeaderMap, Form(form): Form>, -) -> Result { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + let target_weight = parse_optional_positive_f64(form.get("target_weight"))?; let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?; let bmr = parse_optional_positive_f64(form.get("bmr"))?; + let public_entries = form.contains_key("public_entries"); + let public_weights = form.contains_key("public_weights"); + let public_reports = form.contains_key("public_reports"); + let public_planning = form.contains_key("public_planning"); let db_conn = state.db.lock().await; - db::upsert_planning(&db_conn, target_weight, target_calories, bmr) - .map_err(internal_db_error)?; - Ok(Redirect::to("/planning")) + db::upsert_planning( + &db_conn, + auth.user.id, + target_weight, + target_calories, + bmr, + public_entries, + public_weights, + public_reports, + public_planning, + ) + .map_err(internal_db_error)?; + Ok(with_session_cookie( + Redirect::to("/planning").into_response(), + &auth.token, + )) } -/// GET `/day/{date}` - list/edit entries for one day. pub async fn show_day_entries( State(state): State, + headers: HeaderMap, Path(date_text): Path, -) -> Result, AppError> { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; validate_date(&date_text)?; let (entries, weight) = { let db_conn = state.db.lock().await; - let entries = db::fetch_day_entries(&db_conn, &date_text).map_err(internal_db_error)?; - let weight = db::fetch_weight_for_day(&db_conn, &date_text).map_err(internal_db_error)?; + let entries = + db::fetch_day_entries(&db_conn, auth.user.id, &date_text).map_err(internal_db_error)?; + let weight = db::fetch_weight_for_day(&db_conn, auth.user.id, &date_text) + .map_err(internal_db_error)?; (entries, weight) }; @@ -153,17 +473,21 @@ pub async fn show_day_entries( entries, weight_value: weight.map(|w| format!("{w:.1}")).unwrap_or_default(), }; - - let html = views::render_day_page(&page).map_err(internal_template_error)?; - Ok(Html(html)) + let resp = + Html(views::render_day_page(&page).map_err(internal_template_error)?).into_response(); + Ok(with_session_cookie(resp, &auth.token)) } -/// POST `/day/{date}/weight` - set or update the day's weight entry. pub async fn update_day_weight( State(state): State, + headers: HeaderMap, Path(date_text): Path, Form(form): Form>, -) -> Result { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; validate_date(&date_text)?; let weight = form .get("weight") @@ -181,57 +505,85 @@ pub async fn update_day_weight( } let db_conn = state.db.lock().await; - db::upsert_weight_for_day(&db_conn, &date_text, weight).map_err(internal_db_error)?; - Ok(Redirect::to(&format!("/day/{date_text}"))) + db::upsert_weight_for_day(&db_conn, auth.user.id, &date_text, weight) + .map_err(internal_db_error)?; + Ok(with_session_cookie( + Redirect::to(&format!("/day/{date_text}")).into_response(), + &auth.token, + )) } -/// POST `/day/{date}/add` - create a new entry. pub async fn create_entry( State(state): State, + headers: HeaderMap, Path(date_text): Path, Form(form): Form>, -) -> Result { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; validate_date(&date_text)?; let (name, calories) = parse_entry_form_fields(&form)?; let db_conn = state.db.lock().await; - db::insert_entry(&db_conn, &date_text, &name, calories).map_err(internal_db_error)?; - - Ok(Redirect::to(&format!("/day/{date_text}"))) + db::insert_entry(&db_conn, auth.user.id, &date_text, &name, calories) + .map_err(internal_db_error)?; + Ok(with_session_cookie( + Redirect::to(&format!("/day/{date_text}")).into_response(), + &auth.token, + )) } -/// POST `/day/{date}/entry/{id}/update` - edit an existing entry. pub async fn edit_entry( State(state): State, + headers: HeaderMap, Path((date_text, id)): Path<(String, i64)>, Form(form): Form>, -) -> Result { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; validate_date(&date_text)?; let (name, calories) = parse_entry_form_fields(&form)?; let db_conn = state.db.lock().await; - db::update_entry(&db_conn, &date_text, id, &name, calories).map_err(internal_db_error)?; + db::update_entry(&db_conn, auth.user.id, &date_text, id, &name, calories) + .map_err(internal_db_error)?; - Ok(Redirect::to("/")) + Ok(with_session_cookie( + Redirect::to("/").into_response(), + &auth.token, + )) } -/// POST `/day/{date}/entry/{id}/delete` - remove an entry. pub async fn remove_entry( State(state): State, + headers: HeaderMap, Path((date_text, id)): Path<(String, i64)>, -) -> Result { +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; validate_date(&date_text)?; let db_conn = state.db.lock().await; - db::delete_entry(&db_conn, &date_text, id).map_err(internal_db_error)?; + db::delete_entry(&db_conn, auth.user.id, &date_text, id).map_err(internal_db_error)?; - Ok(Redirect::to(&format!("/day/{date_text}"))) + Ok(with_session_cookie( + Redirect::to(&format!("/day/{date_text}")).into_response(), + &auth.token, + )) } async fn render_calendar_for_month( - state: AppState, + state: &AppState, + auth: &AuthUser, focus_date: NaiveDate, -) -> Result, AppError> { +) -> Result { + let user_id = auth.user.id; let today = Local::now().date_naive(); let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?; @@ -239,6 +591,7 @@ async fn render_calendar_for_month( let db_conn = state.db.lock().await; db::fetch_month_summaries( &db_conn, + user_id, &first_day.format("%Y-%m-%d").to_string(), &first_of_next_month.format("%Y-%m-%d").to_string(), ) @@ -248,6 +601,7 @@ async fn render_calendar_for_month( let db_conn = state.db.lock().await; db::fetch_daily_weights_for_range( &db_conn, + user_id, &first_day.format("%Y-%m-%d").to_string(), &first_of_next_month.format("%Y-%m-%d").to_string(), ) @@ -255,7 +609,6 @@ async fn render_calendar_for_month( }; let mut cells = Vec::with_capacity(42); - let first_weekday = first_day.weekday().num_days_from_sunday() as usize; for _ in 0..first_weekday { cells.push(CalendarCellView::padding()); @@ -300,12 +653,14 @@ async fn render_calendar_for_month( cells, }; - let html = views::render_calendar_page(&page).map_err(internal_template_error)?; - Ok(Html(html)) + let resp = + Html(views::render_calendar_page(&page).map_err(internal_template_error)?).into_response(); + Ok(with_session_cookie(resp, &auth.token)) } -async fn build_report_card( +async fn build_report_card_for_user( state: &AppState, + user_id: i64, title: &str, end_day_inclusive: NaiveDate, day_count: i64, @@ -334,7 +689,7 @@ async fn build_report_card( let start_text = effective_start.format("%Y-%m-%d").to_string(); let end_text = window_end.format("%Y-%m-%d").to_string(); let db_conn = state.db.lock().await; - db::fetch_total_calories_for_range(&db_conn, &start_text, &end_text) + db::fetch_total_calories_for_range(&db_conn, user_id, &start_text, &end_text) .map_err(internal_db_error)? }; @@ -359,6 +714,7 @@ async fn build_report_card( async fn build_rolling_3day_chart_series( state: &AppState, + user_id: i64, start_day: NaiveDate, end_day: NaiveDate, ) -> Result<(Vec, Vec>, Vec), AppError> { @@ -368,9 +724,10 @@ async fn build_rolling_3day_chart_series( let (calorie_map, weight_map) = { let db_conn = state.db.lock().await; - let calories = db::fetch_daily_calorie_totals_for_range(&db_conn, &start_text, &end_text) - .map_err(internal_db_error)?; - let weights = db::fetch_daily_weights_for_range(&db_conn, &start_text, &end_text) + let calories = + db::fetch_daily_calorie_totals_for_range(&db_conn, user_id, &start_text, &end_text) + .map_err(internal_db_error)?; + let weights = db::fetch_daily_weights_for_range(&db_conn, user_id, &start_text, &end_text) .map_err(internal_db_error)?; (calories, weights) }; @@ -378,7 +735,6 @@ async fn build_rolling_3day_chart_series( let mut labels = Vec::new(); let mut daily_calories = Vec::new(); let mut daily_weights = Vec::new(); - let mut day = start_day; while day <= end_day { let date_key = day.format("%Y-%m-%d").to_string(); @@ -393,8 +749,7 @@ async fn build_rolling_3day_chart_series( for i in 0..labels.len() { let start_idx = i.saturating_sub(2); let cal_window = &daily_calories[start_idx..=i]; - let cal_avg = cal_window.iter().sum::() / cal_window.len() as f64; - rolling_calories.push(cal_avg); + rolling_calories.push(cal_window.iter().sum::() / cal_window.len() as f64); let mut weight_sum = 0.0; let mut weight_count = 0; @@ -404,14 +759,144 @@ async fn build_rolling_3day_chart_series( weight_count += 1; } } - if weight_count == 0 { - rolling_weights.push(None); + rolling_weights.push(if weight_count == 0 { + None } else { - rolling_weights.push(Some(weight_sum / weight_count as f64)); - } + Some(weight_sum / weight_count as f64) + }); + } + Ok((labels, rolling_weights, rolling_calories)) +} + +async fn get_auth_user( + state: &AppState, + headers: &HeaderMap, +) -> Result, AppError> { + let Some(token) = session_token_from_headers(headers) else { + return Ok(None); + }; + + let now = Utc::now().timestamp(); + let session = { + let db_conn = state.db.lock().await; + db::fetch_session(&db_conn, &token).map_err(internal_db_error)? + }; + let Some(session) = session else { + return Ok(None); + }; + if session.expires_at_unix <= now { + let db_conn = state.db.lock().await; + let _ = db::delete_session(&db_conn, &token); + return Ok(None); } - Ok((labels, rolling_weights, rolling_calories)) + let user = { + let db_conn = state.db.lock().await; + db::fetch_user_by_id(&db_conn, session.user_id).map_err(internal_db_error)? + }; + let Some(user) = user else { + return Ok(None); + }; + + let new_expiry = session_expiry_unix(); + { + let db_conn = state.db.lock().await; + db::touch_session(&db_conn, &token, new_expiry).map_err(internal_db_error)?; + } + + Ok(Some(AuthUser { user, token })) +} + +async fn signup_allowed(state: &AppState) -> Result { + if state.config.allow_signup { + return Ok(true); + } + let db_conn = state.db.lock().await; + let count = db::count_users(&db_conn).map_err(internal_db_error)?; + Ok(count == 0) +} + +async fn render_login_with_error(state: &AppState, error: &str) -> Result { + let page = LoginPageView { + allow_signup: signup_allowed(state).await?, + error: error.to_string(), + }; + Ok(Html(views::render_login_page(&page).map_err(internal_template_error)?).into_response()) +} + +async fn render_signup_with_error(state: &AppState, error: &str) -> Result { + let page = SignupPageView { + allow_signup: signup_allowed(state).await?, + error: error.to_string(), + }; + Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response()) +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut ArgonOsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Password hashing failed".to_string(), + ) + }) +} + +fn verify_password(password_hash: &str, password: &str) -> bool { + let parsed = match PasswordHash::new(password_hash) { + Ok(v) => v, + Err(_) => return false, + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +fn generate_session_token() -> String { + let mut bytes = [0_u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect::() +} + +fn session_expiry_unix() -> i64 { + Utc::now().timestamp() + SESSION_SECONDS +} + +fn session_token_from_headers(headers: &HeaderMap) -> Option { + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + for part in raw.split(';') { + let trimmed = part.trim(); + let (name, value) = trimmed.split_once('=')?; + if name == SESSION_COOKIE { + return Some(value.to_string()); + } + } + None +} + +fn with_session_cookie(mut response: Response, token: &str) -> Response { + let cookie = format!( + "{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000", + name = SESSION_COOKIE + ); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +fn clear_session_cookie(mut response: Response) -> Response { + let cookie = format!( + "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", + name = SESSION_COOKIE + ); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response } fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppError> { @@ -419,18 +904,15 @@ fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppErro StatusCode::INTERNAL_SERVER_ERROR, "Invalid date".to_string(), ))?; - let (next_year, next_month) = if today.month() == 12 { (today.year() + 1, 1) } else { (today.year(), today.month() + 1) }; - let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or(( StatusCode::INTERNAL_SERVER_ERROR, "Invalid date".to_string(), ))?; - let days_in_month = (first_of_next_month - first_day).num_days(); Ok((first_day, first_of_next_month, days_in_month)) } @@ -441,13 +923,11 @@ fn calendar_nav_for_month(focus_date: NaiveDate, today: NaiveDate) -> CalendarNa } else { NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() - 1, 1).expect("valid date") }; - let next_month = if focus_date.month() == 12 { NaiveDate::from_ymd_opt(focus_date.year() + 1, 1, 1).expect("valid date") } else { NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() + 1, 1).expect("valid date") }; - let prev_year = NaiveDate::from_ymd_opt(focus_date.year() - 1, focus_date.month(), 1).expect("valid date"); let next_year = @@ -493,14 +973,12 @@ fn parse_entry_form_fields(form: &HashMap) -> Result<(String, i6 "Calories must be a number".to_string(), ) })?; - if name.is_empty() { return Err((StatusCode::BAD_REQUEST, "Name is required".to_string())); } if calories < 0 { return Err((StatusCode::BAD_REQUEST, "Calories must be >= 0".to_string())); } - Ok((name.to_string(), calories)) } @@ -515,14 +993,11 @@ fn parse_optional_positive_f64(raw: Option<&String>) -> Result, AppE let parsed = trimmed.parse::().map_err(|_| { ( StatusCode::BAD_REQUEST, - "Target weight must be a number".to_string(), + "Value must be a number".to_string(), ) })?; if parsed <= 0.0 { - return Err(( - StatusCode::BAD_REQUEST, - "Target weight must be > 0".to_string(), - )); + return Err((StatusCode::BAD_REQUEST, "Value must be > 0".to_string())); } Ok(Some(parsed)) } diff --git a/src/main.rs b/src/main.rs index b887dc5..aa80c16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod config; mod db; mod handlers; mod views; @@ -23,14 +24,22 @@ async fn main() -> Result<(), Box> { let conn = Connection::open(DB_PATH)?; db::init_db(&conn)?; db::seed_db_if_empty(&conn)?; + let app_config = config::load_config(); let state = AppState { db: Arc::new(Mutex::new(conn)), + config: app_config, }; // Route wiring stays here so `main` is the single startup overview. let app = Router::new() .route("/", get(handlers::show_calendar)) + .route("/login", get(handlers::show_login)) + .route("/login", post(handlers::login)) + .route("/signup", get(handlers::show_signup)) + .route("/signup", post(handlers::signup)) + .route("/logout", post(handlers::logout)) + .route("/u/{username}", get(handlers::show_public_profile)) .route("/reports", get(handlers::show_reports)) .route("/planning", get(handlers::show_planning)) .route("/planning", post(handlers::update_planning)) diff --git a/src/views.rs b/src/views.rs index 4006e20..cbb0054 100644 --- a/src/views.rs +++ b/src/views.rs @@ -147,6 +147,40 @@ pub struct PlanningPageView { pub target_weight_value: String, pub target_calories_value: String, pub bmr_value: String, + pub public_entries: bool, + pub public_weights: bool, + pub public_reports: bool, + pub public_planning: bool, +} + +pub struct LoginPageView { + pub allow_signup: bool, + pub error: String, +} + +pub struct SignupPageView { + pub allow_signup: bool, + pub error: String, +} + +#[derive(Clone)] +pub struct PublicDaySummaryView { + pub date_text: String, + pub calories: i64, + pub weight_label: String, +} + +pub struct PublicProfilePageView { + pub username: String, + pub show_entries: bool, + pub show_weights: bool, + pub show_reports: bool, + pub show_planning: bool, + pub recent_days: Vec, + pub report_cards: Vec, + pub target_weight_label: String, + pub target_calories_label: String, + pub bmr_label: String, } #[derive(Template)] @@ -173,6 +207,24 @@ struct PlanningTemplate<'a> { page: &'a PlanningPageView, } +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate<'a> { + page: &'a LoginPageView, +} + +#[derive(Template)] +#[template(path = "signup.html")] +struct SignupTemplate<'a> { + page: &'a SignupPageView, +} + +#[derive(Template)] +#[template(path = "public_profile.html")] +struct PublicProfileTemplate<'a> { + page: &'a PublicProfilePageView, +} + impl CalendarTemplate<'_> { fn active_tab(&self) -> &str { "calendar" @@ -197,6 +249,24 @@ impl PlanningTemplate<'_> { } } +impl LoginTemplate<'_> { + fn active_tab(&self) -> &str { + "" + } +} + +impl SignupTemplate<'_> { + fn active_tab(&self) -> &str { + "" + } +} + +impl PublicProfileTemplate<'_> { + fn active_tab(&self) -> &str { + "" + } +} + pub fn render_calendar_page(page: &CalendarPageView) -> Result { CalendarTemplate { page }.render() } @@ -212,3 +282,15 @@ pub fn render_reports_page(page: &ReportsPageView) -> Result Result { PlanningTemplate { page }.render() } + +pub fn render_login_page(page: &LoginPageView) -> Result { + LoginTemplate { page }.render() +} + +pub fn render_signup_page(page: &SignupPageView) -> Result { + SignupTemplate { page }.render() +} + +pub fn render_public_profile_page(page: &PublicProfilePageView) -> Result { + PublicProfileTemplate { page }.render() +} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..6245266 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Sign In{% endblock %} + +{% block content %} +
+

Sign In

+

Sign in to view and edit your private data.

+ {% if !page.error.is_empty() %} +

{{ page.error }}

+ {% endif %} +
+ + + +
+ {% if page.allow_signup %} +

Need an account? Sign up

+ {% endif %} +
+{% endblock %} diff --git a/templates/planning.html b/templates/planning.html index 61d0c23..2d4d0e3 100644 --- a/templates/planning.html +++ b/templates/planning.html @@ -46,6 +46,26 @@ /> +
+ Public profile visibility + + + + +
+
diff --git a/templates/public_profile.html b/templates/public_profile.html new file mode 100644 index 0000000..2c65b2d --- /dev/null +++ b/templates/public_profile.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %}Public Profile{% endblock %} + +{% block content %} +
+

@{{ page.username }}

+

Public profile

+
+ +{% if page.show_entries || page.show_weights %} +
+

Recent Days

+
+ + + + + + {% for row in page.recent_days %} + + + + + + {% endfor %} + +
DateCaloriesWeight
{{ row.date_text }}{% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %}{% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}
+
+
+{% endif %} + +{% if page.show_reports %} +
+ {% for card in page.report_cards %} +
+

{{ card.title }}

+

{{ card.range_label }}

+

{{ card.average_calories_per_day }}

+

avg cal/day

+
+ {% endfor %} +
+{% endif %} + +{% if page.show_planning %} +
+

Planning

+
+
Target weight (lbs)
{{ page.target_weight_label }}
+
Target calories
{{ page.target_calories_label }}
+
BMR
{{ page.bmr_label }}
+
+
+{% endif %} + +{% if !page.show_entries && !page.show_weights && !page.show_reports && !page.show_planning %} +
+

This user has not shared any data publicly.

+
+{% endif %} +{% endblock %} diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000..6e1f889 --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Sign Up{% endblock %} + +{% block content %} +
+

Create Account

+

First account created becomes admin.

+ {% if !page.allow_signup %} +

Sign-up is disabled by server config.

+ {% endif %} + {% if !page.error.is_empty() %} +

{{ page.error }}

+ {% endif %} +
+ + + +
+

Already have an account? Sign in

+
+{% endblock %}