use actix_web::{HttpResponse, Responder, post, web};
use log::info;
use serde::Deserialize;
use crate::{
auth::{
HodisContext, create_exercise, create_goal, create_user, generate_short_code,
verify_password,
},
config::Server,
view::auth::{render_login_success, render_signup_success},
view::{render_error, render_success},
};
#[derive(Debug, Deserialize)]
pub struct SignupForm {
pub username: String,
pub email: String,
pub password: String,
pub invite_code: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct DeleteInviteForm {
pub code: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateExerciseForm {
pub name: String,
pub description: String,
pub unit: String,
pub points_per_unit: String,
}
#[derive(Debug, Deserialize)]
pub struct DeleteExerciseForm {
pub id: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateGoalForm {
pub name: String,
pub target_points: String,
}
#[derive(Debug, Deserialize)]
pub struct DeleteGoalForm {
pub id: String,
}
#[derive(Debug, Deserialize)]
pub struct EditGoalForm {
pub id: String,
pub target_points: String,
}
#[derive(Debug, Deserialize)]
pub struct ToggleAdminForm {
pub user_id: String,
pub is_admin: String,
}
#[derive(Debug, Deserialize)]
pub struct LogExerciseForm {
pub exercise_id: String,
pub amount: String,
}
#[derive(Debug, Deserialize)]
pub struct ToggleFavoriteForm {
pub exercise_id: String,
}
#[post("/auth/signup")]
pub async fn signup_handler(
auth_state: web::Data<HodisContext>,
form: web::Form<SignupForm>,
) -> impl Responder {
let db = auth_state.db();
if form.invite_code.is_empty() {
return HttpResponse::BadRequest()
.body(render_error("You need an invite link to create an account").into_string());
}
match db.validate_invite_link(&form.invite_code).await {
Ok(true) => {}
Ok(false) => {
return HttpResponse::BadRequest()
.body(render_error("Invalid invite link").into_string());
}
Err(e) => {
log::error!("Database error checking invite: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
}
if form.username.len() < 3 {
return HttpResponse::BadRequest()
.body(render_error("Username must be at least 3 characters").into_string());
}
if form.email.is_empty() || !form.email.contains('@') {
return HttpResponse::BadRequest()
.body(render_error("Please enter a valid email address").into_string());
}
if form.password.len() < 8 {
return HttpResponse::BadRequest()
.body(render_error("Password must be at least 8 characters").into_string());
}
match db.get_user_by_username(&form.username).await {
Ok(Some(_)) => {
return HttpResponse::Conflict()
.body(render_error("Username already exists").into_string());
}
Ok(None) => {}
Err(e) => {
log::error!("Database error: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
}
let user = match create_user(
form.username.clone(),
form.email.clone(),
form.password.clone(),
) {
Ok(user) => user,
Err(e) => {
log::error!("Failed to create user: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to create user").into_string());
}
};
if let Err(e) = db.create_user(&user).await {
log::error!("Failed to save user: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to save user").into_string());
}
info!("Created user: {}", user.username);
HttpResponse::Ok()
.content_type("text/html")
.body(render_signup_success(&user.username).into_string())
}
#[post("/auth/login")]
pub async fn login_handler(
auth_state: web::Data<HodisContext>,
form: web::Form<LoginForm>,
) -> impl Responder {
let db = auth_state.db();
let user = match db.get_user_by_username(&form.username).await {
Ok(Some(user)) => user,
Ok(None) => {
return HttpResponse::Unauthorized()
.body(render_error("Invalid credentials").into_string());
}
Err(e) => {
log::error!("Database error: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
};
match verify_password(&form.password, &user.password_hash) {
Ok(true) => {}
Ok(false) => {
return HttpResponse::Unauthorized()
.body(render_error("Invalid credentials").into_string());
}
Err(e) => {
log::error!("Password verification error: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Authentication error").into_string());
}
}
let token = match auth_state.create_session(user.id.clone()).await {
Ok(token) => token,
Err(e) => {
log::error!("Failed to create session: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to create session").into_string());
}
};
info!("User logged in: {}", form.username);
HttpResponse::Ok()
.insert_header(("HX-Redirect", "/"))
.cookie(
actix_web::cookie::Cookie::build("session", token)
.path("/")
.http_only(true)
.same_site(actix_web::cookie::SameSite::Strict)
.max_age(actix_web::cookie::time::Duration::days(365))
.finish(),
)
.body(render_login_success(&user.username).into_string())
}
#[post("/auth/logout")]
pub async fn logout_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
) -> impl Responder {
let token = match req.cookie("session") {
Some(cookie) => cookie.value().to_string(),
None => {
return HttpResponse::Unauthorized().body("Not logged in");
}
};
if let Err(e) = auth_state.invalidate_token(&token).await {
log::error!("Failed to invalidate token: {e}");
return HttpResponse::InternalServerError().body("Failed to logout");
}
HttpResponse::Found()
.insert_header(("Location", "/"))
.cookie(
actix_web::cookie::Cookie::build("session", "")
.path("/")
.http_only(true)
.same_site(actix_web::cookie::SameSite::Strict)
.max_age(actix_web::cookie::time::Duration::ZERO)
.finish(),
)
.finish()
}
#[post("/admin/invites/create")]
pub async fn admin_create_invite_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
let code = generate_short_code();
if let Err(e) = auth_state.db().create_invite_link(&code).await {
log::error!("Failed to create invite link: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to create invite link").into_string());
}
info!("Created invite link: {code}");
let invite_links = match auth_state.db().get_all_invite_links().await {
Ok(links) => links,
Err(e) => {
log::error!("Failed to fetch invite links: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(crate::view::admin::render_invites_list(&invite_links).into_string())
}
#[post("/admin/invites/delete")]
pub async fn admin_delete_invite_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<DeleteInviteForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
if let Err(e) = auth_state.db().delete_invite_link(&form.code).await {
log::error!("Failed to delete invite link: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to delete invite link").into_string());
}
let invite_links = match auth_state.db().get_all_invite_links().await {
Ok(links) => links,
Err(e) => {
log::error!("Failed to fetch invite links: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(crate::view::admin::render_invites_list(&invite_links).into_string())
}
#[post("/admin/exercises/create")]
pub async fn admin_create_exercise_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<CreateExerciseForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
let points: f64 = match form.points_per_unit.parse() {
Ok(p) if p > 0.0 => p,
_ => {
return HttpResponse::BadRequest()
.body(render_error("Points per unit must be a positive number").into_string());
}
};
let unit = if form.unit.is_empty() {
"reps".to_string()
} else {
form.unit.clone()
};
let exercise = create_exercise(form.name.clone(), form.description.clone(), unit, points);
if let Err(e) = auth_state.db().create_exercise(&exercise).await {
log::error!("Failed to create exercise: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to create exercise").into_string());
}
info!("Created exercise: {}", exercise.name);
let exercises = match auth_state.db().get_all_exercises().await {
Ok(exs) => exs,
Err(e) => {
log::error!("Failed to fetch exercises: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(crate::view::admin::render_exercises_list(&exercises).into_string())
}
#[post("/admin/exercises/delete")]
pub async fn admin_delete_exercise_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<DeleteExerciseForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
if let Err(e) = auth_state.db().delete_exercise(&form.id).await {
log::error!("Failed to delete exercise: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to delete exercise").into_string());
}
let exercises = match auth_state.db().get_all_exercises().await {
Ok(exs) => exs,
Err(e) => {
log::error!("Failed to fetch exercises: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(crate::view::admin::render_exercises_list(&exercises).into_string())
}
#[post("/admin/goals/create")]
pub async fn admin_create_goal_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<CreateGoalForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
let target_points: f64 = match form.target_points.parse() {
Ok(p) if p > 0.0 => p,
_ => {
return HttpResponse::BadRequest()
.body(render_error("Target points must be a positive number").into_string());
}
};
let goal = create_goal(form.name.clone(), target_points);
if let Err(e) = auth_state.db().create_goal(&goal).await {
log::error!("Failed to create goal: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to create goal").into_string());
}
info!("Created goal: {}", goal.name);
HttpResponse::Ok()
.content_type("text/html")
.insert_header(("HX-Redirect", "/admin"))
.body(render_success("Goal created!").into_string())
}
#[post("/admin/goals/delete")]
pub async fn admin_delete_goal_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<DeleteGoalForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
if let Err(e) = auth_state.db().delete_goal(&form.id).await {
log::error!("Failed to delete goal: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to delete goal").into_string());
}
info!("Deleted goal: {}", form.id);
HttpResponse::Ok()
.content_type("text/html")
.insert_header(("HX-Redirect", "/admin"))
.body(render_success("Goal deleted!").into_string())
}
#[post("/admin/goals/edit")]
pub async fn admin_edit_goal_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<EditGoalForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
let target_points: f64 = match form.target_points.parse() {
Ok(p) if p > 0.0 => p,
_ => {
return HttpResponse::BadRequest()
.body(render_error("Target points must be a positive number").into_string());
}
};
if let Err(e) = auth_state
.db()
.update_goal_target_points(&form.id, target_points)
.await
{
log::error!("Failed to update goal: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to update goal").into_string());
}
info!("Updated goal {} target to {}", form.id, target_points);
let invite_links = auth_state
.db()
.get_all_invite_links()
.await
.unwrap_or_default();
let exercises = auth_state
.db()
.get_all_exercises()
.await
.unwrap_or_default();
let goal = auth_state.db().get_active_goal().await.unwrap_or_default();
let total_points = auth_state.db().get_total_points().await.unwrap_or(0.0);
let all_logs = auth_state
.db()
.get_all_exercise_logs()
.await
.unwrap_or_default();
let all_users = auth_state.db().get_all_users().await.unwrap_or_default();
HttpResponse::Ok().content_type("text/html").body(
crate::view::admin::render_admin_content(
&invite_links,
&exercises,
&goal,
total_points,
&all_logs,
&all_users,
)
.into_string(),
)
}
#[post("/admin/users/toggle-admin")]
pub async fn admin_toggle_admin_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
form: web::Form<ToggleAdminForm>,
) -> impl Responder {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized");
}
let is_admin = form.is_admin == "true";
if let Err(e) = auth_state
.db()
.set_user_admin(&form.user_id, is_admin)
.await
{
log::error!("Failed to toggle admin: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to update user").into_string());
}
info!("Set admin={} for user {}", is_admin, form.user_id);
let invite_links = auth_state
.db()
.get_all_invite_links()
.await
.unwrap_or_default();
let exercises = auth_state
.db()
.get_all_exercises()
.await
.unwrap_or_default();
let goal = auth_state.db().get_active_goal().await.unwrap_or_default();
let total_points = auth_state.db().get_total_points().await.unwrap_or(0.0);
let all_logs = auth_state
.db()
.get_all_exercise_logs()
.await
.unwrap_or_default();
let all_users = auth_state.db().get_all_users().await.unwrap_or_default();
HttpResponse::Ok().content_type("text/html").body(
crate::view::admin::render_admin_content(
&invite_links,
&exercises,
&goal,
total_points,
&all_logs,
&all_users,
)
.into_string(),
)
}
#[post("/log-exercise")]
pub async fn log_exercise_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
form: web::Form<LogExerciseForm>,
) -> impl Responder {
let token = match req.cookie("session") {
Some(cookie) => cookie.value().to_string(),
None => {
return HttpResponse::Unauthorized().body(render_error("Not logged in").into_string());
}
};
let user_id = match auth_state.validate_token(&token).await {
Some(id) => id,
None => {
return HttpResponse::Unauthorized()
.body(render_error("Session expired").into_string());
}
};
let amount: f64 = match form.amount.parse() {
Ok(a) if a > 0.0 => a,
_ => {
return HttpResponse::BadRequest()
.body(render_error("Amount must be a positive number").into_string());
}
};
let exercise = match auth_state.db().get_exercise_by_id(&form.exercise_id).await {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::BadRequest()
.body(render_error("Exercise not found").into_string());
}
Err(e) => {
log::error!("Database error: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Database error").into_string());
}
};
let points_earned = amount * exercise.points_per_unit;
let log = crate::auth::ExerciseLog {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.clone(),
exercise_id: exercise.id.clone(),
amount,
points_earned,
created_at: chrono::Utc::now().to_rfc3339(),
};
if let Err(e) = auth_state.db().create_exercise_log(&log).await {
log::error!("Failed to log exercise: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to log exercise").into_string());
}
let goal = auth_state.db().get_active_goal().await.unwrap_or_default();
let total_points = auth_state.db().get_total_points().await.unwrap_or(0.0);
let user_points = auth_state
.db()
.get_points_for_user(&user_id)
.await
.unwrap_or(0.0);
let exercises = auth_state
.db()
.get_all_exercises()
.await
.unwrap_or_default();
let recent_logs = auth_state
.db()
.get_exercise_logs_for_user(&user_id)
.await
.unwrap_or_default();
let favorite_ids = auth_state
.db()
.get_favorite_exercise_ids(&user_id)
.await
.unwrap_or_default();
let success_msg = format!(
"Logged {:.1} {} of {} for {:.1} points!",
amount, exercise.unit, exercise.name, points_earned
);
let dashboard = crate::view::dashboard::render_dashboard_content(
&user_id,
&exercises,
&goal,
total_points,
user_points,
&recent_logs,
&favorite_ids,
Some(&success_msg),
);
HttpResponse::Ok().content_type("text/html").body(
maud::html! {
div id="dashboard-content" hx-target="this" hx-swap="outerHTML" {
(dashboard)
}
}
.into_string(),
)
}
#[post("/toggle-favorite")]
pub async fn toggle_favorite_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
form: web::Form<ToggleFavoriteForm>,
) -> impl Responder {
let token = match req.cookie("session") {
Some(cookie) => cookie.value().to_string(),
None => {
return HttpResponse::Unauthorized().body(render_error("Not logged in").into_string());
}
};
let user_id = match auth_state.validate_token(&token).await {
Some(id) => id,
None => {
return HttpResponse::Unauthorized()
.body(render_error("Session expired").into_string());
}
};
let is_fav = auth_state
.db()
.is_favorite_exercise(&user_id, &form.exercise_id)
.await
.unwrap_or_default();
if is_fav {
if let Err(e) = auth_state
.db()
.remove_favorite_exercise(&user_id, &form.exercise_id)
.await
{
log::error!("Failed to remove favorite: {e}");
}
} else {
if let Err(e) = auth_state
.db()
.add_favorite_exercise(&user_id, &form.exercise_id)
.await
{
log::error!("Failed to add favorite: {e}");
}
}
let exercises = auth_state
.db()
.get_all_exercises()
.await
.unwrap_or_default();
let goal = auth_state.db().get_active_goal().await.unwrap_or_default();
let total_points = auth_state.db().get_total_points().await.unwrap_or(0.0);
let user_points = auth_state
.db()
.get_points_for_user(&user_id)
.await
.unwrap_or(0.0);
let recent_logs = auth_state
.db()
.get_exercise_logs_for_user(&user_id)
.await
.unwrap_or_default();
let favorite_ids = auth_state
.db()
.get_favorite_exercise_ids(&user_id)
.await
.unwrap_or_default();
let dashboard = crate::view::dashboard::render_dashboard_content(
&user_id,
&exercises,
&goal,
total_points,
user_points,
&recent_logs,
&favorite_ids,
None,
);
HttpResponse::Ok().content_type("text/html").body(
maud::html! {
div id="dashboard-content" hx-target="this" hx-swap="outerHTML" {
(dashboard)
}
}
.into_string(),
)
}
#[post("/delete-log")]
pub async fn delete_log_handler(
req: actix_web::HttpRequest,
auth_state: web::Data<HodisContext>,
form: web::Form<DeleteExerciseForm>,
) -> impl Responder {
let token = match req.cookie("session") {
Some(cookie) => cookie.value().to_string(),
None => {
return HttpResponse::Unauthorized().body(render_error("Not logged in").into_string());
}
};
let user_id = match auth_state.validate_token(&token).await {
Some(id) => id,
None => {
return HttpResponse::Unauthorized()
.body(render_error("Session expired").into_string());
}
};
if let Err(e) = auth_state.db().delete_exercise_log(&form.id).await {
log::error!("Failed to delete log: {e}");
return HttpResponse::InternalServerError()
.body(render_error("Failed to delete log entry").into_string());
}
let exercises = auth_state
.db()
.get_all_exercises()
.await
.unwrap_or_default();
let goal = auth_state.db().get_active_goal().await.unwrap_or_default();
let total_points = auth_state.db().get_total_points().await.unwrap_or(0.0);
let user_points = auth_state
.db()
.get_points_for_user(&user_id)
.await
.unwrap_or(0.0);
let recent_logs = auth_state
.db()
.get_exercise_logs_for_user(&user_id)
.await
.unwrap_or_default();
let favorite_ids = auth_state
.db()
.get_favorite_exercise_ids(&user_id)
.await
.unwrap_or_default();
let dashboard = crate::view::dashboard::render_dashboard_content(
&user_id,
&exercises,
&goal,
total_points,
user_points,
&recent_logs,
&favorite_ids,
None,
);
HttpResponse::Ok().content_type("text/html").body(
maud::html! {
div id="dashboard-content" hx-target="this" hx-swap="outerHTML" {
(dashboard)
}
}
.into_string(),
)
}