1229 lines
40 KiB
Rust
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),
|
|
)
|
|
}
|