use actix_web::{HttpRequest, HttpResponse, Result as AwResult, get, post, web};
use super::render_layout;
use crate::auth::{Exercise, ExerciseLog, Goal, HodisContext, User};
use crate::config::Server;
#[get("/admin/backup")]
pub async fn admin_backup_handler(
req: HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
) -> AwResult<HttpResponse> {
if !crate::auth::is_admin_request(&req, &auth_state, &config).await {
return Ok(HttpResponse::Unauthorized()
.insert_header(("WWW-Authenticate", "Basic realm=\"admin\""))
.body("Unauthorized"));
}
let db_path = config.db_path();
let data = match std::fs::read(db_path) {
Ok(data) => data,
Err(e) => {
log::error!("Failed to read database file for backup: {e}");
return Ok(HttpResponse::InternalServerError().body("Failed to read database"));
}
};
let filename = format!(
"hodis-backup-{}.db",
chrono::Utc::now().format("%Y-%m-%d")
);
Ok(HttpResponse::Ok()
.content_type("application/x-sqlite3")
.insert_header(("Content-Disposition", format!("attachment; filename=\"{filename}\"")))
.body(data))
}
pub fn render_invites_list(invite_links: &[(String, String)]) -> maud::Markup {
maud::html! {
@if invite_links.is_empty() {
p class="black-50 f6 tc" { "Keine aktiven Einladungslinks" }
} @else {
@for (code, created) in invite_links {
div class="flex items-center justify-between pa2 bb b--black-10" {
a href={"/auth/signup?code="(code)} class="f6 fw6 brand no-underline underline-hover" { "/auth/signup?code="(code) }
span class="f6 black-40 ml2" { (created.split('T').next().unwrap_or("")) }
form hx-post="/admin/invites/delete" hx-target="#invites-list" hx-swap="innerHTML" class="dib ma0" style="display:inline;" {
input type="hidden" name="code" value=(code);
button type="submit" class="f7 red bn bg-transparent pointer underline dim" { "Entfernen" }
}
}
}
}
}
}
pub fn render_exercises_list(exercises: &[Exercise]) -> maud::Markup {
maud::html! {
@if exercises.is_empty() {
p class="black-50 f6 tc" { "Noch keine Übungen" }
} @else {
@for ex in exercises {
div class="flex items-center justify-between pa2 bb b--black-10" {
div class="flex-auto" {
span class="f6 fw6" { (ex.name) }
span class="f6 black-50 ml2" { (format!("{:.1} pts/{}", ex.points_per_unit, ex.unit)) }
@if !ex.description.is_empty() {
br;
span class="f7 black-40" { (ex.description) }
}
}
form hx-post="/admin/exercises/delete" hx-target="#exercises-list" hx-swap="innerHTML" class="dib ma0" style="display:inline;" {
input type="hidden" name="id" value=(ex.id);
button type="submit" class="f7 red bn bg-transparent pointer underline dim" { "Löschen" }
}
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_admin_content(
invite_links: &[(String, String)],
exercises: &[Exercise],
goal: &Option<Goal>,
total_points: f64,
all_logs: &[ExerciseLog],
all_users: &[User],
) -> maud::Markup {
maud::html! {
div id="admin-content" class="mw7 center" style="padding: 0 1rem;" {
h1 class="f3 fw6 mb4" style="color: #e3001a;" { "Admin-Bereich" }
div class="mb4" {
h2 class="f4 fw6 mb2 black-70" { "Ziel" }
@if let Some(g) = goal {
div class="ba b--black-20 br2 pa3 mb2" {
div class="flex justify-between items-center mb2" {
span class="fw6" { (g.name) }
div class="flex items-center" {
form hx-post="/admin/goals/edit" hx-target="#admin-content" hx-swap="outerHTML" class="dib ma0 flex items-center" style="display:inline-flex;" {
input type="hidden" name="id" value=(g.id);
input type="number" name="target_points" value=(format!("{:.0}", g.target_points)) step="1" min="1" class="db pa1 ba b--black-30 br1 f6" style="width: 70px;";
button type="submit" class="f7 btn-brand br1 bn pointer pa1 ml1" { "Speichern" }
}
form hx-post="/admin/goals/delete" hx-target="#goal-result" hx-swap="innerHTML" class="dib ma0 ml2" style="display:inline;" {
input type="hidden" name="id" value=(g.id);
button type="submit" class="f7 red bn bg-transparent pointer underline dim" { "Löschen" }
}
}
}
div class="w-100 bg-light-gray br2" style="height: 12px;" {
@let pct = if g.target_points > 0.0 { (total_points / g.target_points * 100.0).min(100.0) } else { 0.0 };
div class="bg-brand br2" style=(format!("width: {:.1}%; height: 12px;", pct)) {}
}
p class="f6 black-50 mt1" { (format!("{:.1} / {:.0} Punkte ({:.1}%)", total_points, g.target_points, pct)) }
}
} @else {
p class="black-50 f6" { "Noch kein Ziel gesetzt" }
}
form hx-post="/admin/goals/create" hx-swap="innerHTML" hx-target="#goal-result" class="ba b--black-20 br2 pa3" {
div class="mb3" {
label class="db f6 black-70 mb1" for="goal-name" { "Zielname" }
input type="text" name="name" id="goal-name" required class="db w-100 pa2 ba b--black-30 br1" placeholder="z.B. Frühjahrs-Challenge";
}
div class="mb3" {
label class="db f6 black-70 mb1" for="goal-target" { "Zielpunkte" }
input type="number" name="target_points" id="goal-target" required step="0.1" min="0.1" class="db w-100 pa2 ba b--black-30 br1" placeholder="z.B. 1000";
}
button type="submit" class="pa2 btn-brand br1 f6 fw5" { "Ziel setzen" }
}
div id="goal-result" class="mt2" {}
}
div class="mb4" {
h2 class="f4 fw6 mb2 black-70" { "Datenbank-Backup" }
p class="f6 black-50 mb2" { "Lade eine Kopie der aktuellen Datenbank herunter." }
a href="/admin/backup" class="pa2 btn-brand br1 f6 fw5 no-underline underline-hover" download { "Backup herunterladen" }
}
div class="mb4" {
h2 class="f4 fw6 mb2 black-70" { "Einladungslinks" }
p class="f6 black-50 mb2" { "Erstelle Einladungslinks für neue Nutzer. Links bleiben gültig bis sie gelöscht werden." }
form hx-post="/admin/invites/create" hx-target="#invites-list" hx-swap="innerHTML" class="mb3" {
button type="submit" class="pa2 btn-brand br1 f6 fw5" { "Neuen Einladungslink erstellen" }
}
div class="ba b--black-20 br2" {
div id="invites-list" {
(render_invites_list(invite_links))
}
}
}
div class="mb4" {
h2 class="f4 fw6 mb2 black-70" { "Übungen" }
form hx-post="/admin/exercises/create" hx-target="#exercises-list" hx-swap="innerHTML" class="ba b--black-20 br2 pa3 mb3" {
div class="mb3" {
label class="db f6 black-70 mb1" for="ex-name" { "Name" }
input type="text" name="name" id="ex-name" required class="db w-100 pa2 ba b--black-30 br1" placeholder="z.B. Laufen";
}
div class="mb3" {
label class="db f6 black-70 mb1" for="ex-desc" { "Beschreibung" }
input type="text" name="description" id="ex-desc" class="db w-100 pa2 ba b--black-30 br1" placeholder="Optionale Beschreibung";
}
div class="mb3 flex" {
div class="flex-auto mr2" {
label class="db f6 black-70 mb1" for="ex-unit" { "Einheit (km, Wdh., usw.)" }
input type="text" name="unit" id="ex-unit" value="reps" class="db w-100 pa2 ba b--black-30 br1";
}
div style="width: 120px;" {
label class="db f6 black-70 mb1" for="ex-pts" { "Punkte/Einheit" }
input type="number" name="points_per_unit" id="ex-pts" value="1" step="0.1" min="0.1" required class="db w-100 pa2 ba b--black-30 br1";
}
}
button type="submit" class="pa2 btn-brand br1 f6 fw5" { "Übung hinzufügen" }
}
div class="ba b--black-20 br2" {
div id="exercises-list" {
(render_exercises_list(exercises))
}
}
}
div class="mb4" {
h2 class="f4 fw6 mb2 black-70" { "Registrierte Nutzer" }
@if all_users.is_empty() {
p class="black-50 f6" { "Noch keine Nutzer" }
} @else {
div class="ba b--black-20 br2 overflow-hidden" {
div class="flex pa2 bb b--black-10 bg-light-gray" {
div class="flex-auto fw6 f6" { "Benutzername" }
div class="f6 fw6" style="width: 100px;" { "Rolle" }
div class="f6 fw6" style="width: 100px;" { "Aktion" }
div class="f6 fw6 tr" style="width: 100px;" { "Beigetreten" }
}
@for user in all_users {
div class="flex pa2 bb b--black-10 items-center" {
div class="flex-auto" {
span class="f6" { (&user.username) }
}
div style="width: 100px;" {
@if user.is_admin {
span class="f6 fw6 brand" { "Admin" }
} @else {
span class="f6 black-50" { "Nutzer" }
}
}
div style="width: 100px;" {
form hx-post="/admin/users/toggle-admin" hx-target="#admin-content" hx-swap="outerHTML" class="dib ma0" style="display:inline;" {
input type="hidden" name="user_id" value=(user.id);
input type="hidden" name="is_admin" value=(if user.is_admin { "false" } else { "true" });
@if user.is_admin {
button type="submit" class="f7 red bn bg-transparent pointer underline dim" { "Admin entfernen" }
} @else {
button type="submit" class="f7 brand bn bg-transparent pointer underline dim" { "Zum Admin" }
}
}
}
div class="tr black-40 f6" style="width: 100px;" {
(user.created_at.split('T').next().unwrap_or(""))
}
}
}
}
}
}
div class="mb4" {
h2 class="f4 fw6 mb2 black-70" { "Letzte Aktivitäten" }
@if all_logs.is_empty() {
p class="black-50 f6" { "Noch keine Aktivitäten" }
} @else {
div class="ba b--black-20 br2 overflow-hidden" {
div class="flex pa2 bb b--black-10 bg-light-gray" {
div class="flex-auto fw6 f6" { "Übung" }
div class="fw6 f6" style="width: 80px;" { "Menge" }
div class="fw6 f6" style="width: 80px;" { "Punkte" }
div class="fw6 f6 tr" style="width: 100px;" { "Datum" }
}
@for log in all_logs.iter().take(20) {
div class="flex pa2 bb b--black-10 items-center" {
div class="flex-auto f6" { (&log.exercise_id[..8]) }
div class="f6" style="width: 80px;" { (format!("{:.1}", log.amount)) }
div class="f6" style="width: 80px;" { (format!("{:.1}", log.points_earned)) }
div class="tr black-40 f6" style="width: 100px;" { (log.created_at.split('T').next().unwrap_or("")) }
}
}
}
}
}
}
}
}
#[get("/admin")]
pub async fn admin_page(
req: HttpRequest,
auth_state: web::Data<HodisContext>,
config: web::Data<Server>,
) -> AwResult<maud::Markup> {
let is_admin = crate::auth::is_admin_request(&req, &auth_state, &config).await;
if !is_admin {
return Ok(maud::html! {
script {
"window.location.href = '/admin-login';"
}
});
}
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();
let content = render_admin_content(
&invite_links,
&exercises,
&goal,
total_points,
&all_logs,
&all_users,
);
Ok(render_layout(&content, None, false))
}
#[get("/admin-login")]
pub async fn admin_login_page(req: HttpRequest) -> AwResult<maud::Markup> {
let content = maud::html! {
div class="flex flex-column items-center justify-center" style="min-height: calc(100vh - 8rem); padding: 2rem 1rem;" {
div class="w-100 mw6" {
h1 class="f3 fw6 mb4 tc" style="color: #e3001a;" { "Admin-Anmeldung" }
p class="f6 black-60 tc mb3" {
"Gib deine Admin-Zugangsdaten ein, um auf den Admin-Bereich zuzugreifen."
}
form method="POST" action="/admin" class="ba b--black-20 br2 pa4 bg-white" {
div class="mb3" {
label class="db f6 black-70 mb2" for="admin-user" { "Benutzername" }
input type="text" name="username" id="admin-user" required autocomplete="username" class="db w-100 pa2 ba b--black-30 br1";
}
div class="mb4" {
label class="db f6 black-70 mb2" for="admin-pass" { "Passwort" }
input type="password" name="password" id="admin-pass" required autocomplete="current-password" class="db w-100 pa2 ba b--black-30 br1";
}
button type="submit" class="w-100 pa2 btn-brand br1 f5 fw6" {
"Als Admin anmelden"
}
}
div class="mt3 tc" {
a href="/" class="brand no-underline f6 underline" { "Zurück zur Startseite" }
}
}
}
};
if req.headers().get("HX-Request").is_some() {
Ok(content)
} else {
Ok(render_layout(&content, None, false))
}
}
#[derive(Debug, serde::Deserialize)]
pub struct AdminLoginForm {
pub username: String,
pub password: String,
}
#[post("/admin")]
pub async fn admin_login_handler(
req: HttpRequest,
form: web::Form<AdminLoginForm>,
config: web::Data<Server>,
) -> Result<HttpResponse, actix_web::Error> {
if form.username == config.admin_username() && form.password == config.admin_password() {
use base64::Engine;
let credentials = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", form.username, form.password));
let cookie = actix_web::cookie::Cookie::build("admin_auth", credentials)
.path("/")
.http_only(true)
.same_site(actix_web::cookie::SameSite::Strict)
.finish();
if req.headers().get("HX-Request").is_some() {
Ok(HttpResponse::Ok()
.insert_header(("HX-Redirect", "/admin"))
.cookie(cookie)
.finish())
} else {
Ok(HttpResponse::Found()
.insert_header(("Location", "/admin"))
.cookie(cookie)
.finish())
}
} else {
Ok(HttpResponse::Found()
.insert_header(("Location", "/admin-login"))
.finish())
}
}