saving
This commit is contained in:
parent
d781a42107
commit
ec125b9366
|
|
@ -1,2 +1,3 @@
|
||||||
/target
|
/target
|
||||||
|
config/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ version = 4
|
||||||
name = "CurseTechnique"
|
name = "CurseTechnique"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"rand",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
@ -34,6 +36,18 @@ dependencies = [
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "askama"
|
name = "askama"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -142,6 +156,12 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "basic-toml"
|
name = "basic-toml"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
|
|
@ -157,6 +177,24 @@ version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
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]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
|
|
@ -204,6 +242,36 @@ version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
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]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
|
|
@ -274,6 +342,27 @@ dependencies = [
|
||||||
"pin-utils",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -553,6 +642,17 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -577,6 +677,15 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
|
|
@ -595,6 +704,36 @@ dependencies = [
|
||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -734,6 +873,12 @@ dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.114"
|
version = "2.0.114"
|
||||||
|
|
@ -827,6 +972,12 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
argon2 = "0.5.3"
|
||||||
askama = "0.12.1"
|
askama = "0.12.1"
|
||||||
axum = "0.8.1"
|
axum = "0.8.1"
|
||||||
chrono = { version = "0.4.40", features = ["clock"] }
|
chrono = { version = "0.4.40", features = ["clock"] }
|
||||||
|
rand = "0.8.5"
|
||||||
rusqlite = { version = "0.31.0", features = ["bundled"] }
|
rusqlite = { version = "0.31.0", features = ["bundled"] }
|
||||||
tokio = { version = "1.44.0", features = ["full"] }
|
tokio = { version = "1.44.0", features = ["full"] }
|
||||||
|
|
|
||||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
|
|
@ -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);
|
||||||
|
}
|
||||||
364
src/db.rs
364
src/db.rs
|
|
@ -3,7 +3,6 @@ use std::collections::HashMap;
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
/// A single food entry row from `food_entries`.
|
|
||||||
pub struct FoodEntry {
|
pub struct FoodEntry {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -11,70 +10,216 @@ pub struct FoodEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
/// Aggregated daily values used by the calendar grid.
|
|
||||||
pub struct DaySummary {
|
pub struct DaySummary {
|
||||||
pub entry_count: i64,
|
pub entry_count: i64,
|
||||||
pub total_calories: 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)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct PlanningConfig {
|
pub struct PlanningConfig {
|
||||||
pub target_weight: Option<f64>,
|
pub target_weight: Option<f64>,
|
||||||
pub target_calories: Option<i64>,
|
pub target_calories: Option<i64>,
|
||||||
pub bmr: Option<f64>,
|
pub bmr: Option<f64>,
|
||||||
|
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> {
|
pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS food_entries (
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
entry_date TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
calories INTEGER NOT NULL CHECK (calories >= 0),
|
calories INTEGER NOT NULL CHECK (calories >= 0),
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS user_daily_weights (
|
||||||
entry_date TEXT PRIMARY KEY,
|
user_id INTEGER NOT NULL,
|
||||||
weight REAL NOT NULL CHECK (weight > 0)
|
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 (
|
CREATE TABLE IF NOT EXISTS user_planning (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
user_id INTEGER PRIMARY KEY,
|
||||||
target_weight REAL,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder seeding hook. Currently no-op unless you add seed inserts.
|
|
||||||
pub fn seed_db_if_empty(conn: &Connection) -> Result<(), rusqlite::Error> {
|
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))?;
|
let _count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
|
||||||
if count > 0 {
|
Ok(())
|
||||||
return Ok(());
|
}
|
||||||
}
|
|
||||||
|
pub fn count_users(conn: &Connection) -> Result<i64, rusqlite::Error> {
|
||||||
|
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<i64, rusqlite::Error> {
|
||||||
|
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<Option<UserRecord>, 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<Option<UserRecord>, 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<Option<SessionRecord>, 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns one summary per date for the given half-open range [start_date, end_date).
|
|
||||||
pub fn fetch_month_summaries(
|
pub fn fetch_month_summaries(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
end_date: &str,
|
end_date: &str,
|
||||||
) -> Result<HashMap<String, DaySummary>, rusqlite::Error> {
|
) -> Result<HashMap<String, DaySummary>, rusqlite::Error> {
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT entry_date, COUNT(*) as entry_count, SUM(calories) as total
|
"SELECT entry_date, COUNT(*) as entry_count, SUM(calories) as total
|
||||||
FROM food_entries
|
FROM user_food_entries
|
||||||
WHERE entry_date >= ?1 AND entry_date < ?2
|
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3
|
||||||
GROUP BY entry_date",
|
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::<String, DaySummary>::new();
|
let mut totals = HashMap::<String, DaySummary>::new();
|
||||||
while let Some(row) = rows.next()? {
|
while let Some(row) = rows.next()? {
|
||||||
|
|
@ -89,20 +234,21 @@ pub fn fetch_month_summaries(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(totals)
|
Ok(totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all entries for one day.
|
pub fn fetch_day_entries(
|
||||||
pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result<Vec<FoodEntry>, rusqlite::Error> {
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
|
date: &str,
|
||||||
|
) -> Result<Vec<FoodEntry>, rusqlite::Error> {
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT id, name, calories
|
"SELECT id, name, calories
|
||||||
FROM food_entries
|
FROM user_food_entries
|
||||||
WHERE entry_date = ?1
|
WHERE user_id = ?1 AND entry_date = ?2
|
||||||
ORDER BY id",
|
ORDER BY id",
|
||||||
)?;
|
)?;
|
||||||
|
let iter = statement.query_map(params![user_id, date], |row| {
|
||||||
let iter = statement.query_map(params![date], |row| {
|
|
||||||
Ok(FoodEntry {
|
Ok(FoodEntry {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
name: row.get(1)?,
|
name: row.get(1)?,
|
||||||
|
|
@ -114,53 +260,53 @@ pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result<Vec<FoodEntry>
|
||||||
for row in iter {
|
for row in iter {
|
||||||
entries.push(row?);
|
entries.push(row?);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns total calories in the half-open range [start_date, end_date).
|
|
||||||
pub fn fetch_total_calories_for_range(
|
pub fn fetch_total_calories_for_range(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
end_date: &str,
|
end_date: &str,
|
||||||
) -> Result<i64, rusqlite::Error> {
|
) -> Result<i64, rusqlite::Error> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COALESCE(SUM(calories), 0)
|
"SELECT COALESCE(SUM(calories), 0)
|
||||||
FROM food_entries
|
FROM user_food_entries
|
||||||
WHERE entry_date >= ?1 AND entry_date < ?2",
|
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3",
|
||||||
params![start_date, end_date],
|
params![user_id, start_date, end_date],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the earliest date with either calories or weight data.
|
pub fn fetch_first_activity_date(
|
||||||
pub fn fetch_first_activity_date(conn: &Connection) -> Result<Option<String>, rusqlite::Error> {
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
|
) -> Result<Option<String>, rusqlite::Error> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT MIN(entry_date)
|
"SELECT MIN(entry_date)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT entry_date FROM food_entries
|
SELECT entry_date FROM user_food_entries WHERE user_id = ?1
|
||||||
UNION ALL
|
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),
|
|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(
|
pub fn fetch_daily_calorie_totals_for_range(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
end_date: &str,
|
end_date: &str,
|
||||||
) -> Result<HashMap<String, i64>, rusqlite::Error> {
|
) -> Result<HashMap<String, i64>, rusqlite::Error> {
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT entry_date, COALESCE(SUM(calories), 0)
|
"SELECT entry_date, COALESCE(SUM(calories), 0)
|
||||||
FROM food_entries
|
FROM user_food_entries
|
||||||
WHERE entry_date >= ?1 AND entry_date < ?2
|
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3
|
||||||
GROUP BY entry_date",
|
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();
|
let mut out = HashMap::new();
|
||||||
while let Some(row) = rows.next()? {
|
while let Some(row) = rows.next()? {
|
||||||
let date: String = row.get(0)?;
|
let date: String = row.get(0)?;
|
||||||
|
|
@ -170,19 +316,18 @@ pub fn fetch_daily_calorie_totals_for_range(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a map date -> weight for the half-open range [start_date, end_date).
|
|
||||||
pub fn fetch_daily_weights_for_range(
|
pub fn fetch_daily_weights_for_range(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
end_date: &str,
|
end_date: &str,
|
||||||
) -> Result<HashMap<String, f64>, rusqlite::Error> {
|
) -> Result<HashMap<String, f64>, rusqlite::Error> {
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT entry_date, weight
|
"SELECT entry_date, weight
|
||||||
FROM daily_weights
|
FROM user_daily_weights
|
||||||
WHERE entry_date >= ?1 AND entry_date < ?2",
|
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();
|
let mut out = HashMap::new();
|
||||||
while let Some(row) = rows.next()? {
|
while let Some(row) = rows.next()? {
|
||||||
let date: String = row.get(0)?;
|
let date: String = row.get(0)?;
|
||||||
|
|
@ -192,10 +337,14 @@ pub fn fetch_daily_weights_for_range(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result<Option<f64>, rusqlite::Error> {
|
pub fn fetch_weight_for_day(
|
||||||
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
|
date: &str,
|
||||||
|
) -> Result<Option<f64>, rusqlite::Error> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT weight FROM daily_weights WHERE entry_date = ?1",
|
"SELECT weight FROM user_daily_weights WHERE user_id = ?1 AND entry_date = ?2",
|
||||||
params![date],
|
params![user_id, date],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
|
|
@ -203,112 +352,133 @@ pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result<Option<f64>
|
||||||
|
|
||||||
pub fn upsert_weight_for_day(
|
pub fn upsert_weight_for_day(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
date: &str,
|
date: &str,
|
||||||
weight: f64,
|
weight: f64,
|
||||||
) -> Result<(), rusqlite::Error> {
|
) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO daily_weights (entry_date, weight)
|
"INSERT INTO user_daily_weights (user_id, entry_date, weight)
|
||||||
VALUES (?1, ?2)
|
VALUES (?1, ?2, ?3)
|
||||||
ON CONFLICT(entry_date) DO UPDATE SET weight = excluded.weight",
|
ON CONFLICT(user_id, entry_date) DO UPDATE SET weight = excluded.weight",
|
||||||
params![date, weight],
|
params![user_id, date, weight],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fetch_planning(conn: &Connection) -> Result<PlanningConfig, rusqlite::Error> {
|
pub fn fetch_planning(conn: &Connection, user_id: i64) -> Result<PlanningConfig, rusqlite::Error> {
|
||||||
let row: Option<(Option<f64>, Option<i64>, Option<f64>)> = conn
|
let row: Option<(Option<f64>, Option<i64>, Option<f64>, i64, i64, i64, i64)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT target_weight, target_calories, bmr FROM planning WHERE id = 1",
|
"SELECT target_weight, target_calories, bmr, public_entries, public_weights, public_reports, public_planning
|
||||||
[],
|
FROM user_planning
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
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()?;
|
.optional()?;
|
||||||
|
|
||||||
match row {
|
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_weight,
|
||||||
target_calories,
|
target_calories,
|
||||||
bmr,
|
bmr,
|
||||||
|
public_entries: pe == 1,
|
||||||
|
public_weights: pw == 1,
|
||||||
|
public_reports: pr == 1,
|
||||||
|
public_planning: pp == 1,
|
||||||
}),
|
}),
|
||||||
None => Ok(PlanningConfig {
|
None => Ok(PlanningConfig {
|
||||||
target_weight: None,
|
target_weight: None,
|
||||||
target_calories: None,
|
target_calories: None,
|
||||||
bmr: None,
|
bmr: None,
|
||||||
|
public_entries: false,
|
||||||
|
public_weights: false,
|
||||||
|
public_reports: false,
|
||||||
|
public_planning: false,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_planning(
|
pub fn upsert_planning(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
target_weight: Option<f64>,
|
target_weight: Option<f64>,
|
||||||
target_calories: Option<i64>,
|
target_calories: Option<i64>,
|
||||||
bmr: Option<f64>,
|
bmr: Option<f64>,
|
||||||
|
public_entries: bool,
|
||||||
|
public_weights: bool,
|
||||||
|
public_reports: bool,
|
||||||
|
public_planning: bool,
|
||||||
) -> Result<(), rusqlite::Error> {
|
) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO planning (id, target_weight, target_calories, bmr)
|
"INSERT INTO user_planning (
|
||||||
VALUES (1, ?1, ?2, ?3)
|
user_id, target_weight, target_calories, bmr,
|
||||||
ON CONFLICT(id) DO UPDATE
|
public_entries, public_weights, public_reports, public_planning
|
||||||
SET target_weight = excluded.target_weight,
|
)
|
||||||
|
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,
|
target_calories = excluded.target_calories,
|
||||||
bmr = excluded.bmr",
|
bmr = excluded.bmr,
|
||||||
params![target_weight, target_calories, 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(())
|
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(
|
pub fn insert_entry(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
date: &str,
|
date: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
calories: i64,
|
calories: i64,
|
||||||
) -> Result<(), rusqlite::Error> {
|
) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO food_entries (entry_date, name, calories) VALUES (?1, ?2, ?3)",
|
"INSERT INTO user_food_entries (user_id, entry_date, name, calories)
|
||||||
params![date, name, calories],
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![user_id, date, name, calories],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates one entry if both `id` and `date` match.
|
|
||||||
pub fn update_entry(
|
pub fn update_entry(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
date: &str,
|
date: &str,
|
||||||
id: i64,
|
id: i64,
|
||||||
name: &str,
|
name: &str,
|
||||||
calories: i64,
|
calories: i64,
|
||||||
) -> Result<(), rusqlite::Error> {
|
) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE food_entries
|
"UPDATE user_food_entries
|
||||||
SET name = ?1, calories = ?2
|
SET name = ?1, calories = ?2
|
||||||
WHERE id = ?3 AND entry_date = ?4",
|
WHERE user_id = ?3 AND id = ?4 AND entry_date = ?5",
|
||||||
params![name, calories, id, date],
|
params![name, calories, user_id, id, date],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes one entry if both `id` and `date` match.
|
pub fn delete_entry(
|
||||||
pub fn delete_entry(conn: &Connection, date: &str, id: i64) -> Result<(), rusqlite::Error> {
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
|
date: &str,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM food_entries WHERE id = ?1 AND entry_date = ?2",
|
"DELETE FROM user_food_entries
|
||||||
params![id, date],
|
WHERE user_id = ?1 AND id = ?2 AND entry_date = ?3",
|
||||||
|
params![user_id, id, date],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
647
src/handlers.rs
647
src/handlers.rs
|
|
@ -1,75 +1,355 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
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::extract::{Form, Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||||
use axum::response::{Html, Redirect};
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
use chrono::{Datelike, Days, Duration, Local, NaiveDate};
|
use chrono::{Datelike, Days, Duration, Local, NaiveDate, Utc};
|
||||||
|
use rand::RngCore;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::views::{
|
use crate::views::{
|
||||||
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, PlanningPageView,
|
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView,
|
||||||
ReportCardView, ReportsPageView,
|
PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView,
|
||||||
|
SignupPageView,
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppError = (StatusCode, String);
|
type AppError = (StatusCode, String);
|
||||||
|
const SESSION_COOKIE: &str = "session_token";
|
||||||
|
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
/// Shared application state injected into each handler.
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Mutex<Connection>>,
|
pub db: Arc<Mutex<Connection>>,
|
||||||
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET `/` - calendar overview for the current month.
|
#[derive(Clone)]
|
||||||
pub async fn show_calendar(State(state): State<AppState>) -> Result<Html<String>, AppError> {
|
struct AuthUser {
|
||||||
render_calendar_for_month(state, Local::now().date_naive()).await
|
user: db::UserRecord,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn show_calendar(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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(
|
pub async fn show_calendar_for_month(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((year, month)): Path<(i32, u32)>,
|
Path((year, month)): Path<(i32, u32)>,
|
||||||
) -> Result<Html<String>, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
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((
|
let focus_date = NaiveDate::from_ymd_opt(year, month, 1).ok_or((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"Month route must use /calendar/{year}/{month} with month 1-12".to_string(),
|
"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_login(
|
||||||
pub async fn show_reports(State(state): State<AppState>) -> Result<Html<String>, AppError> {
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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 today = Local::now().date_naive();
|
||||||
let planning = {
|
let planning = {
|
||||||
let db_conn = state.db.lock().await;
|
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 first_activity_date = {
|
||||||
let db_conn = state.db.lock().await;
|
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)?
|
.map_err(internal_db_error)?
|
||||||
.and_then(|raw| NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok())
|
.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 daily =
|
||||||
let rolling_7 =
|
build_report_card_for_user(&state, user_id, "Daily (Today)", today, 1, None).await?;
|
||||||
build_report_card(&state, "Rolling 7-Day", today, 7, first_activity_date).await?;
|
let rolling_7 = build_report_card_for_user(
|
||||||
let rolling_monthly = build_report_card(
|
|
||||||
&state,
|
&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)",
|
"Rolling Monthly (Past 30 Days)",
|
||||||
today,
|
today,
|
||||||
30,
|
30,
|
||||||
first_activity_date,
|
first_activity_date,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let rolling_30 =
|
let rolling_30 = build_report_card_for_user(
|
||||||
build_report_card(&state, "Rolling 30-Day", today, 30, first_activity_date).await?;
|
&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 chart_start = first_activity_date.unwrap_or(today - Days::new(29));
|
||||||
let (labels, rolling_weight, rolling_calories) =
|
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 {
|
let rolling_loss = if let Some(bmr) = planning.bmr {
|
||||||
rolling_calories
|
rolling_calories
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -92,15 +372,22 @@ pub async fn show_reports(State(state): State<AppState>) -> Result<Html<String>,
|
||||||
.unwrap_or_else(|| "Not set".to_string()),
|
.unwrap_or_else(|| "Not set".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = views::render_reports_page(&page).map_err(internal_template_error)?;
|
let resp =
|
||||||
Ok(Html(html))
|
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(
|
||||||
pub async fn show_planning(State(state): State<AppState>) -> Result<Html<String>, AppError> {
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
let planning = {
|
let planning = {
|
||||||
let db_conn = state.db.lock().await;
|
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 {
|
let page = PlanningPageView {
|
||||||
|
|
@ -113,37 +400,70 @@ pub async fn show_planning(State(state): State<AppState>) -> Result<Html<String>
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
bmr_value: planning.bmr.map(|v| format!("{v:.0}")).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)?;
|
let resp =
|
||||||
Ok(Html(html))
|
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(
|
pub async fn update_planning(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Form(form): Form<HashMap<String, String>>,
|
Form(form): Form<HashMap<String, String>>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
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_weight = parse_optional_positive_f64(form.get("target_weight"))?;
|
||||||
let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?;
|
let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?;
|
||||||
let bmr = parse_optional_positive_f64(form.get("bmr"))?;
|
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;
|
let db_conn = state.db.lock().await;
|
||||||
db::upsert_planning(&db_conn, target_weight, target_calories, bmr)
|
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)?;
|
.map_err(internal_db_error)?;
|
||||||
Ok(Redirect::to("/planning"))
|
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(
|
pub async fn show_day_entries(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(date_text): Path<String>,
|
Path(date_text): Path<String>,
|
||||||
) -> Result<Html<String>, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
validate_date(&date_text)?;
|
validate_date(&date_text)?;
|
||||||
|
|
||||||
let (entries, weight) = {
|
let (entries, weight) = {
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
let entries = db::fetch_day_entries(&db_conn, &date_text).map_err(internal_db_error)?;
|
let entries =
|
||||||
let weight = db::fetch_weight_for_day(&db_conn, &date_text).map_err(internal_db_error)?;
|
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)
|
(entries, weight)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -153,17 +473,21 @@ pub async fn show_day_entries(
|
||||||
entries,
|
entries,
|
||||||
weight_value: weight.map(|w| format!("{w:.1}")).unwrap_or_default(),
|
weight_value: weight.map(|w| format!("{w:.1}")).unwrap_or_default(),
|
||||||
};
|
};
|
||||||
|
let resp =
|
||||||
let html = views::render_day_page(&page).map_err(internal_template_error)?;
|
Html(views::render_day_page(&page).map_err(internal_template_error)?).into_response();
|
||||||
Ok(Html(html))
|
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(
|
pub async fn update_day_weight(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(date_text): Path<String>,
|
Path(date_text): Path<String>,
|
||||||
Form(form): Form<HashMap<String, String>>,
|
Form(form): Form<HashMap<String, String>>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
validate_date(&date_text)?;
|
validate_date(&date_text)?;
|
||||||
let weight = form
|
let weight = form
|
||||||
.get("weight")
|
.get("weight")
|
||||||
|
|
@ -181,57 +505,85 @@ pub async fn update_day_weight(
|
||||||
}
|
}
|
||||||
|
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
db::upsert_weight_for_day(&db_conn, &date_text, weight).map_err(internal_db_error)?;
|
db::upsert_weight_for_day(&db_conn, auth.user.id, &date_text, weight)
|
||||||
Ok(Redirect::to(&format!("/day/{date_text}")))
|
.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(
|
pub async fn create_entry(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(date_text): Path<String>,
|
Path(date_text): Path<String>,
|
||||||
Form(form): Form<HashMap<String, String>>,
|
Form(form): Form<HashMap<String, String>>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
validate_date(&date_text)?;
|
validate_date(&date_text)?;
|
||||||
let (name, calories) = parse_entry_form_fields(&form)?;
|
let (name, calories) = parse_entry_form_fields(&form)?;
|
||||||
|
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
db::insert_entry(&db_conn, &date_text, &name, calories).map_err(internal_db_error)?;
|
db::insert_entry(&db_conn, auth.user.id, &date_text, &name, calories)
|
||||||
|
.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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST `/day/{date}/entry/{id}/update` - edit an existing entry.
|
|
||||||
pub async fn edit_entry(
|
pub async fn edit_entry(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((date_text, id)): Path<(String, i64)>,
|
Path((date_text, id)): Path<(String, i64)>,
|
||||||
Form(form): Form<HashMap<String, String>>,
|
Form(form): Form<HashMap<String, String>>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
validate_date(&date_text)?;
|
validate_date(&date_text)?;
|
||||||
let (name, calories) = parse_entry_form_fields(&form)?;
|
let (name, calories) = parse_entry_form_fields(&form)?;
|
||||||
|
|
||||||
let db_conn = state.db.lock().await;
|
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(
|
pub async fn remove_entry(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((date_text, id)): Path<(String, i64)>,
|
Path((date_text, id)): Path<(String, i64)>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
validate_date(&date_text)?;
|
validate_date(&date_text)?;
|
||||||
|
|
||||||
let db_conn = state.db.lock().await;
|
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(
|
async fn render_calendar_for_month(
|
||||||
state: AppState,
|
state: &AppState,
|
||||||
|
auth: &AuthUser,
|
||||||
focus_date: NaiveDate,
|
focus_date: NaiveDate,
|
||||||
) -> Result<Html<String>, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let user_id = auth.user.id;
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?;
|
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;
|
let db_conn = state.db.lock().await;
|
||||||
db::fetch_month_summaries(
|
db::fetch_month_summaries(
|
||||||
&db_conn,
|
&db_conn,
|
||||||
|
user_id,
|
||||||
&first_day.format("%Y-%m-%d").to_string(),
|
&first_day.format("%Y-%m-%d").to_string(),
|
||||||
&first_of_next_month.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;
|
let db_conn = state.db.lock().await;
|
||||||
db::fetch_daily_weights_for_range(
|
db::fetch_daily_weights_for_range(
|
||||||
&db_conn,
|
&db_conn,
|
||||||
|
user_id,
|
||||||
&first_day.format("%Y-%m-%d").to_string(),
|
&first_day.format("%Y-%m-%d").to_string(),
|
||||||
&first_of_next_month.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 mut cells = Vec::with_capacity(42);
|
||||||
|
|
||||||
let first_weekday = first_day.weekday().num_days_from_sunday() as usize;
|
let first_weekday = first_day.weekday().num_days_from_sunday() as usize;
|
||||||
for _ in 0..first_weekday {
|
for _ in 0..first_weekday {
|
||||||
cells.push(CalendarCellView::padding());
|
cells.push(CalendarCellView::padding());
|
||||||
|
|
@ -300,12 +653,14 @@ async fn render_calendar_for_month(
|
||||||
cells,
|
cells,
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = views::render_calendar_page(&page).map_err(internal_template_error)?;
|
let resp =
|
||||||
Ok(Html(html))
|
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,
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
title: &str,
|
title: &str,
|
||||||
end_day_inclusive: NaiveDate,
|
end_day_inclusive: NaiveDate,
|
||||||
day_count: i64,
|
day_count: i64,
|
||||||
|
|
@ -334,7 +689,7 @@ async fn build_report_card(
|
||||||
let start_text = effective_start.format("%Y-%m-%d").to_string();
|
let start_text = effective_start.format("%Y-%m-%d").to_string();
|
||||||
let end_text = window_end.format("%Y-%m-%d").to_string();
|
let end_text = window_end.format("%Y-%m-%d").to_string();
|
||||||
let db_conn = state.db.lock().await;
|
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)?
|
.map_err(internal_db_error)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -359,6 +714,7 @@ async fn build_report_card(
|
||||||
|
|
||||||
async fn build_rolling_3day_chart_series(
|
async fn build_rolling_3day_chart_series(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
start_day: NaiveDate,
|
start_day: NaiveDate,
|
||||||
end_day: NaiveDate,
|
end_day: NaiveDate,
|
||||||
) -> Result<(Vec<String>, Vec<Option<f64>>, Vec<f64>), AppError> {
|
) -> Result<(Vec<String>, Vec<Option<f64>>, Vec<f64>), AppError> {
|
||||||
|
|
@ -368,9 +724,10 @@ async fn build_rolling_3day_chart_series(
|
||||||
|
|
||||||
let (calorie_map, weight_map) = {
|
let (calorie_map, weight_map) = {
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
let calories = db::fetch_daily_calorie_totals_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)?;
|
.map_err(internal_db_error)?;
|
||||||
let weights = db::fetch_daily_weights_for_range(&db_conn, &start_text, &end_text)
|
let weights = db::fetch_daily_weights_for_range(&db_conn, user_id, &start_text, &end_text)
|
||||||
.map_err(internal_db_error)?;
|
.map_err(internal_db_error)?;
|
||||||
(calories, weights)
|
(calories, weights)
|
||||||
};
|
};
|
||||||
|
|
@ -378,7 +735,6 @@ async fn build_rolling_3day_chart_series(
|
||||||
let mut labels = Vec::new();
|
let mut labels = Vec::new();
|
||||||
let mut daily_calories = Vec::new();
|
let mut daily_calories = Vec::new();
|
||||||
let mut daily_weights = Vec::new();
|
let mut daily_weights = Vec::new();
|
||||||
|
|
||||||
let mut day = start_day;
|
let mut day = start_day;
|
||||||
while day <= end_day {
|
while day <= end_day {
|
||||||
let date_key = day.format("%Y-%m-%d").to_string();
|
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() {
|
for i in 0..labels.len() {
|
||||||
let start_idx = i.saturating_sub(2);
|
let start_idx = i.saturating_sub(2);
|
||||||
let cal_window = &daily_calories[start_idx..=i];
|
let cal_window = &daily_calories[start_idx..=i];
|
||||||
let cal_avg = cal_window.iter().sum::<f64>() / cal_window.len() as f64;
|
rolling_calories.push(cal_window.iter().sum::<f64>() / cal_window.len() as f64);
|
||||||
rolling_calories.push(cal_avg);
|
|
||||||
|
|
||||||
let mut weight_sum = 0.0;
|
let mut weight_sum = 0.0;
|
||||||
let mut weight_count = 0;
|
let mut weight_count = 0;
|
||||||
|
|
@ -404,14 +759,144 @@ async fn build_rolling_3day_chart_series(
|
||||||
weight_count += 1;
|
weight_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if weight_count == 0 {
|
rolling_weights.push(if weight_count == 0 {
|
||||||
rolling_weights.push(None);
|
None
|
||||||
} else {
|
} 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<Option<AuthUser>, 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<bool, AppError> {
|
||||||
|
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<Response, AppError> {
|
||||||
|
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<Response, AppError> {
|
||||||
|
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<String, AppError> {
|
||||||
|
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::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_expiry_unix() -> i64 {
|
||||||
|
Utc::now().timestamp() + SESSION_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
|
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> {
|
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,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Invalid date".to_string(),
|
"Invalid date".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let (next_year, next_month) = if today.month() == 12 {
|
let (next_year, next_month) = if today.month() == 12 {
|
||||||
(today.year() + 1, 1)
|
(today.year() + 1, 1)
|
||||||
} else {
|
} else {
|
||||||
(today.year(), today.month() + 1)
|
(today.year(), today.month() + 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or((
|
let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Invalid date".to_string(),
|
"Invalid date".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let days_in_month = (first_of_next_month - first_day).num_days();
|
let days_in_month = (first_of_next_month - first_day).num_days();
|
||||||
Ok((first_day, first_of_next_month, days_in_month))
|
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 {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() - 1, 1).expect("valid date")
|
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() - 1, 1).expect("valid date")
|
||||||
};
|
};
|
||||||
|
|
||||||
let next_month = if focus_date.month() == 12 {
|
let next_month = if focus_date.month() == 12 {
|
||||||
NaiveDate::from_ymd_opt(focus_date.year() + 1, 1, 1).expect("valid date")
|
NaiveDate::from_ymd_opt(focus_date.year() + 1, 1, 1).expect("valid date")
|
||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() + 1, 1).expect("valid date")
|
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() + 1, 1).expect("valid date")
|
||||||
};
|
};
|
||||||
|
|
||||||
let prev_year =
|
let prev_year =
|
||||||
NaiveDate::from_ymd_opt(focus_date.year() - 1, focus_date.month(), 1).expect("valid date");
|
NaiveDate::from_ymd_opt(focus_date.year() - 1, focus_date.month(), 1).expect("valid date");
|
||||||
let next_year =
|
let next_year =
|
||||||
|
|
@ -493,14 +973,12 @@ fn parse_entry_form_fields(form: &HashMap<String, String>) -> Result<(String, i6
|
||||||
"Calories must be a number".to_string(),
|
"Calories must be a number".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Name is required".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "Name is required".to_string()));
|
||||||
}
|
}
|
||||||
if calories < 0 {
|
if calories < 0 {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Calories must be >= 0".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "Calories must be >= 0".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((name.to_string(), calories))
|
Ok((name.to_string(), calories))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,14 +993,11 @@ fn parse_optional_positive_f64(raw: Option<&String>) -> Result<Option<f64>, AppE
|
||||||
let parsed = trimmed.parse::<f64>().map_err(|_| {
|
let parsed = trimmed.parse::<f64>().map_err(|_| {
|
||||||
(
|
(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"Target weight must be a number".to_string(),
|
"Value must be a number".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if parsed <= 0.0 {
|
if parsed <= 0.0 {
|
||||||
return Err((
|
return Err((StatusCode::BAD_REQUEST, "Value must be > 0".to_string()));
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Target weight must be > 0".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(Some(parsed))
|
Ok(Some(parsed))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod views;
|
mod views;
|
||||||
|
|
@ -23,14 +24,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let conn = Connection::open(DB_PATH)?;
|
let conn = Connection::open(DB_PATH)?;
|
||||||
db::init_db(&conn)?;
|
db::init_db(&conn)?;
|
||||||
db::seed_db_if_empty(&conn)?;
|
db::seed_db_if_empty(&conn)?;
|
||||||
|
let app_config = config::load_config();
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db: Arc::new(Mutex::new(conn)),
|
db: Arc::new(Mutex::new(conn)),
|
||||||
|
config: app_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Route wiring stays here so `main` is the single startup overview.
|
// Route wiring stays here so `main` is the single startup overview.
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(handlers::show_calendar))
|
.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("/reports", get(handlers::show_reports))
|
||||||
.route("/planning", get(handlers::show_planning))
|
.route("/planning", get(handlers::show_planning))
|
||||||
.route("/planning", post(handlers::update_planning))
|
.route("/planning", post(handlers::update_planning))
|
||||||
|
|
|
||||||
82
src/views.rs
82
src/views.rs
|
|
@ -147,6 +147,40 @@ pub struct PlanningPageView {
|
||||||
pub target_weight_value: String,
|
pub target_weight_value: String,
|
||||||
pub target_calories_value: String,
|
pub target_calories_value: String,
|
||||||
pub bmr_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<PublicDaySummaryView>,
|
||||||
|
pub report_cards: Vec<ReportCardView>,
|
||||||
|
pub target_weight_label: String,
|
||||||
|
pub target_calories_label: String,
|
||||||
|
pub bmr_label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -173,6 +207,24 @@ struct PlanningTemplate<'a> {
|
||||||
page: &'a PlanningPageView,
|
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<'_> {
|
impl CalendarTemplate<'_> {
|
||||||
fn active_tab(&self) -> &str {
|
fn active_tab(&self) -> &str {
|
||||||
"calendar"
|
"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<String, askama::Error> {
|
pub fn render_calendar_page(page: &CalendarPageView) -> Result<String, askama::Error> {
|
||||||
CalendarTemplate { page }.render()
|
CalendarTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
@ -212,3 +282,15 @@ pub fn render_reports_page(page: &ReportsPageView) -> Result<String, askama::Err
|
||||||
pub fn render_planning_page(page: &PlanningPageView) -> Result<String, askama::Error> {
|
pub fn render_planning_page(page: &PlanningPageView) -> Result<String, askama::Error> {
|
||||||
PlanningTemplate { page }.render()
|
PlanningTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_login_page(page: &LoginPageView) -> Result<String, askama::Error> {
|
||||||
|
LoginTemplate { page }.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_signup_page(page: &SignupPageView) -> Result<String, askama::Error> {
|
||||||
|
SignupTemplate { page }.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_public_profile_page(page: &PublicProfilePageView) -> Result<String, askama::Error> {
|
||||||
|
PublicProfileTemplate { page }.render()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sign In{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="mx-auto max-w-md rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Sign In</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Sign in to view and edit your private data.</p>
|
||||||
|
{% if !page.error.is_empty() %}
|
||||||
|
<p class="mt-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{{ page.error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login" class="mt-4 grid gap-3">
|
||||||
|
<input type="text" name="username" placeholder="Username" required class="rounded-lg border border-slate-300 px-3 py-2 text-sm" />
|
||||||
|
<input type="password" name="password" placeholder="Password" required minlength="4" class="rounded-lg border border-slate-300 px-3 py-2 text-sm" />
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Sign In</button>
|
||||||
|
</form>
|
||||||
|
{% if page.allow_signup %}
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Need an account? <a class="font-semibold text-slate-900 underline" href="/signup">Sign up</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -46,6 +46,26 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="grid gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-slate-700">Public profile visibility</legend>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="public_entries" {% if page.public_entries %}checked{% endif %} />
|
||||||
|
<span>Show calorie entries</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="public_weights" {% if page.public_weights %}checked{% endif %} />
|
||||||
|
<span>Show weight entries (lbs)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="public_reports" {% if page.public_reports %}checked{% endif %} />
|
||||||
|
<span>Show reports</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="public_planning" {% if page.public_planning %}checked{% endif %} />
|
||||||
|
<span>Show planning targets</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Save Planning</button>
|
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Save Planning</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Public Profile{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">@{{ page.username }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Public profile</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if page.show_entries || page.show_weights %}
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">Recent Days</h2>
|
||||||
|
<div class="mt-3 overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<tr><th class="px-3 py-2">Date</th><th class="px-3 py-2">Calories</th><th class="px-3 py-2">Weight</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in page.recent_days %}
|
||||||
|
<tr class="border-t border-slate-200">
|
||||||
|
<td class="px-3 py-2 text-sm">{{ row.date_text }}</td>
|
||||||
|
<td class="px-3 py-2 text-sm">{% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %}</td>
|
||||||
|
<td class="px-3 py-2 text-sm">{% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.show_reports %}
|
||||||
|
<section class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{% for card in page.report_cards %}
|
||||||
|
<article class="rounded-2xl border border-slate-200/90 bg-white p-4 shadow-sm">
|
||||||
|
<h2 class="text-base font-bold text-slate-900">{{ card.title }}</h2>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ card.range_label }}</p>
|
||||||
|
<p class="mt-4 text-3xl font-bold tracking-tight text-slate-900">{{ card.average_calories_per_day }}</p>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">avg cal/day</p>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.show_planning %}
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">Planning</h2>
|
||||||
|
<dl class="mt-3 space-y-2 text-sm text-slate-700">
|
||||||
|
<div class="flex justify-between"><dt>Target weight (lbs)</dt><dd class="font-semibold">{{ page.target_weight_label }}</dd></div>
|
||||||
|
<div class="flex justify-between"><dt>Target calories</dt><dd class="font-semibold">{{ page.target_calories_label }}</dd></div>
|
||||||
|
<div class="flex justify-between"><dt>BMR</dt><dd class="font-semibold">{{ page.bmr_label }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if !page.show_entries && !page.show_weights && !page.show_reports && !page.show_planning %}
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-600">This user has not shared any data publicly.</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sign Up{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="mx-auto max-w-md rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Create Account</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">First account created becomes admin.</p>
|
||||||
|
{% if !page.allow_signup %}
|
||||||
|
<p class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">Sign-up is disabled by server config.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if !page.error.is_empty() %}
|
||||||
|
<p class="mt-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{{ page.error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/signup" class="mt-4 grid gap-3">
|
||||||
|
<input type="text" name="username" placeholder="Username" required class="rounded-lg border border-slate-300 px-3 py-2 text-sm" />
|
||||||
|
<input type="password" name="password" placeholder="Password (min 4 chars)" required minlength="4" class="rounded-lg border border-slate-300 px-3 py-2 text-sm" />
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700" {% if !page.allow_signup %}disabled{% endif %}>Sign Up</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Already have an account? <a class="font-semibold text-slate-900 underline" href="/login">Sign in</a></p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue