saving
This commit is contained in:
parent
d781a42107
commit
ec125b9366
|
|
@ -1,2 +1,3 @@
|
|||
/target
|
||||
config/
|
||||
tmp/
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ version = 4
|
|||
name = "CurseTechnique"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
"axum",
|
||||
"chrono",
|
||||
"rand",
|
||||
"rusqlite",
|
||||
"tokio",
|
||||
]
|
||||
|
|
@ -34,6 +36,18 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.12.1"
|
||||
|
|
@ -142,6 +156,12 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
|
|
@ -157,6 +177,24 @@ version = "2.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
|
|
@ -204,6 +242,36 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -274,6 +342,27 @@ dependencies = [
|
|||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
|
|
@ -553,6 +642,17 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -577,6 +677,15 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
|
|
@ -595,6 +704,36 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -734,6 +873,12 @@ dependencies = [
|
|||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
|
|
@ -827,6 +972,12 @@ dependencies = [
|
|||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
askama = "0.12.1"
|
||||
axum = "0.8.1"
|
||||
chrono = { version = "0.4.40", features = ["clock"] }
|
||||
rand = "0.8.5"
|
||||
rusqlite = { version = "0.31.0", features = ["bundled"] }
|
||||
tokio = { version = "1.44.0", features = ["full"] }
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
366
src/db.rs
366
src/db.rs
|
|
@ -3,7 +3,6 @@ use std::collections::HashMap;
|
|||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
#[derive(Clone)]
|
||||
/// A single food entry row from `food_entries`.
|
||||
pub struct FoodEntry {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
|
|
@ -11,70 +10,216 @@ pub struct FoodEntry {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
/// Aggregated daily values used by the calendar grid.
|
||||
pub struct DaySummary {
|
||||
pub entry_count: i64,
|
||||
pub total_calories: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserRecord {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
#[allow(dead_code)]
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SessionRecord {
|
||||
pub user_id: i64,
|
||||
pub expires_at_unix: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PlanningConfig {
|
||||
pub target_weight: Option<f64>,
|
||||
pub target_calories: Option<i64>,
|
||||
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> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS food_entries (
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at_unix INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_food_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
entry_date TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
calories INTEGER NOT NULL CHECK (calories >= 0),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_food_entries_entry_date ON food_entries(entry_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_food_entries_user_date
|
||||
ON user_food_entries(user_id, entry_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_weights (
|
||||
entry_date TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL CHECK (weight > 0)
|
||||
CREATE TABLE IF NOT EXISTS user_daily_weights (
|
||||
user_id INTEGER NOT NULL,
|
||||
entry_date TEXT NOT NULL,
|
||||
weight REAL NOT NULL CHECK (weight > 0),
|
||||
PRIMARY KEY (user_id, entry_date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_daily_weights_user_date
|
||||
ON user_daily_weights(user_id, entry_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planning (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
CREATE TABLE IF NOT EXISTS user_planning (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
target_weight REAL,
|
||||
target_calories INTEGER CHECK (target_calories >= 0)
|
||||
target_calories INTEGER CHECK (target_calories >= 0),
|
||||
bmr REAL CHECK (bmr > 0),
|
||||
public_entries INTEGER NOT NULL DEFAULT 0,
|
||||
public_weights INTEGER NOT NULL DEFAULT 0,
|
||||
public_reports INTEGER NOT NULL DEFAULT 0,
|
||||
public_planning INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
ensure_planning_bmr_column(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placeholder seeding hook. Currently no-op unless you add seed inserts.
|
||||
pub fn seed_db_if_empty(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM food_entries", [], |row| row.get(0))?;
|
||||
if count > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn count_users(conn: &Connection) -> Result<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(())
|
||||
}
|
||||
|
||||
/// Returns one summary per date for the given half-open range [start_date, end_date).
|
||||
pub fn fetch_month_summaries(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
) -> Result<HashMap<String, DaySummary>, rusqlite::Error> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT entry_date, COUNT(*) as entry_count, SUM(calories) as total
|
||||
FROM food_entries
|
||||
WHERE entry_date >= ?1 AND entry_date < ?2
|
||||
FROM user_food_entries
|
||||
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3
|
||||
GROUP BY entry_date",
|
||||
)?;
|
||||
let mut rows = statement.query(params![start_date, end_date])?;
|
||||
let mut rows = statement.query(params![user_id, start_date, end_date])?;
|
||||
|
||||
let mut totals = HashMap::<String, DaySummary>::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
|
|
@ -89,20 +234,21 @@ pub fn fetch_month_summaries(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(totals)
|
||||
}
|
||||
|
||||
/// Returns all entries for one day.
|
||||
pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result<Vec<FoodEntry>, rusqlite::Error> {
|
||||
pub fn fetch_day_entries(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
date: &str,
|
||||
) -> Result<Vec<FoodEntry>, rusqlite::Error> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, name, calories
|
||||
FROM food_entries
|
||||
WHERE entry_date = ?1
|
||||
FROM user_food_entries
|
||||
WHERE user_id = ?1 AND entry_date = ?2
|
||||
ORDER BY id",
|
||||
)?;
|
||||
|
||||
let iter = statement.query_map(params![date], |row| {
|
||||
let iter = statement.query_map(params![user_id, date], |row| {
|
||||
Ok(FoodEntry {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
|
|
@ -114,53 +260,53 @@ pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result<Vec<FoodEntry>
|
|||
for row in iter {
|
||||
entries.push(row?);
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Returns total calories in the half-open range [start_date, end_date).
|
||||
pub fn fetch_total_calories_for_range(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
) -> Result<i64, rusqlite::Error> {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(SUM(calories), 0)
|
||||
FROM food_entries
|
||||
WHERE entry_date >= ?1 AND entry_date < ?2",
|
||||
params![start_date, end_date],
|
||||
FROM user_food_entries
|
||||
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3",
|
||||
params![user_id, start_date, end_date],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the earliest date with either calories or weight data.
|
||||
pub fn fetch_first_activity_date(conn: &Connection) -> Result<Option<String>, rusqlite::Error> {
|
||||
pub fn fetch_first_activity_date(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
) -> Result<Option<String>, rusqlite::Error> {
|
||||
conn.query_row(
|
||||
"SELECT MIN(entry_date)
|
||||
FROM (
|
||||
SELECT entry_date FROM food_entries
|
||||
SELECT entry_date FROM user_food_entries WHERE user_id = ?1
|
||||
UNION ALL
|
||||
SELECT entry_date FROM daily_weights
|
||||
SELECT entry_date FROM user_daily_weights WHERE user_id = ?1
|
||||
)",
|
||||
[],
|
||||
params![user_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a map date -> total calories for the half-open range [start_date, end_date).
|
||||
pub fn fetch_daily_calorie_totals_for_range(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
) -> Result<HashMap<String, i64>, rusqlite::Error> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT entry_date, COALESCE(SUM(calories), 0)
|
||||
FROM food_entries
|
||||
WHERE entry_date >= ?1 AND entry_date < ?2
|
||||
FROM user_food_entries
|
||||
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3
|
||||
GROUP BY entry_date",
|
||||
)?;
|
||||
let mut rows = statement.query(params![start_date, end_date])?;
|
||||
|
||||
let mut rows = statement.query(params![user_id, start_date, end_date])?;
|
||||
let mut out = HashMap::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let date: String = row.get(0)?;
|
||||
|
|
@ -170,19 +316,18 @@ pub fn fetch_daily_calorie_totals_for_range(
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Returns a map date -> weight for the half-open range [start_date, end_date).
|
||||
pub fn fetch_daily_weights_for_range(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
) -> Result<HashMap<String, f64>, rusqlite::Error> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT entry_date, weight
|
||||
FROM daily_weights
|
||||
WHERE entry_date >= ?1 AND entry_date < ?2",
|
||||
FROM user_daily_weights
|
||||
WHERE user_id = ?1 AND entry_date >= ?2 AND entry_date < ?3",
|
||||
)?;
|
||||
let mut rows = statement.query(params![start_date, end_date])?;
|
||||
|
||||
let mut rows = statement.query(params![user_id, start_date, end_date])?;
|
||||
let mut out = HashMap::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let date: String = row.get(0)?;
|
||||
|
|
@ -192,10 +337,14 @@ pub fn fetch_daily_weights_for_range(
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result<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(
|
||||
"SELECT weight FROM daily_weights WHERE entry_date = ?1",
|
||||
params![date],
|
||||
"SELECT weight FROM user_daily_weights WHERE user_id = ?1 AND entry_date = ?2",
|
||||
params![user_id, date],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
|
|
@ -203,112 +352,133 @@ pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result<Option<f64>
|
|||
|
||||
pub fn upsert_weight_for_day(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
date: &str,
|
||||
weight: f64,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"INSERT INTO daily_weights (entry_date, weight)
|
||||
VALUES (?1, ?2)
|
||||
ON CONFLICT(entry_date) DO UPDATE SET weight = excluded.weight",
|
||||
params![date, weight],
|
||||
"INSERT INTO user_daily_weights (user_id, entry_date, weight)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(user_id, entry_date) DO UPDATE SET weight = excluded.weight",
|
||||
params![user_id, date, weight],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fetch_planning(conn: &Connection) -> Result<PlanningConfig, rusqlite::Error> {
|
||||
let row: Option<(Option<f64>, Option<i64>, Option<f64>)> = conn
|
||||
pub fn fetch_planning(conn: &Connection, user_id: i64) -> Result<PlanningConfig, rusqlite::Error> {
|
||||
let row: Option<(Option<f64>, Option<i64>, Option<f64>, i64, i64, i64, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT target_weight, target_calories, bmr FROM planning WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
"SELECT target_weight, target_calories, bmr, public_entries, public_weights, public_reports, public_planning
|
||||
FROM user_planning
|
||||
WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
match row {
|
||||
Some((target_weight, target_calories, bmr)) => Ok(PlanningConfig {
|
||||
Some((target_weight, target_calories, bmr, pe, pw, pr, pp)) => Ok(PlanningConfig {
|
||||
target_weight,
|
||||
target_calories,
|
||||
bmr,
|
||||
public_entries: pe == 1,
|
||||
public_weights: pw == 1,
|
||||
public_reports: pr == 1,
|
||||
public_planning: pp == 1,
|
||||
}),
|
||||
None => Ok(PlanningConfig {
|
||||
target_weight: None,
|
||||
target_calories: None,
|
||||
bmr: None,
|
||||
public_entries: false,
|
||||
public_weights: false,
|
||||
public_reports: false,
|
||||
public_planning: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upsert_planning(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
target_weight: Option<f64>,
|
||||
target_calories: Option<i64>,
|
||||
bmr: Option<f64>,
|
||||
public_entries: bool,
|
||||
public_weights: bool,
|
||||
public_reports: bool,
|
||||
public_planning: bool,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"INSERT INTO planning (id, target_weight, target_calories, bmr)
|
||||
VALUES (1, ?1, ?2, ?3)
|
||||
ON CONFLICT(id) DO UPDATE
|
||||
SET target_weight = excluded.target_weight,
|
||||
target_calories = excluded.target_calories,
|
||||
bmr = excluded.bmr",
|
||||
params![target_weight, target_calories, bmr],
|
||||
"INSERT INTO user_planning (
|
||||
user_id, target_weight, target_calories, bmr,
|
||||
public_entries, public_weights, public_reports, public_planning
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
target_weight = excluded.target_weight,
|
||||
target_calories = excluded.target_calories,
|
||||
bmr = excluded.bmr,
|
||||
public_entries = excluded.public_entries,
|
||||
public_weights = excluded.public_weights,
|
||||
public_reports = excluded.public_reports,
|
||||
public_planning = excluded.public_planning",
|
||||
params![
|
||||
user_id,
|
||||
target_weight,
|
||||
target_calories,
|
||||
bmr,
|
||||
if public_entries { 1 } else { 0 },
|
||||
if public_weights { 1 } else { 0 },
|
||||
if public_reports { 1 } else { 0 },
|
||||
if public_planning { 1 } else { 0 },
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_planning_bmr_column(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(planning)")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut has_bmr = false;
|
||||
while let Some(row) = rows.next()? {
|
||||
let name: String = row.get(1)?;
|
||||
if name == "bmr" {
|
||||
has_bmr = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !has_bmr {
|
||||
conn.execute("ALTER TABLE planning ADD COLUMN bmr REAL", [])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts one entry for a date.
|
||||
pub fn insert_entry(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
date: &str,
|
||||
name: &str,
|
||||
calories: i64,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"INSERT INTO food_entries (entry_date, name, calories) VALUES (?1, ?2, ?3)",
|
||||
params![date, name, calories],
|
||||
"INSERT INTO user_food_entries (user_id, entry_date, name, calories)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![user_id, date, name, calories],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates one entry if both `id` and `date` match.
|
||||
pub fn update_entry(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
date: &str,
|
||||
id: i64,
|
||||
name: &str,
|
||||
calories: i64,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE food_entries
|
||||
"UPDATE user_food_entries
|
||||
SET name = ?1, calories = ?2
|
||||
WHERE id = ?3 AND entry_date = ?4",
|
||||
params![name, calories, id, date],
|
||||
WHERE user_id = ?3 AND id = ?4 AND entry_date = ?5",
|
||||
params![name, calories, user_id, id, date],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes one entry if both `id` and `date` match.
|
||||
pub fn delete_entry(conn: &Connection, date: &str, id: i64) -> Result<(), rusqlite::Error> {
|
||||
pub fn delete_entry(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
date: &str,
|
||||
id: i64,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"DELETE FROM food_entries WHERE id = ?1 AND entry_date = ?2",
|
||||
params![id, date],
|
||||
"DELETE FROM user_food_entries
|
||||
WHERE user_id = ?1 AND id = ?2 AND entry_date = ?3",
|
||||
params![user_id, id, date],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
653
src/handlers.rs
653
src/handlers.rs
|
|
@ -1,75 +1,355 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::{Argon2, password_hash::rand_core::OsRng as ArgonOsRng};
|
||||
use axum::extract::{Form, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, Redirect};
|
||||
use chrono::{Datelike, Days, Duration, Local, NaiveDate};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use chrono::{Datelike, Days, Duration, Local, NaiveDate, Utc};
|
||||
use rand::RngCore;
|
||||
use rusqlite::Connection;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::views::{
|
||||
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, PlanningPageView,
|
||||
ReportCardView, ReportsPageView,
|
||||
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView,
|
||||
PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView,
|
||||
SignupPageView,
|
||||
};
|
||||
|
||||
type AppError = (StatusCode, String);
|
||||
const SESSION_COOKIE: &str = "session_token";
|
||||
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Shared application state injected into each handler.
|
||||
pub struct AppState {
|
||||
pub db: Arc<Mutex<Connection>>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
/// GET `/` - calendar overview for the current month.
|
||||
pub async fn show_calendar(State(state): State<AppState>) -> Result<Html<String>, AppError> {
|
||||
render_calendar_for_month(state, Local::now().date_naive()).await
|
||||
#[derive(Clone)]
|
||||
struct AuthUser {
|
||||
user: db::UserRecord,
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub async fn show_calendar(
|
||||
State(state): State<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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
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((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Month route must use /calendar/{year}/{month} with month 1-12".to_string(),
|
||||
))?;
|
||||
render_calendar_for_month(state, focus_date).await
|
||||
render_calendar_for_month(&state, &auth, focus_date).await
|
||||
}
|
||||
|
||||
/// GET `/reports` - summary cards + rolling trend charts.
|
||||
pub async fn show_reports(State(state): State<AppState>) -> Result<Html<String>, AppError> {
|
||||
pub async fn show_login(
|
||||
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 planning = {
|
||||
let db_conn = state.db.lock().await;
|
||||
db::fetch_planning(&db_conn).map_err(internal_db_error)?
|
||||
db::fetch_planning(&db_conn, user_id).map_err(internal_db_error)?
|
||||
};
|
||||
let first_activity_date = {
|
||||
let db_conn = state.db.lock().await;
|
||||
db::fetch_first_activity_date(&db_conn)
|
||||
db::fetch_first_activity_date(&db_conn, user_id)
|
||||
.map_err(internal_db_error)?
|
||||
.and_then(|raw| NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok())
|
||||
};
|
||||
|
||||
let daily = build_report_card(&state, "Daily (Today)", today, 1, None).await?;
|
||||
let rolling_7 =
|
||||
build_report_card(&state, "Rolling 7-Day", today, 7, first_activity_date).await?;
|
||||
let rolling_monthly = build_report_card(
|
||||
let daily =
|
||||
build_report_card_for_user(&state, user_id, "Daily (Today)", today, 1, None).await?;
|
||||
let rolling_7 = build_report_card_for_user(
|
||||
&state,
|
||||
user_id,
|
||||
"Rolling 7-Day",
|
||||
today,
|
||||
7,
|
||||
first_activity_date,
|
||||
)
|
||||
.await?;
|
||||
let rolling_monthly = build_report_card_for_user(
|
||||
&state,
|
||||
user_id,
|
||||
"Rolling Monthly (Past 30 Days)",
|
||||
today,
|
||||
30,
|
||||
first_activity_date,
|
||||
)
|
||||
.await?;
|
||||
let rolling_30 =
|
||||
build_report_card(&state, "Rolling 30-Day", today, 30, first_activity_date).await?;
|
||||
let rolling_30 = build_report_card_for_user(
|
||||
&state,
|
||||
user_id,
|
||||
"Rolling 30-Day",
|
||||
today,
|
||||
30,
|
||||
first_activity_date,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let chart_start = first_activity_date.unwrap_or(today - Days::new(29));
|
||||
let (labels, rolling_weight, rolling_calories) =
|
||||
build_rolling_3day_chart_series(&state, chart_start, today).await?;
|
||||
build_rolling_3day_chart_series(&state, user_id, chart_start, today).await?;
|
||||
let rolling_loss = if let Some(bmr) = planning.bmr {
|
||||
rolling_calories
|
||||
.iter()
|
||||
|
|
@ -92,15 +372,22 @@ pub async fn show_reports(State(state): State<AppState>) -> Result<Html<String>,
|
|||
.unwrap_or_else(|| "Not set".to_string()),
|
||||
};
|
||||
|
||||
let html = views::render_reports_page(&page).map_err(internal_template_error)?;
|
||||
Ok(Html(html))
|
||||
let resp =
|
||||
Html(views::render_reports_page(&page).map_err(internal_template_error)?).into_response();
|
||||
Ok(with_session_cookie(resp, &auth.token))
|
||||
}
|
||||
|
||||
/// GET `/planning` - target weight/calorie/BMR configuration.
|
||||
pub async fn show_planning(State(state): State<AppState>) -> Result<Html<String>, AppError> {
|
||||
pub async fn show_planning(
|
||||
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 db_conn = state.db.lock().await;
|
||||
db::fetch_planning(&db_conn).map_err(internal_db_error)?
|
||||
db::fetch_planning(&db_conn, auth.user.id).map_err(internal_db_error)?
|
||||
};
|
||||
|
||||
let page = PlanningPageView {
|
||||
|
|
@ -113,37 +400,70 @@ pub async fn show_planning(State(state): State<AppState>) -> Result<Html<String>
|
|||
.map(|v| v.to_string())
|
||||
.unwrap_or_default(),
|
||||
bmr_value: planning.bmr.map(|v| format!("{v:.0}")).unwrap_or_default(),
|
||||
public_entries: planning.public_entries,
|
||||
public_weights: planning.public_weights,
|
||||
public_reports: planning.public_reports,
|
||||
public_planning: planning.public_planning,
|
||||
};
|
||||
let html = views::render_planning_page(&page).map_err(internal_template_error)?;
|
||||
Ok(Html(html))
|
||||
let resp =
|
||||
Html(views::render_planning_page(&page).map_err(internal_template_error)?).into_response();
|
||||
Ok(with_session_cookie(resp, &auth.token))
|
||||
}
|
||||
|
||||
/// POST `/planning` - save target weight/calorie/BMR configuration.
|
||||
pub async fn update_planning(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
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_calories = parse_optional_non_negative_i64(form.get("target_calories"))?;
|
||||
let bmr = parse_optional_positive_f64(form.get("bmr"))?;
|
||||
let public_entries = form.contains_key("public_entries");
|
||||
let public_weights = form.contains_key("public_weights");
|
||||
let public_reports = form.contains_key("public_reports");
|
||||
let public_planning = form.contains_key("public_planning");
|
||||
|
||||
let db_conn = state.db.lock().await;
|
||||
db::upsert_planning(&db_conn, target_weight, target_calories, bmr)
|
||||
.map_err(internal_db_error)?;
|
||||
Ok(Redirect::to("/planning"))
|
||||
db::upsert_planning(
|
||||
&db_conn,
|
||||
auth.user.id,
|
||||
target_weight,
|
||||
target_calories,
|
||||
bmr,
|
||||
public_entries,
|
||||
public_weights,
|
||||
public_reports,
|
||||
public_planning,
|
||||
)
|
||||
.map_err(internal_db_error)?;
|
||||
Ok(with_session_cookie(
|
||||
Redirect::to("/planning").into_response(),
|
||||
&auth.token,
|
||||
))
|
||||
}
|
||||
|
||||
/// GET `/day/{date}` - list/edit entries for one day.
|
||||
pub async fn show_day_entries(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
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)?;
|
||||
|
||||
let (entries, weight) = {
|
||||
let db_conn = state.db.lock().await;
|
||||
let entries = db::fetch_day_entries(&db_conn, &date_text).map_err(internal_db_error)?;
|
||||
let weight = db::fetch_weight_for_day(&db_conn, &date_text).map_err(internal_db_error)?;
|
||||
let entries =
|
||||
db::fetch_day_entries(&db_conn, auth.user.id, &date_text).map_err(internal_db_error)?;
|
||||
let weight = db::fetch_weight_for_day(&db_conn, auth.user.id, &date_text)
|
||||
.map_err(internal_db_error)?;
|
||||
(entries, weight)
|
||||
};
|
||||
|
||||
|
|
@ -153,17 +473,21 @@ pub async fn show_day_entries(
|
|||
entries,
|
||||
weight_value: weight.map(|w| format!("{w:.1}")).unwrap_or_default(),
|
||||
};
|
||||
|
||||
let html = views::render_day_page(&page).map_err(internal_template_error)?;
|
||||
Ok(Html(html))
|
||||
let resp =
|
||||
Html(views::render_day_page(&page).map_err(internal_template_error)?).into_response();
|
||||
Ok(with_session_cookie(resp, &auth.token))
|
||||
}
|
||||
|
||||
/// POST `/day/{date}/weight` - set or update the day's weight entry.
|
||||
pub async fn update_day_weight(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(date_text): Path<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)?;
|
||||
let weight = form
|
||||
.get("weight")
|
||||
|
|
@ -181,57 +505,85 @@ pub async fn update_day_weight(
|
|||
}
|
||||
|
||||
let db_conn = state.db.lock().await;
|
||||
db::upsert_weight_for_day(&db_conn, &date_text, weight).map_err(internal_db_error)?;
|
||||
Ok(Redirect::to(&format!("/day/{date_text}")))
|
||||
db::upsert_weight_for_day(&db_conn, auth.user.id, &date_text, weight)
|
||||
.map_err(internal_db_error)?;
|
||||
Ok(with_session_cookie(
|
||||
Redirect::to(&format!("/day/{date_text}")).into_response(),
|
||||
&auth.token,
|
||||
))
|
||||
}
|
||||
|
||||
/// POST `/day/{date}/add` - create a new entry.
|
||||
pub async fn create_entry(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(date_text): Path<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)?;
|
||||
let (name, calories) = parse_entry_form_fields(&form)?;
|
||||
|
||||
let db_conn = state.db.lock().await;
|
||||
db::insert_entry(&db_conn, &date_text, &name, calories).map_err(internal_db_error)?;
|
||||
|
||||
Ok(Redirect::to(&format!("/day/{date_text}")))
|
||||
db::insert_entry(&db_conn, auth.user.id, &date_text, &name, calories)
|
||||
.map_err(internal_db_error)?;
|
||||
Ok(with_session_cookie(
|
||||
Redirect::to(&format!("/day/{date_text}")).into_response(),
|
||||
&auth.token,
|
||||
))
|
||||
}
|
||||
|
||||
/// POST `/day/{date}/entry/{id}/update` - edit an existing entry.
|
||||
pub async fn edit_entry(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((date_text, id)): Path<(String, i64)>,
|
||||
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)?;
|
||||
let (name, calories) = parse_entry_form_fields(&form)?;
|
||||
|
||||
let db_conn = state.db.lock().await;
|
||||
db::update_entry(&db_conn, &date_text, id, &name, calories).map_err(internal_db_error)?;
|
||||
db::update_entry(&db_conn, auth.user.id, &date_text, id, &name, calories)
|
||||
.map_err(internal_db_error)?;
|
||||
|
||||
Ok(Redirect::to("/"))
|
||||
Ok(with_session_cookie(
|
||||
Redirect::to("/").into_response(),
|
||||
&auth.token,
|
||||
))
|
||||
}
|
||||
|
||||
/// POST `/day/{date}/entry/{id}/delete` - remove an entry.
|
||||
pub async fn remove_entry(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
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)?;
|
||||
|
||||
let db_conn = state.db.lock().await;
|
||||
db::delete_entry(&db_conn, &date_text, id).map_err(internal_db_error)?;
|
||||
db::delete_entry(&db_conn, auth.user.id, &date_text, id).map_err(internal_db_error)?;
|
||||
|
||||
Ok(Redirect::to(&format!("/day/{date_text}")))
|
||||
Ok(with_session_cookie(
|
||||
Redirect::to(&format!("/day/{date_text}")).into_response(),
|
||||
&auth.token,
|
||||
))
|
||||
}
|
||||
|
||||
async fn render_calendar_for_month(
|
||||
state: AppState,
|
||||
state: &AppState,
|
||||
auth: &AuthUser,
|
||||
focus_date: NaiveDate,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
) -> Result<Response, AppError> {
|
||||
let user_id = auth.user.id;
|
||||
let today = Local::now().date_naive();
|
||||
let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?;
|
||||
|
||||
|
|
@ -239,6 +591,7 @@ async fn render_calendar_for_month(
|
|||
let db_conn = state.db.lock().await;
|
||||
db::fetch_month_summaries(
|
||||
&db_conn,
|
||||
user_id,
|
||||
&first_day.format("%Y-%m-%d").to_string(),
|
||||
&first_of_next_month.format("%Y-%m-%d").to_string(),
|
||||
)
|
||||
|
|
@ -248,6 +601,7 @@ async fn render_calendar_for_month(
|
|||
let db_conn = state.db.lock().await;
|
||||
db::fetch_daily_weights_for_range(
|
||||
&db_conn,
|
||||
user_id,
|
||||
&first_day.format("%Y-%m-%d").to_string(),
|
||||
&first_of_next_month.format("%Y-%m-%d").to_string(),
|
||||
)
|
||||
|
|
@ -255,7 +609,6 @@ async fn render_calendar_for_month(
|
|||
};
|
||||
|
||||
let mut cells = Vec::with_capacity(42);
|
||||
|
||||
let first_weekday = first_day.weekday().num_days_from_sunday() as usize;
|
||||
for _ in 0..first_weekday {
|
||||
cells.push(CalendarCellView::padding());
|
||||
|
|
@ -300,12 +653,14 @@ async fn render_calendar_for_month(
|
|||
cells,
|
||||
};
|
||||
|
||||
let html = views::render_calendar_page(&page).map_err(internal_template_error)?;
|
||||
Ok(Html(html))
|
||||
let resp =
|
||||
Html(views::render_calendar_page(&page).map_err(internal_template_error)?).into_response();
|
||||
Ok(with_session_cookie(resp, &auth.token))
|
||||
}
|
||||
|
||||
async fn build_report_card(
|
||||
async fn build_report_card_for_user(
|
||||
state: &AppState,
|
||||
user_id: i64,
|
||||
title: &str,
|
||||
end_day_inclusive: NaiveDate,
|
||||
day_count: i64,
|
||||
|
|
@ -334,7 +689,7 @@ async fn build_report_card(
|
|||
let start_text = effective_start.format("%Y-%m-%d").to_string();
|
||||
let end_text = window_end.format("%Y-%m-%d").to_string();
|
||||
let db_conn = state.db.lock().await;
|
||||
db::fetch_total_calories_for_range(&db_conn, &start_text, &end_text)
|
||||
db::fetch_total_calories_for_range(&db_conn, user_id, &start_text, &end_text)
|
||||
.map_err(internal_db_error)?
|
||||
};
|
||||
|
||||
|
|
@ -359,6 +714,7 @@ async fn build_report_card(
|
|||
|
||||
async fn build_rolling_3day_chart_series(
|
||||
state: &AppState,
|
||||
user_id: i64,
|
||||
start_day: NaiveDate,
|
||||
end_day: NaiveDate,
|
||||
) -> Result<(Vec<String>, Vec<Option<f64>>, Vec<f64>), AppError> {
|
||||
|
|
@ -368,9 +724,10 @@ async fn build_rolling_3day_chart_series(
|
|||
|
||||
let (calorie_map, weight_map) = {
|
||||
let db_conn = state.db.lock().await;
|
||||
let calories = db::fetch_daily_calorie_totals_for_range(&db_conn, &start_text, &end_text)
|
||||
.map_err(internal_db_error)?;
|
||||
let weights = db::fetch_daily_weights_for_range(&db_conn, &start_text, &end_text)
|
||||
let calories =
|
||||
db::fetch_daily_calorie_totals_for_range(&db_conn, user_id, &start_text, &end_text)
|
||||
.map_err(internal_db_error)?;
|
||||
let weights = db::fetch_daily_weights_for_range(&db_conn, user_id, &start_text, &end_text)
|
||||
.map_err(internal_db_error)?;
|
||||
(calories, weights)
|
||||
};
|
||||
|
|
@ -378,7 +735,6 @@ async fn build_rolling_3day_chart_series(
|
|||
let mut labels = Vec::new();
|
||||
let mut daily_calories = Vec::new();
|
||||
let mut daily_weights = Vec::new();
|
||||
|
||||
let mut day = start_day;
|
||||
while day <= end_day {
|
||||
let date_key = day.format("%Y-%m-%d").to_string();
|
||||
|
|
@ -393,8 +749,7 @@ async fn build_rolling_3day_chart_series(
|
|||
for i in 0..labels.len() {
|
||||
let start_idx = i.saturating_sub(2);
|
||||
let cal_window = &daily_calories[start_idx..=i];
|
||||
let cal_avg = cal_window.iter().sum::<f64>() / cal_window.len() as f64;
|
||||
rolling_calories.push(cal_avg);
|
||||
rolling_calories.push(cal_window.iter().sum::<f64>() / cal_window.len() as f64);
|
||||
|
||||
let mut weight_sum = 0.0;
|
||||
let mut weight_count = 0;
|
||||
|
|
@ -404,14 +759,144 @@ async fn build_rolling_3day_chart_series(
|
|||
weight_count += 1;
|
||||
}
|
||||
}
|
||||
if weight_count == 0 {
|
||||
rolling_weights.push(None);
|
||||
rolling_weights.push(if weight_count == 0 {
|
||||
None
|
||||
} else {
|
||||
rolling_weights.push(Some(weight_sum / weight_count as f64));
|
||||
}
|
||||
Some(weight_sum / weight_count as f64)
|
||||
});
|
||||
}
|
||||
Ok((labels, rolling_weights, rolling_calories))
|
||||
}
|
||||
|
||||
async fn get_auth_user(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<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> {
|
||||
|
|
@ -419,18 +904,15 @@ fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppErro
|
|||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid date".to_string(),
|
||||
))?;
|
||||
|
||||
let (next_year, next_month) = if today.month() == 12 {
|
||||
(today.year() + 1, 1)
|
||||
} else {
|
||||
(today.year(), today.month() + 1)
|
||||
};
|
||||
|
||||
let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid date".to_string(),
|
||||
))?;
|
||||
|
||||
let days_in_month = (first_of_next_month - first_day).num_days();
|
||||
Ok((first_day, first_of_next_month, days_in_month))
|
||||
}
|
||||
|
|
@ -441,13 +923,11 @@ fn calendar_nav_for_month(focus_date: NaiveDate, today: NaiveDate) -> CalendarNa
|
|||
} else {
|
||||
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() - 1, 1).expect("valid date")
|
||||
};
|
||||
|
||||
let next_month = if focus_date.month() == 12 {
|
||||
NaiveDate::from_ymd_opt(focus_date.year() + 1, 1, 1).expect("valid date")
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() + 1, 1).expect("valid date")
|
||||
};
|
||||
|
||||
let prev_year =
|
||||
NaiveDate::from_ymd_opt(focus_date.year() - 1, focus_date.month(), 1).expect("valid date");
|
||||
let next_year =
|
||||
|
|
@ -493,14 +973,12 @@ fn parse_entry_form_fields(form: &HashMap<String, String>) -> Result<(String, i6
|
|||
"Calories must be a number".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if name.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Name is required".to_string()));
|
||||
}
|
||||
if calories < 0 {
|
||||
return Err((StatusCode::BAD_REQUEST, "Calories must be >= 0".to_string()));
|
||||
}
|
||||
|
||||
Ok((name.to_string(), calories))
|
||||
}
|
||||
|
||||
|
|
@ -515,14 +993,11 @@ fn parse_optional_positive_f64(raw: Option<&String>) -> Result<Option<f64>, AppE
|
|||
let parsed = trimmed.parse::<f64>().map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Target weight must be a number".to_string(),
|
||||
"Value must be a number".to_string(),
|
||||
)
|
||||
})?;
|
||||
if parsed <= 0.0 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Target weight must be > 0".to_string(),
|
||||
));
|
||||
return Err((StatusCode::BAD_REQUEST, "Value must be > 0".to_string()));
|
||||
}
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod config;
|
||||
mod db;
|
||||
mod handlers;
|
||||
mod views;
|
||||
|
|
@ -23,14 +24,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let conn = Connection::open(DB_PATH)?;
|
||||
db::init_db(&conn)?;
|
||||
db::seed_db_if_empty(&conn)?;
|
||||
let app_config = config::load_config();
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(Mutex::new(conn)),
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
// Route wiring stays here so `main` is the single startup overview.
|
||||
let app = Router::new()
|
||||
.route("/", get(handlers::show_calendar))
|
||||
.route("/login", get(handlers::show_login))
|
||||
.route("/login", post(handlers::login))
|
||||
.route("/signup", get(handlers::show_signup))
|
||||
.route("/signup", post(handlers::signup))
|
||||
.route("/logout", post(handlers::logout))
|
||||
.route("/u/{username}", get(handlers::show_public_profile))
|
||||
.route("/reports", get(handlers::show_reports))
|
||||
.route("/planning", get(handlers::show_planning))
|
||||
.route("/planning", post(handlers::update_planning))
|
||||
|
|
|
|||
82
src/views.rs
82
src/views.rs
|
|
@ -147,6 +147,40 @@ pub struct PlanningPageView {
|
|||
pub target_weight_value: String,
|
||||
pub target_calories_value: String,
|
||||
pub bmr_value: String,
|
||||
pub public_entries: bool,
|
||||
pub public_weights: bool,
|
||||
pub public_reports: bool,
|
||||
pub public_planning: bool,
|
||||
}
|
||||
|
||||
pub struct LoginPageView {
|
||||
pub allow_signup: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub struct SignupPageView {
|
||||
pub allow_signup: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PublicDaySummaryView {
|
||||
pub date_text: String,
|
||||
pub calories: i64,
|
||||
pub weight_label: String,
|
||||
}
|
||||
|
||||
pub struct PublicProfilePageView {
|
||||
pub username: String,
|
||||
pub show_entries: bool,
|
||||
pub show_weights: bool,
|
||||
pub show_reports: bool,
|
||||
pub show_planning: bool,
|
||||
pub recent_days: Vec<PublicDaySummaryView>,
|
||||
pub report_cards: Vec<ReportCardView>,
|
||||
pub target_weight_label: String,
|
||||
pub target_calories_label: String,
|
||||
pub bmr_label: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -173,6 +207,24 @@ struct PlanningTemplate<'a> {
|
|||
page: &'a PlanningPageView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate<'a> {
|
||||
page: &'a LoginPageView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "signup.html")]
|
||||
struct SignupTemplate<'a> {
|
||||
page: &'a SignupPageView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "public_profile.html")]
|
||||
struct PublicProfileTemplate<'a> {
|
||||
page: &'a PublicProfilePageView,
|
||||
}
|
||||
|
||||
impl CalendarTemplate<'_> {
|
||||
fn active_tab(&self) -> &str {
|
||||
"calendar"
|
||||
|
|
@ -197,6 +249,24 @@ impl PlanningTemplate<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
impl LoginTemplate<'_> {
|
||||
fn active_tab(&self) -> &str {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
impl SignupTemplate<'_> {
|
||||
fn active_tab(&self) -> &str {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
impl PublicProfileTemplate<'_> {
|
||||
fn active_tab(&self) -> &str {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_calendar_page(page: &CalendarPageView) -> Result<String, askama::Error> {
|
||||
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> {
|
||||
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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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