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::{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, ChangePasswordPageView, DayPageView, LoginPageView, PlanningPageView, PublicDayPageView, 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)] pub struct AppState { pub db: Arc>, pub config: AppConfig, } #[derive(Clone)] struct AuthUser { user: db::UserRecord, token: String, } pub async fn show_calendar( State(state): State, headers: HeaderMap, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; render_calendar_for_month(&state, &auth, Local::now().date_naive()).await } pub async fn show_calendar_for_month( State(state): State, headers: HeaderMap, Path((year, month)): Path<(i32, u32)>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; let focus_date = NaiveDate::from_ymd_opt(year, month, 1).ok_or(( StatusCode::BAD_REQUEST, "Month route must use /calendar/{year}/{month} with month 1-12".to_string(), ))?; render_calendar_for_month(&state, &auth, focus_date).await } pub async fn show_login( State(state): State, headers: HeaderMap, ) -> Result { if get_auth_user(&state, &headers).await?.is_some() { return Ok(Redirect::to("/").into_response()); } let page = LoginPageView { allow_signup: signup_allowed(&state).await?, error: String::new(), }; Ok(Html(views::render_login_page(&page).map_err(internal_template_error)?).into_response()) } pub async fn show_signup( State(state): State, headers: HeaderMap, ) -> Result { if get_auth_user(&state, &headers).await?.is_some() { return Ok(Redirect::to("/").into_response()); } let allowed = signup_allowed(&state).await?; let page = SignupPageView { allow_signup: allowed, error: String::new(), }; Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response()) } pub async fn login( State(state): State, Form(form): Form>, ) -> Result { let username = form.get("username").map(|v| v.trim()).unwrap_or(""); let password = form.get("password").map(|v| v.as_str()).unwrap_or(""); if username.is_empty() || password.is_empty() { return render_login_with_error(&state, "Username and password are required").await; } let user = { let db_conn = state.db.lock().await; db::fetch_user_by_username(&db_conn, username).map_err(internal_db_error)? }; let Some(user) = user else { return render_login_with_error(&state, "Invalid username or password").await; }; if !verify_password(&user.password_hash, password) { return render_login_with_error(&state, "Invalid username or password").await; } let token = generate_session_token(); let expires = session_expiry_unix(); { let db_conn = state.db.lock().await; db::create_or_replace_session(&db_conn, &token, user.id, expires) .map_err(internal_db_error)?; } Ok(with_session_cookie( Redirect::to("/").into_response(), &token, )) } pub async fn signup( State(state): State, Form(form): Form>, ) -> Result { if !signup_allowed(&state).await? { return render_signup_with_error(&state, "Sign-up is disabled").await; } let username = form.get("username").map(|v| v.trim()).unwrap_or(""); let password = form.get("password").map(|v| v.as_str()).unwrap_or(""); if username.len() < 2 { return render_signup_with_error(&state, "Username must be at least 2 chars").await; } if password.len() < 4 { return render_signup_with_error(&state, "Password must be at least 4 chars").await; } let (user_count, existing) = { let db_conn = state.db.lock().await; let count = db::count_users(&db_conn).map_err(internal_db_error)?; let existing = db::fetch_user_by_username(&db_conn, username).map_err(internal_db_error)?; (count, existing) }; if existing.is_some() { return render_signup_with_error(&state, "Username already taken").await; } let password_hash = hash_password(password)?; let user_id = { let db_conn = state.db.lock().await; db::create_user(&db_conn, username, &password_hash, user_count == 0) .map_err(internal_db_error)? }; let token = generate_session_token(); let expires = session_expiry_unix(); { let db_conn = state.db.lock().await; db::create_or_replace_session(&db_conn, &token, user_id, expires) .map_err(internal_db_error)?; } Ok(with_session_cookie( Redirect::to("/").into_response(), &token, )) } pub async fn logout( State(state): State, headers: HeaderMap, ) -> Result { if let Some(token) = session_token_from_headers(&headers) { let db_conn = state.db.lock().await; let _ = db::delete_session(&db_conn, &token); } Ok(clear_session_cookie(Redirect::to("/login").into_response())) } pub async fn show_public_profile( State(state): State, Path(username): Path, ) -> Result { let user = { let db_conn = state.db.lock().await; db::fetch_user_by_username(&db_conn, &username).map_err(internal_db_error)? }; let Some(user) = user else { return Err((StatusCode::NOT_FOUND, "User not found".to_string())); }; let planning = { let db_conn = state.db.lock().await; db::fetch_planning(&db_conn, user.id).map_err(internal_db_error)? }; let today = Local::now().date_naive(); let start = today - Days::new(13); let (calorie_map, weight_map) = { let db_conn = state.db.lock().await; let c = db::fetch_daily_calorie_totals_for_range( &db_conn, user.id, &start.format("%Y-%m-%d").to_string(), &(today + Days::new(1)).format("%Y-%m-%d").to_string(), ) .map_err(internal_db_error)?; let w = db::fetch_daily_weights_for_range( &db_conn, user.id, &start.format("%Y-%m-%d").to_string(), &(today + Days::new(1)).format("%Y-%m-%d").to_string(), ) .map_err(internal_db_error)?; (c, w) }; let mut recent_days = Vec::new(); let mut day = start; while day <= today { let key = day.format("%Y-%m-%d").to_string(); recent_days.push(PublicDaySummaryView { date_text: key.clone(), day_href: format!("/u/{}/day/{}", user.username, key), 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 mut chart_labels = Vec::new(); let mut calories_series = Vec::new(); let mut weights_series = Vec::new(); for row in &recent_days { chart_labels.push(row.date_text.clone()); calories_series.push(row.calories as f64); let weight = weight_map.get(&row.date_text).copied(); weights_series.push(weight); } 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, chart_labels_js: js_string_array(&chart_labels), calories_js: js_f64_array(&calories_series), weights_js: js_optional_f64_array(&weights_series), target_weight_label: planning .target_weight .map(|v| format!("{v:.1} lbs")) .unwrap_or_else(|| "Not set".to_string()), target_calories_label: planning .target_calories .map(|v| v.to_string()) .unwrap_or_else(|| "Not set".to_string()), bmr_label: planning .bmr .map(|v| format!("{v:.0} cal/day")) .unwrap_or_else(|| "Not set".to_string()), }; Ok( Html(views::render_public_profile_page(&page).map_err(internal_template_error)?) .into_response(), ) } pub async fn show_reports( State(state): State, headers: HeaderMap, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; let user_id = auth.user.id; let today = Local::now().date_naive(); let planning = { let db_conn = state.db.lock().await; db::fetch_planning(&db_conn, 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, user_id) .map_err(internal_db_error)? .and_then(|raw| NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()) }; 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_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, user_id, chart_start, today).await?; let rolling_loss = if let Some(bmr) = planning.bmr { rolling_calories .iter() .map(|avg_cal| Some((avg_cal - bmr) / 3500.0)) .collect::>() } else { vec![None; rolling_calories.len()] }; let page = ReportsPageView { generated_for_date: today.format("%Y-%m-%d").to_string(), cards: vec![daily, rolling_7, rolling_monthly, rolling_30], chart_labels_js: js_string_array(&labels), weight_rolling_js: js_optional_f64_array(&rolling_weight), calories_rolling_js: js_f64_array(&rolling_calories), loss_rolling_js: js_optional_f64_array(&rolling_loss), bmr_label: planning .bmr .map(|v| format!("{v:.0} cal/day")) .unwrap_or_else(|| "Not set".to_string()), }; let resp = Html(views::render_reports_page(&page).map_err(internal_template_error)?).into_response(); Ok(with_session_cookie(resp, &auth.token)) } pub async fn show_planning( State(state): State, headers: HeaderMap, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; let planning = { let db_conn = state.db.lock().await; db::fetch_planning(&db_conn, auth.user.id).map_err(internal_db_error)? }; let page = PlanningPageView { target_weight_value: planning .target_weight .map(|v| format!("{v:.1}")) .unwrap_or_default(), target_calories_value: planning .target_calories .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 resp = Html(views::render_planning_page(&page).map_err(internal_template_error)?).into_response(); Ok(with_session_cookie(resp, &auth.token)) } pub async fn show_change_password( State(state): State, headers: HeaderMap, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; let page = ChangePasswordPageView { error: String::new(), success: String::new(), }; let resp = Html(views::render_change_password_page(&page).map_err(internal_template_error)?) .into_response(); Ok(with_session_cookie(resp, &auth.token)) } pub async fn change_password( State(state): State, headers: HeaderMap, Form(form): Form>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; let current = form.get("current_password").map(|v| v.as_str()).unwrap_or(""); let new_password = form.get("new_password").map(|v| v.as_str()).unwrap_or(""); let confirm = form.get("confirm_password").map(|v| v.as_str()).unwrap_or(""); if current.is_empty() || new_password.is_empty() || confirm.is_empty() { return render_change_password_page( &auth.token, "All password fields are required", "", ); } if !verify_password(&auth.user.password_hash, current) { return render_change_password_page(&auth.token, "Current password is incorrect", ""); } if new_password.len() < 4 { return render_change_password_page( &auth.token, "New password must be at least 4 chars", "", ); } if new_password != confirm { return render_change_password_page(&auth.token, "New passwords do not match", ""); } let new_hash = hash_password(new_password)?; { let db_conn = state.db.lock().await; db::update_user_password(&db_conn, auth.user.id, &new_hash).map_err(internal_db_error)?; } render_change_password_page(&auth.token, "", "Password updated") } pub async fn update_planning( State(state): State, headers: HeaderMap, Form(form): Form>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; let target_weight = parse_optional_positive_f64(form.get("target_weight"))?; let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?; let bmr = parse_optional_positive_f64(form.get("bmr"))?; let public_entries = form.contains_key("public_entries"); let public_weights = form.contains_key("public_weights"); let public_reports = form.contains_key("public_reports"); let public_planning = form.contains_key("public_planning"); let db_conn = state.db.lock().await; db::upsert_planning( &db_conn, 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, )) } pub async fn show_day_entries( State(state): State, headers: HeaderMap, Path(date_text): Path, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; validate_date(&date_text)?; let (entries, weight) = { let db_conn = state.db.lock().await; let entries = db::fetch_day_entries(&db_conn, 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) }; let page = DayPageView { date_text, daily_total: entries.iter().map(|entry| entry.calories).sum(), entries, weight_value: weight.map(|w| format!("{w:.1}")).unwrap_or_default(), }; let resp = Html(views::render_day_page(&page).map_err(internal_template_error)?).into_response(); Ok(with_session_cookie(resp, &auth.token)) } pub async fn update_day_weight( State(state): State, headers: HeaderMap, Path(date_text): Path, Form(form): Form>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; validate_date(&date_text)?; let weight = form .get("weight") .ok_or((StatusCode::BAD_REQUEST, "Weight is required".to_string()))? .trim() .parse::() .map_err(|_| { ( StatusCode::BAD_REQUEST, "Weight must be a number".to_string(), ) })?; if weight <= 0.0 { return Err((StatusCode::BAD_REQUEST, "Weight must be > 0".to_string())); } let db_conn = state.db.lock().await; 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, )) } pub async fn create_entry( State(state): State, headers: HeaderMap, Path(date_text): Path, Form(form): Form>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; validate_date(&date_text)?; let (name, calories) = parse_entry_form_fields(&form)?; let db_conn = state.db.lock().await; db::insert_entry(&db_conn, 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, )) } pub async fn edit_entry( State(state): State, headers: HeaderMap, Path((date_text, id)): Path<(String, i64)>, Form(form): Form>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; validate_date(&date_text)?; let (name, calories) = parse_entry_form_fields(&form)?; let db_conn = state.db.lock().await; db::update_entry(&db_conn, auth.user.id, &date_text, id, &name, calories) .map_err(internal_db_error)?; Ok(with_session_cookie( Redirect::to("/").into_response(), &auth.token, )) } pub async fn remove_entry( State(state): State, headers: HeaderMap, Path((date_text, id)): Path<(String, i64)>, ) -> Result { let auth = get_auth_user(&state, &headers).await?; let Some(auth) = auth else { return Ok(Redirect::to("/login").into_response()); }; validate_date(&date_text)?; let db_conn = state.db.lock().await; db::delete_entry(&db_conn, auth.user.id, &date_text, id).map_err(internal_db_error)?; Ok(with_session_cookie( Redirect::to(&format!("/day/{date_text}")).into_response(), &auth.token, )) } pub async fn show_public_day( State(state): State, Path((username, date_text)): Path<(String, String)>, ) -> Result { validate_date(&date_text)?; 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)? }; if !planning.public_entries { return Err((StatusCode::NOT_FOUND, "Page not found".to_string())); } let (entries, weight) = { let db_conn = state.db.lock().await; let entries = db::fetch_day_entries(&db_conn, user.id, &date_text).map_err(internal_db_error)?; let weight = db::fetch_weight_for_day(&db_conn, user.id, &date_text) .map_err(internal_db_error)?; (entries, weight) }; let daily_total = entries.iter().map(|entry| entry.calories).sum(); let chart_labels = entries .iter() .map(|entry| entry.name.clone()) .collect::>(); let chart_values = entries .iter() .map(|entry| entry.calories as f64) .collect::>(); let page = PublicDayPageView { username: user.username, date_text, daily_total, entries, show_weight: planning.public_weights, weight_label: weight .map(|w| format!("{w:.1} lbs")) .unwrap_or_else(|| "-".to_string()), chart_labels_js: js_string_array(&chart_labels), chart_values_js: js_f64_array(&chart_values), }; Ok(Html(views::render_public_day_page(&page).map_err(internal_template_error)?).into_response()) } async fn render_calendar_for_month( state: &AppState, auth: &AuthUser, focus_date: NaiveDate, ) -> Result { let user_id = auth.user.id; let today = Local::now().date_naive(); let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?; let totals = { 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(), ) .map_err(internal_db_error)? }; let weights = { 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(), ) .map_err(internal_db_error)? }; 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()); } for day_num in 1..=days_in_month { if let Some(date) = NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month(), day_num as u32) { let date_text = date.format("%Y-%m-%d").to_string(); let summary = totals.get(&date_text); let entry_count = summary.map(|s| s.entry_count).unwrap_or(0); let weight_label = weights .get(&date_text) .map(|w| format!("{w:.1} lbs")) .unwrap_or_else(|| "No weight (lbs)".to_string()); cells.push(CalendarCellView::day( format!("/day/{date_text}"), date_text, day_num as u32, summary.map(|s| s.total_calories).unwrap_or(0), entry_count_label(entry_count), weight_label, date == today, date > today, )); } } let total_cells = first_weekday + days_in_month as usize; let tail_padding = (7 - (total_cells % 7)) % 7; for _ in 0..tail_padding { cells.push(CalendarCellView::padding()); } let page = CalendarPageView { month_label: focus_date.format("%B %Y").to_string(), today_date: today.format("%Y-%m-%d").to_string(), focus_month_value: focus_date.format("%Y-%m").to_string(), nav: calendar_nav_for_month(focus_date, today), cells, }; 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_for_user( state: &AppState, user_id: i64, title: &str, end_day_inclusive: NaiveDate, day_count: i64, floor_start_date: Option, ) -> Result { let original_start = end_day_inclusive - Days::new((day_count - 1) as u64); let effective_start = if let Some(first_date) = floor_start_date { if first_date > original_start { first_date } else { original_start } } else { original_start }; let effective_day_count = if effective_start > end_day_inclusive { 0 } else { (end_day_inclusive - effective_start).num_days() + 1 }; let total_calories = if effective_day_count == 0 { 0 } else { let window_end = end_day_inclusive + Days::new(1); 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, user_id, &start_text, &end_text) .map_err(internal_db_error)? }; let avg = if effective_day_count == 0 { 0.0 } else { total_calories as f64 / effective_day_count as f64 }; Ok(ReportCardView { title: title.to_string(), range_label: format!( "{} to {}", effective_start.format("%Y-%m-%d"), end_day_inclusive.format("%Y-%m-%d") ), day_count: effective_day_count, total_calories, average_calories_per_day: format!("{avg:.1}"), }) } async fn build_rolling_3day_chart_series( state: &AppState, user_id: i64, start_day: NaiveDate, end_day: NaiveDate, ) -> Result<(Vec, Vec>, Vec), AppError> { let range_end = end_day + Days::new(1); let start_text = start_day.format("%Y-%m-%d").to_string(); let end_text = range_end.format("%Y-%m-%d").to_string(); let (calorie_map, weight_map) = { let db_conn = state.db.lock().await; 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) }; 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(); labels.push(date_key.clone()); daily_calories.push(*calorie_map.get(&date_key).unwrap_or(&0) as f64); daily_weights.push(weight_map.get(&date_key).copied()); day += Duration::days(1); } let mut rolling_calories = Vec::with_capacity(labels.len()); let mut rolling_weights = Vec::with_capacity(labels.len()); for i in 0..labels.len() { let start_idx = i.saturating_sub(2); let cal_window = &daily_calories[start_idx..=i]; rolling_calories.push(cal_window.iter().sum::() / cal_window.len() as f64); let mut weight_sum = 0.0; let mut weight_count = 0; for item in &daily_weights[start_idx..=i] { if let Some(w) = item { weight_sum += *w; weight_count += 1; } } rolling_weights.push(if weight_count == 0 { None } else { Some(weight_sum / weight_count as f64) }); } Ok((labels, rolling_weights, rolling_calories)) } async fn get_auth_user( state: &AppState, headers: &HeaderMap, ) -> Result, AppError> { let Some(token) = session_token_from_headers(headers) else { return Ok(None); }; let now = Utc::now().timestamp(); let session = { let db_conn = state.db.lock().await; db::fetch_session(&db_conn, &token).map_err(internal_db_error)? }; let Some(session) = session else { return Ok(None); }; if session.expires_at_unix <= now { let db_conn = state.db.lock().await; let _ = db::delete_session(&db_conn, &token); return Ok(None); } let user = { let db_conn = state.db.lock().await; db::fetch_user_by_id(&db_conn, session.user_id).map_err(internal_db_error)? }; let Some(user) = user else { return Ok(None); }; let new_expiry = session_expiry_unix(); { let db_conn = state.db.lock().await; db::touch_session(&db_conn, &token, new_expiry).map_err(internal_db_error)?; } Ok(Some(AuthUser { user, token })) } async fn signup_allowed(state: &AppState) -> Result { if state.config.allow_signup { return Ok(true); } let db_conn = state.db.lock().await; let count = db::count_users(&db_conn).map_err(internal_db_error)?; Ok(count == 0) } async fn render_login_with_error(state: &AppState, error: &str) -> Result { let page = LoginPageView { allow_signup: signup_allowed(state).await?, error: error.to_string(), }; Ok(Html(views::render_login_page(&page).map_err(internal_template_error)?).into_response()) } async fn render_signup_with_error(state: &AppState, error: &str) -> Result { let page = SignupPageView { allow_signup: signup_allowed(state).await?, error: error.to_string(), }; Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response()) } fn render_change_password_page( token: &str, error: &str, success: &str, ) -> Result { let page = ChangePasswordPageView { error: error.to_string(), success: success.to_string(), }; let resp = Html(views::render_change_password_page(&page).map_err(internal_template_error)?) .into_response(); Ok(with_session_cookie(resp, token)) } fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut ArgonOsRng); Argon2::default() .hash_password(password.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Password hashing failed".to_string(), ) }) } fn verify_password(password_hash: &str, password: &str) -> bool { let parsed = match PasswordHash::new(password_hash) { Ok(v) => v, Err(_) => return false, }; Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() } fn generate_session_token() -> String { let mut bytes = [0_u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect::() } fn session_expiry_unix() -> i64 { Utc::now().timestamp() + SESSION_SECONDS } fn session_token_from_headers(headers: &HeaderMap) -> Option { let raw = headers.get(header::COOKIE)?.to_str().ok()?; for part in raw.split(';') { let trimmed = part.trim(); let (name, value) = trimmed.split_once('=')?; if name == SESSION_COOKIE { return Some(value.to_string()); } } None } fn with_session_cookie(mut response: Response, token: &str) -> Response { let cookie = format!( "{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000", name = SESSION_COOKIE ); if let Ok(value) = HeaderValue::from_str(&cookie) { response.headers_mut().insert(header::SET_COOKIE, value); } response } fn clear_session_cookie(mut response: Response) -> Response { let cookie = format!( "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", name = SESSION_COOKIE ); if let Ok(value) = HeaderValue::from_str(&cookie) { response.headers_mut().insert(header::SET_COOKIE, value); } response } fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppError> { let first_day = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).ok_or(( 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)) } fn calendar_nav_for_month(focus_date: NaiveDate, today: NaiveDate) -> CalendarNav { let prev_month = if focus_date.month() == 1 { NaiveDate::from_ymd_opt(focus_date.year() - 1, 12, 1).expect("valid date") } 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 = NaiveDate::from_ymd_opt(focus_date.year() + 1, focus_date.month(), 1).expect("valid date"); CalendarNav { prev_month_href: calendar_path(prev_month), next_month_href: calendar_path(next_month), prev_year_href: calendar_path(prev_year), next_year_href: calendar_path(next_year), current_month_href: calendar_path( NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid date"), ), show_current_month_link: focus_date.year() != today.year() || focus_date.month() != today.month(), } } fn calendar_path(date: NaiveDate) -> String { format!("/calendar/{}/{}", date.year(), date.month()) } fn validate_date(date_text: &str) -> Result<(), AppError> { NaiveDate::parse_from_str(date_text, "%Y-%m-%d") .map(|_| ()) .map_err(|_| { ( StatusCode::BAD_REQUEST, "Date must be YYYY-MM-DD".to_string(), ) }) } fn parse_entry_form_fields(form: &HashMap) -> Result<(String, i64), AppError> { let name = form.get("name").map(|v| v.trim()).unwrap_or(""); let calories = form .get("calories") .ok_or((StatusCode::BAD_REQUEST, "Calories are required".to_string()))? .parse::() .map_err(|_| { ( StatusCode::BAD_REQUEST, "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)) } fn parse_optional_positive_f64(raw: Option<&String>) -> Result, AppError> { match raw { None => Ok(None), Some(value) => { let trimmed = value.trim(); if trimmed.is_empty() { return Ok(None); } let parsed = trimmed.parse::().map_err(|_| { ( StatusCode::BAD_REQUEST, "Value must be a number".to_string(), ) })?; if parsed <= 0.0 { return Err((StatusCode::BAD_REQUEST, "Value must be > 0".to_string())); } Ok(Some(parsed)) } } } fn parse_optional_non_negative_i64(raw: Option<&String>) -> Result, AppError> { match raw { None => Ok(None), Some(value) => { let trimmed = value.trim(); if trimmed.is_empty() { return Ok(None); } let parsed = trimmed.parse::().map_err(|_| { ( StatusCode::BAD_REQUEST, "Target calories must be a number".to_string(), ) })?; if parsed < 0 { return Err(( StatusCode::BAD_REQUEST, "Target calories must be >= 0".to_string(), )); } Ok(Some(parsed)) } } } fn entry_count_label(entry_count: i64) -> String { if entry_count == 0 { "No entries".to_string() } else if entry_count == 1 { "1 entry".to_string() } else { format!("{} entries", entry_count) } } fn js_string_array(values: &[String]) -> String { let quoted = values .iter() .map(|v| format!("\"{}\"", v.replace('\"', "\\\""))) .collect::>() .join(","); format!("[{quoted}]") } fn js_f64_array(values: &[f64]) -> String { let inner = values .iter() .map(|v| format!("{v:.3}")) .collect::>() .join(","); format!("[{inner}]") } fn js_optional_f64_array(values: &[Option]) -> String { let inner = values .iter() .map(|v| match v { Some(num) => format!("{num:.3}"), None => "null".to_string(), }) .collect::>() .join(","); format!("[{inner}]") } fn internal_db_error(error: rusqlite::Error) -> AppError { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", error), ) } fn internal_template_error(error: askama::Error) -> AppError { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {}", error), ) }