This commit is contained in:
Peter Li 2026-02-07 12:57:50 -08:00
parent d781a42107
commit ec125b9366
13 changed files with 1252 additions and 187 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target
config/
tmp/

151
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

Binary file not shown.

50
src/config.rs Normal file
View File

@ -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
View File

@ -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,
"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",
params![target_weight, target_calories, bmr],
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(())
}

View File

@ -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)
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(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(
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)
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, &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)?;
(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))
}
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);
}
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))
}

View File

@ -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))

View File

@ -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()
}

21
templates/login.html Normal file
View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

22
templates/signup.html Normal file
View File

@ -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 %}