From a0eddece8698341a96f6bcf8f321234d1f16e6ab Mon Sep 17 00:00:00 2001 From: Philipp Hofer Date: Thu, 21 Aug 2025 12:31:31 +0200 Subject: [PATCH] add function to be able to delete names --- Cargo.lock | 1 + Cargo.toml | 1 + migration.sql | 5 ++ src/admin.rs | 112 +++++++++++++++++++++++++++++++++ src/game.rs | 58 ++++++++++++++--- src/main.rs | 139 +++++++++++++++++++++++++++++++++++++---- src/model/highscore.rs | 8 +-- static/serve/style.css | 21 +++---- 8 files changed, 305 insertions(+), 40 deletions(-) create mode 100644 src/admin.rs diff --git a/Cargo.lock b/Cargo.lock index 2743aee..2a29ed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2671,6 +2671,7 @@ dependencies = [ "axum-extra", "chrono", "maud", + "rand", "rust-i18n", "serde", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index 92123a6..0b56113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ axum = "0.8" axum-extra = { version = "0.10", features = ["cookie-private", "cookie"] } chrono = { version = "0.4", features = ["serde"] } maud = { version = "0.27", features = ["axum"] } +rand = "0.8" rust-i18n = "3.1" serde = { version = "1", features = ["derive"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] } diff --git a/migration.sql b/migration.sql index 4d582db..d9dc5b5 100644 --- a/migration.sql +++ b/migration.sql @@ -24,3 +24,8 @@ CREATE TABLE sightings ( -- Create indexes for better performance on foreign key lookups CREATE INDEX idx_sightings_client_uuid ON sightings(client_uuid); CREATE INDEX idx_sightings_camera_id ON sightings(camera_id); + +CREATE TABLE banned_names ( + name TEXT PRIMARY KEY NOT NULL, + banned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..11da113 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,112 @@ +use crate::{language::language, page::Page, AppState}; +use axum::{ + extract::State, + http::HeaderMap, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, + Form, Router, +}; +use axum_extra::extract::{ + cookie::{Cookie, Expiration}, + CookieJar, PrivateCookieJar, +}; +use maud::{html, Markup}; +use serde::Deserialize; +use time::OffsetDateTime; + +#[derive(Deserialize)] +struct LoginForm { + password: String, +} + +async fn login_page(cookies: CookieJar, headers: HeaderMap) -> Markup { + let lang = language(&cookies, &headers); + rust_i18n::set_locale(lang.to_locale()); + + Page::new(lang).content(html! { + h1 { "Admin Login" } + form method="POST" action="/admin/login" { + fieldset { + label for="password" { "Password:" } + input + type="password" + name="password" + id="password" + required; + input type="submit" value="Login"; + } + } + }) +} + +async fn login( + State(state): State, + private_cookies: PrivateCookieJar, + Form(form): Form, +) -> Response { + if form.password == state.admin_password { + // Set secure admin session cookie + let expiration_date = OffsetDateTime::now_utc() + time::Duration::days(30); + let mut cookie = Cookie::new("admin_session", "authenticated"); + cookie.set_expires(Expiration::DateTime(expiration_date)); + cookie.set_http_only(true); + cookie.set_secure(true); + cookie.set_path("/"); + + let updated_cookies = private_cookies.add(cookie); + (updated_cookies, Redirect::to("/protected")).into_response() + } else { + // Invalid password, redirect back to login + Redirect::to("/admin/login").into_response() + } +} + +async fn logout(private_cookies: PrivateCookieJar) -> Response { + // Remove admin session cookie + let expired_cookie = Cookie::build(("admin_session", "")) + .expires(Expiration::DateTime( + OffsetDateTime::now_utc() - time::Duration::days(1), + )) + .http_only(true) + .secure(true) + .path("/") + .build(); + + let updated_cookies = private_cookies.add(expired_cookie); + (updated_cookies, Redirect::to("/")).into_response() +} + +async fn protected_page( + private_cookies: PrivateCookieJar, + cookies: CookieJar, + headers: HeaderMap, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + let lang = language(&cookies, &headers); + rust_i18n::set_locale(lang.to_locale()); + + let markup = Page::new(lang).content(html! { + h1 { "Protected Admin Area" } + p { "Welcome to the admin area! This is a protected route." } + p { "Only authenticated administrators can access this page." } + + + form method="POST" action="/admin/logout" { + input type="submit" value="Logout" class="secondary"; + } + }); + + markup.into_response() +} + +pub fn routes() -> Router { + Router::new() + .route("/admin/login", get(login_page)) + .route("/admin/login", post(login)) + .route("/admin/logout", post(logout)) + .route("/protected", get(protected_page)) +} diff --git a/src/game.rs b/src/game.rs index 375a979..c64a1ae 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,7 +1,7 @@ use crate::{ language::language, page::{MyMessage, Page}, - AppState, Backend, NameUpdateError, + AppState, NameUpdateError, }; use axum::{ extract::{Path, State}, @@ -13,26 +13,29 @@ use axum::{ use axum_extra::extract::{CookieJar, PrivateCookieJar}; use maud::{html, Markup, PreEscaped}; use serde::Deserialize; -use std::sync::Arc; use uuid::Uuid; async fn index( - State(backend): State>, + State(state): State, cookies: PrivateCookieJar, lang_cookies: CookieJar, headers: HeaderMap, ) -> Response { - retu(backend, cookies, lang_cookies, headers, None).await + retu(state, cookies, lang_cookies, headers, None).await } async fn retu( - backend: Arc, + state: AppState, cookies: PrivateCookieJar, lang_cookies: CookieJar, headers: HeaderMap, message: Option, ) -> Response { + let backend = &state.backend; let (cookies, req) = backend.client_full(cookies, &lang_cookies, &headers).await; + + // Check if user is admin + let is_admin = cookies.get("admin_session").is_some(); let client = req.client; rust_i18n::set_locale(&req.lang.to_string()); @@ -99,6 +102,12 @@ async fn retu( @if rank.client == client { (PreEscaped("")) } (rank.client.get_display_name()) @if rank.client == client { (PreEscaped("")) } + @if is_admin && rank.client.name.is_some() && rank.client.name.as_ref().unwrap() != "***" { + form method="POST" action="/game/ban-name" style="display: inline; margin-left: 0.5rem;" { + input type="hidden" name="name" value=(rank.client.name.as_ref().unwrap()); + input type="submit" value="Block" class="secondary" style="font-size: 0.8rem; padding: 0.25rem 0.5rem;"; + } + } } span.font-headline.font-lg { (rank.amount) @@ -118,12 +127,13 @@ async fn retu( } async fn game( - State(backend): State>, + State(state): State, cookies: PrivateCookieJar, lang_cookies: CookieJar, headers: HeaderMap, Path(uuid): Path, ) -> Response { + let backend = &state.backend; let (cookies, req) = backend.client_full(cookies, &lang_cookies, &headers).await; let client = req.client; rust_i18n::set_locale(req.lang.to_locale()); @@ -146,7 +156,7 @@ async fn game( ) }; - retu(backend, cookies, lang_cookies, headers, Some(message)).await + retu(state, cookies, lang_cookies, headers, Some(message)).await } async fn not_found(cookies: CookieJar, headers: HeaderMap) -> Markup { @@ -161,13 +171,19 @@ struct NameForm { name: String, } +#[derive(Deserialize)] +struct BanNameForm { + name: String, +} + async fn set_name( - State(backend): State>, + State(state): State, cookies: PrivateCookieJar, lang_cookies: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { + let backend = &state.backend; let (cookies, req) = backend.client_full(cookies, &lang_cookies, &headers).await; let client = req.client; rust_i18n::set_locale(req.lang.to_locale()); @@ -191,12 +207,36 @@ async fn set_name( ), }; - retu(backend, cookies, lang_cookies, headers, Some(message)).await + retu(state, cookies, lang_cookies, headers, Some(message)).await +} + +async fn ban_name( + State(state): State, + private_cookies: PrivateCookieJar, + _lang_cookies: CookieJar, + _headers: HeaderMap, + Form(form): Form, +) -> Response { + // Check if user is admin + if private_cookies.get("admin_session").is_none() { + return axum::response::Redirect::to("/game").into_response(); + } + + let backend = &state.backend; + + // Ban the name + let _ = backend.ban_name(&form.name).await; + + // Replace existing instances with asterisks + let _ = backend.replace_banned_names_with_asterisks().await; + + axum::response::Redirect::to("/game").into_response() } pub(super) fn routes() -> Router { Router::new() .route("/game", get(index)) .route("/game", post(set_name)) + .route("/game/ban-name", post(ban_name)) .route("/{*uuid}", get(game)) } diff --git a/src/main.rs b/src/main.rs index 6f01cb7..24031f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,10 @@ use crate::model::client::Client; -use axum::{http::HeaderMap, response::Redirect, routing::{get, post}, Router}; +use axum::{ + http::HeaderMap, + response::Redirect, + routing::{get, post}, + Router, +}; use axum_extra::extract::{ cookie::{Cookie, Expiration, Key}, CookieJar, PrivateCookieJar, @@ -24,6 +29,7 @@ extern crate rust_i18n; i18n!("locales", fallback = "en"); +mod admin; mod game; mod index; pub(crate) mod language; @@ -190,7 +196,7 @@ impl Backend { if name.len() < 3 { return Err(NameUpdateError::TooShort(3, name.len())); } - if contains_bad_word(name) { + if contains_bad_word(name) || self.is_name_banned(name).await { return Err(NameUpdateError::ContainsBadWord); } @@ -209,12 +215,72 @@ impl Backend { Ok(()) } + + async fn is_name_banned(&self, name: &str) -> bool { + match self { + Backend::Sqlite(db) => { + let result = sqlx::query!("SELECT name FROM banned_names WHERE name = ?", name) + .fetch_optional(db) + .await + .unwrap(); + result.is_some() + } + } + } + + async fn ban_name(&self, name: &str) -> Result<(), sqlx::Error> { + match self { + Backend::Sqlite(db) => { + sqlx::query!("INSERT OR IGNORE INTO banned_names (name) VALUES (?)", name) + .execute(db) + .await?; + Ok(()) + } + } + } + + async fn unban_name(&self, name: &str) -> Result<(), sqlx::Error> { + match self { + Backend::Sqlite(db) => { + sqlx::query!("DELETE FROM banned_names WHERE name = ?", name) + .execute(db) + .await?; + Ok(()) + } + } + } + + async fn get_banned_names(&self) -> Vec { + match self { + Backend::Sqlite(db) => { + let rows = sqlx::query!("SELECT name FROM banned_names ORDER BY banned_at DESC") + .fetch_all(db) + .await + .unwrap(); + rows.into_iter().map(|row| row.name).collect() + } + } + } + + async fn replace_banned_names_with_asterisks(&self) -> Result<(), sqlx::Error> { + match self { + Backend::Sqlite(db) => { + sqlx::query!( + "UPDATE client SET name = '***' WHERE name IN (SELECT name FROM banned_names)" + ) + .execute(db) + .await?; + Ok(()) + } + } + } } #[derive(Clone)] pub struct AppState { pub(crate) backend: Arc, pub key: Key, + pub admin_password: String, } impl axum::extract::FromRef for Key { @@ -232,32 +298,73 @@ impl axum::extract::FromRef for Arc { #[derive(Serialize, Deserialize)] struct Config { key: Vec, + admin_password: String, } impl Config { fn generate() -> Self { + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + let admin_password: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(15) + .map(char::from) + .collect(); + Self { key: Key::generate().master().to_vec(), + admin_password, } } } -fn load_or_create_key() -> Result> { +fn load_or_create_config() -> Result<(Key, Config), Box> { let config_path = "config.toml"; // Try to read existing config if Path::new(config_path).exists() { let content = fs::read_to_string(config_path)?; - let config: Config = toml::from_str(&content)?; - return Ok(Key::from(&config.key)); + + // Try to parse as complete config first + if let Ok(config) = toml::from_str::(&content) { + let key = Key::from(&config.key); + return Ok((key, config)); + } + + // If that fails, try to parse just the key and generate new admin password + #[derive(Deserialize)] + struct PartialConfig { + key: Vec, + } + + if let Ok(partial_config) = toml::from_str::(&content) { + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + let admin_password: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(15) + .map(char::from) + .collect(); + + let config = Config { + key: partial_config.key, + admin_password, + }; + + // Write the updated config back + let toml_string = toml::to_string(&config)?; + fs::write(config_path, toml_string)?; + + let key = Key::from(&config.key); + return Ok((key, config)); + } } - // Create new config if file doesn't exist + // Create new config if file doesn't exist or parsing failed let config = Config::generate(); let toml_string = toml::to_string(&config)?; fs::write(config_path, toml_string)?; + let key = Key::from(&config.key); - Ok(Key::from(&config.key)) + Ok((key, config)) } async fn delete_personal_data( @@ -272,16 +379,18 @@ async fn delete_personal_data( let _ = backend.delete_client_data(&uuid).await; } } - + // Remove the client_id cookie by setting an expired cookie let expired_cookie = Cookie::build(("client_id", "")) - .expires(Expiration::DateTime(OffsetDateTime::now_utc() - time::Duration::days(1))) + .expires(Expiration::DateTime( + OffsetDateTime::now_utc() - time::Duration::days(1), + )) .http_only(true) .secure(true) .build(); - + let updated_cookies = cookies.add(expired_cookie); - + // Redirect back to privacy page with success message (updated_cookies, Redirect::to("/privacy?deleted=1")) } @@ -299,10 +408,15 @@ async fn main() { .await .unwrap(); - let key = load_or_create_key().unwrap(); + let (key, config) = load_or_create_config().unwrap(); + + // Print admin password for convenience + tracing::info!("Admin password: {}", config.admin_password); + let state = AppState { backend: Arc::new(Backend::Sqlite(db)), key, + admin_password: config.admin_password, }; let app = Router::new() @@ -311,6 +425,7 @@ async fn main() { .route("/delete-data", post(delete_personal_data)) .nest_service("/static", ServeDir::new("./static/serve")) .merge(game::routes()) + .merge(admin::routes()) .with_state(state); // run our app with hyper, listening globally on port 3000 diff --git a/src/model/highscore.rs b/src/model/highscore.rs index 3dace6f..031635c 100644 --- a/src/model/highscore.rs +++ b/src/model/highscore.rs @@ -37,11 +37,9 @@ impl Backend { ) SELECT rank, name, uuid, amount FROM ranked_clients - WHERE rank <= ( - SELECT rank - FROM ranked_clients - ORDER BY rank - LIMIT 1 OFFSET 9 + WHERE rank <= COALESCE( + (SELECT rank FROM ranked_clients ORDER BY rank LIMIT 1 OFFSET 9), + (SELECT MAX(rank) FROM ranked_clients) ) ORDER BY rank, name" ) diff --git a/static/serve/style.css b/static/serve/style.css index ab65b5e..6651063 100644 --- a/static/serve/style.css +++ b/static/serve/style.css @@ -139,21 +139,14 @@ ul.iterated > li { border-bottom: 2px solid var(--pico-color); border-radius: 2% 6% 5% 4% / 1% 1% 2% 4%; position: relative; - - &::before { - content: ''; - border-bottom: 1px solid var(--pico-color); - display: block; - width: 100%; - height: 100%; - position: absolute; - top: 50%; - left: 50%; - transform: translate3d(-50%, -50%, 0) scale(1.015) rotate(0.5deg); - border-radius: 1% 1% 2% 4% / 2% 6% 5% 4%; - } } +ul.iterated > li > * { + position: relative; + z-index: 1; /* Bring content forward */ +} + + ul.iterated > li.no-border { border-bottom: 0; } @@ -230,4 +223,4 @@ article>footer { /** Funny stuff*/ .easteregg { color: var(--pico-background-color); -} \ No newline at end of file +}