CurseTechnique/src/handlers.rs

1229 lines
40 KiB
Rust

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<Mutex<Connection>>,
pub config: AppConfig,
}
#[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
}
pub async fn show_calendar_for_month(
State(state): State<AppState>,
headers: HeaderMap,
Path((year, month)): Path<(i32, u32)>,
) -> 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, &auth, focus_date).await
}
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(),
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<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, 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::<Vec<_>>()
} 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<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, 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<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 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<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
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<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> 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,
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<AppState>,
headers: HeaderMap,
Path(date_text): Path<String>,
) -> 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, 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<AppState>,
headers: HeaderMap,
Path(date_text): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> 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")
.ok_or((StatusCode::BAD_REQUEST, "Weight is required".to_string()))?
.trim()
.parse::<f64>()
.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<AppState>,
headers: HeaderMap,
Path(date_text): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> 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, 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<AppState>,
headers: HeaderMap,
Path((date_text, id)): Path<(String, i64)>,
Form(form): Form<HashMap<String, String>>,
) -> 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, 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<AppState>,
headers: HeaderMap,
Path((date_text, id)): Path<(String, i64)>,
) -> 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, 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<AppState>,
Path((username, date_text)): Path<(String, String)>,
) -> Result<Response, AppError> {
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::<Vec<_>>();
let chart_values = entries
.iter()
.map(|entry| entry.calories as f64)
.collect::<Vec<_>>();
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<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)?;
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<NaiveDate>,
) -> Result<ReportCardView, AppError> {
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<String>, Vec<Option<f64>>, Vec<f64>), 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::<f64>() / 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<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 render_change_password_page(
token: &str,
error: &str,
success: &str,
) -> Result<Response, AppError> {
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<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> {
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<String, String>) -> 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::<i64>()
.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<Option<f64>, AppError> {
match raw {
None => Ok(None),
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(None);
}
let parsed = trimmed.parse::<f64>().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<Option<i64>, AppError> {
match raw {
None => Ok(None),
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(None);
}
let parsed = trimmed.parse::<i64>().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::<Vec<_>>()
.join(",");
format!("[{quoted}]")
}
fn js_f64_array(values: &[f64]) -> String {
let inner = values
.iter()
.map(|v| format!("{v:.3}"))
.collect::<Vec<_>>()
.join(",");
format!("[{inner}]")
}
fn js_optional_f64_array(values: &[Option<f64>]) -> String {
let inner = values
.iter()
.map(|v| match v {
Some(num) => format!("{num:.3}"),
None => "null".to_string(),
})
.collect::<Vec<_>>()
.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),
)
}