From bc4cf24f235fa330ad6c10466c42c31971a21251 Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sat, 7 Feb 2026 16:23:03 -0800 Subject: [PATCH] adding chang epassword and public --- .gitignore | 1 + README.md | 1 + src/config.rs | 22 +++-- src/db.rs | 12 +++ src/handlers.rs | 150 ++++++++++++++++++++++++++++++++- src/main.rs | 10 ++- src/views.rs | 52 ++++++++++++ templates/change_password.html | 53 ++++++++++++ templates/planning.html | 8 ++ templates/public_day.html | 84 ++++++++++++++++++ templates/public_profile.html | 98 ++++++++++++++++++++- 11 files changed, 479 insertions(+), 12 deletions(-) create mode 100644 templates/change_password.html create mode 100644 templates/public_day.html diff --git a/.gitignore b/.gitignore index f7363ef..17e6628 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +.DS_Store config/ tmp/ data/ diff --git a/README.md b/README.md index ab49f1d..89769fb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Open: http://127.0.0.1:3000 ```conf allow_signup = true +bind_port = 3000 ``` If the file is missing, the app creates it with defaults on startup. diff --git a/src/config.rs b/src/config.rs index 86fbef7..51f76f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,15 @@ use std::path::Path; #[derive(Clone, Copy)] pub struct AppConfig { pub allow_signup: bool, + pub bind_port: u16, } impl Default for AppConfig { fn default() -> Self { - Self { allow_signup: true } + Self { + allow_signup: true, + bind_port: 3000, + } } } @@ -27,9 +31,17 @@ pub fn load_config() -> AppConfig { continue; } if let Some((key, value)) = line.split_once('=') { - if key.trim() == "allow_signup" { - let v = value.trim(); - cfg.allow_signup = v.eq_ignore_ascii_case("true") || v == "1"; + let key = key.trim(); + let value = value.trim(); + + if key == "allow_signup" { + cfg.allow_signup = value.eq_ignore_ascii_case("true") || value == "1"; + } + + if key == "bind_port" { + if let Ok(port) = value.parse::() { + cfg.bind_port = port; + } } } } @@ -45,6 +57,6 @@ fn ensure_default_config_file(path: &str) { let _ = fs::create_dir_all(parent); } - let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n"; + let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n\n# TCP port the web server binds to.\nbind_port = 3000\n"; let _ = fs::write(path, default); } diff --git a/src/db.rs b/src/db.rs index e8eaf77..3ad2c9d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -156,6 +156,18 @@ pub fn fetch_user_by_id( .optional() } +pub fn update_user_password( + conn: &Connection, + user_id: i64, + password_hash: &str, +) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE users SET password_hash = ?1 WHERE id = ?2", + params![password_hash, user_id], + )?; + Ok(()) +} + pub fn create_or_replace_session( conn: &Connection, token: &str, diff --git a/src/handlers.rs b/src/handlers.rs index 437346e..76ceff0 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -14,9 +14,9 @@ use tokio::sync::Mutex; use crate::config::AppConfig; use crate::db; use crate::views::{ - self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView, - PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView, - SignupPageView, + self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView, + LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView, + ReportCardView, ReportsPageView, SignupPageView, }; type AppError = (StatusCode, String); @@ -228,6 +228,7 @@ pub async fn show_public_profile( 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) @@ -269,6 +270,16 @@ pub async fn show_public_profile( 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, @@ -277,6 +288,9 @@ pub async fn show_public_profile( 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")) @@ -410,6 +424,67 @@ pub async fn show_planning( 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, @@ -578,6 +653,61 @@ pub async fn remove_entry( )) } +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, @@ -832,6 +962,20 @@ async fn render_signup_with_error(state: &AppState, error: &str) -> Result 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() diff --git a/src/main.rs b/src/main.rs index aa80c16..1f40bf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use crate::handlers::AppState; const DB_DIR: &str = "data"; const DB_PATH: &str = "data/app.db"; -const LISTEN_ADDR: &str = "127.0.0.1:3000"; +const LISTEN_HOST: &str = "127.0.0.1"; #[tokio::main] async fn main() -> Result<(), Box> { @@ -40,9 +40,12 @@ async fn main() -> Result<(), Box> { .route("/signup", post(handlers::signup)) .route("/logout", post(handlers::logout)) .route("/u/{username}", get(handlers::show_public_profile)) + .route("/u/{username}/day/{date}", get(handlers::show_public_day)) .route("/reports", get(handlers::show_reports)) .route("/planning", get(handlers::show_planning)) .route("/planning", post(handlers::update_planning)) + .route("/planning/password", get(handlers::show_change_password)) + .route("/planning/password", post(handlers::change_password)) .route( "/calendar/{year}/{month}", get(handlers::show_calendar_for_month), @@ -57,8 +60,9 @@ async fn main() -> Result<(), Box> { ) .with_state(state); - let listener = tokio::net::TcpListener::bind(LISTEN_ADDR).await?; - println!("Listening on http://{}", LISTEN_ADDR); + let listen_addr = format!("{}:{}", LISTEN_HOST, app_config.bind_port); + let listener = tokio::net::TcpListener::bind(&listen_addr).await?; + println!("Listening on http://{}", listen_addr); axum::serve(listener, app).await?; Ok(()) diff --git a/src/views.rs b/src/views.rs index cbb0054..11ca6dc 100644 --- a/src/views.rs +++ b/src/views.rs @@ -153,6 +153,11 @@ pub struct PlanningPageView { pub public_planning: bool, } +pub struct ChangePasswordPageView { + pub error: String, + pub success: String, +} + pub struct LoginPageView { pub allow_signup: bool, pub error: String, @@ -166,6 +171,7 @@ pub struct SignupPageView { #[derive(Clone)] pub struct PublicDaySummaryView { pub date_text: String, + pub day_href: String, pub calories: i64, pub weight_label: String, } @@ -178,11 +184,25 @@ pub struct PublicProfilePageView { pub show_planning: bool, pub recent_days: Vec, pub report_cards: Vec, + pub chart_labels_js: String, + pub calories_js: String, + pub weights_js: String, pub target_weight_label: String, pub target_calories_label: String, pub bmr_label: String, } +pub struct PublicDayPageView { + pub username: String, + pub date_text: String, + pub daily_total: i64, + pub entries: Vec, + pub show_weight: bool, + pub weight_label: String, + pub chart_labels_js: String, + pub chart_values_js: String, +} + #[derive(Template)] #[template(path = "calendar.html")] struct CalendarTemplate<'a> { @@ -207,6 +227,12 @@ struct PlanningTemplate<'a> { page: &'a PlanningPageView, } +#[derive(Template)] +#[template(path = "change_password.html")] +struct ChangePasswordTemplate<'a> { + page: &'a ChangePasswordPageView, +} + #[derive(Template)] #[template(path = "login.html")] struct LoginTemplate<'a> { @@ -225,6 +251,12 @@ struct PublicProfileTemplate<'a> { page: &'a PublicProfilePageView, } +#[derive(Template)] +#[template(path = "public_day.html")] +struct PublicDayTemplate<'a> { + page: &'a PublicDayPageView, +} + impl CalendarTemplate<'_> { fn active_tab(&self) -> &str { "calendar" @@ -249,6 +281,12 @@ impl PlanningTemplate<'_> { } } +impl ChangePasswordTemplate<'_> { + fn active_tab(&self) -> &str { + "planning" + } +} + impl LoginTemplate<'_> { fn active_tab(&self) -> &str { "" @@ -267,6 +305,12 @@ impl PublicProfileTemplate<'_> { } } +impl PublicDayTemplate<'_> { + fn active_tab(&self) -> &str { + "" + } +} + pub fn render_calendar_page(page: &CalendarPageView) -> Result { CalendarTemplate { page }.render() } @@ -283,6 +327,10 @@ pub fn render_planning_page(page: &PlanningPageView) -> Result Result { + ChangePasswordTemplate { page }.render() +} + pub fn render_login_page(page: &LoginPageView) -> Result { LoginTemplate { page }.render() } @@ -294,3 +342,7 @@ pub fn render_signup_page(page: &SignupPageView) -> Result Result { PublicProfileTemplate { page }.render() } + +pub fn render_public_day_page(page: &PublicDayPageView) -> Result { + PublicDayTemplate { page }.render() +} diff --git a/templates/change_password.html b/templates/change_password.html new file mode 100644 index 0000000..fe907c5 --- /dev/null +++ b/templates/change_password.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block title %}Change Password{% endblock %} + +{% block content %} +
+

Change Password

+

Use your current password to set a new one.

+ + {% if !page.error.is_empty() %} +

{{ page.error }}

+ {% endif %} + {% if !page.success.is_empty() %} +

{{ page.success }}

+ {% endif %} + +
+ + + +
+ + Back to Planning +
+
+
+{% endblock %} diff --git a/templates/planning.html b/templates/planning.html index 2d4d0e3..5d8968d 100644 --- a/templates/planning.html +++ b/templates/planning.html @@ -71,4 +71,12 @@ + +
+

Security

+

Update your account password.

+ + Change Password + +
{% endblock %} diff --git a/templates/public_day.html b/templates/public_day.html new file mode 100644 index 0000000..ff08b1a --- /dev/null +++ b/templates/public_day.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{% block title %}Shared Day {{ page.date_text }}{% endblock %} + +{% block content %} + + + Back to @{{ page.username }} + + +
+

@{{ page.username }} - {{ page.date_text }}

+

+ Daily total: {{ page.daily_total }} cal +

+ {% if page.show_weight %} +

Weight: {{ page.weight_label }}

+ {% endif %} +
+ +
+

Food Entries

+
+ + + + + + {% if page.entries.is_empty() %} + + {% else %} + {% for entry in page.entries %} + + + + + {% endfor %} + {% endif %} + +
FoodCalories
No entries shared for this day.
{{ entry.name }}{{ entry.calories }} cal
+
+
+ +{% if !page.entries.is_empty() %} +
+

Calories by Entry

+
+ +
+
+{% endif %} +{% endblock %} + +{% block scripts %} +{% if !page.entries.is_empty() %} + + +{% endif %} +{% endblock %} diff --git a/templates/public_profile.html b/templates/public_profile.html index 2c65b2d..f2d97dd 100644 --- a/templates/public_profile.html +++ b/templates/public_profile.html @@ -19,7 +19,13 @@ {% for row in page.recent_days %} - {{ row.date_text }} + + {% if page.show_entries %} + {{ row.date_text }} + {% else %} + {{ row.date_text }} + {% endif %} + {% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %} {% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %} @@ -30,6 +36,31 @@ {% endif %} +{% if page.show_entries || page.show_weights %} +
+

Recent Trends

+

Past 14 days

+
+ {% if page.show_entries %} +
+

Daily Calories

+
+ +
+
+ {% endif %} + {% if page.show_weights %} +
+

Daily Weight (lbs)

+
+ +
+
+ {% endif %} +
+
+{% endif %} + {% if page.show_reports %}
{% for card in page.report_cards %} @@ -60,3 +91,68 @@
{% endif %} {% endblock %} + +{% block scripts %} +{% if page.show_entries || page.show_weights %} + + +{% endif %} +{% endblock %}