add function to be able to delete names

This commit is contained in:
2025-08-21 12:31:31 +02:00
parent c74500adfd
commit a0eddece86
8 changed files with 305 additions and 40 deletions

1
Cargo.lock generated
View File

@@ -2671,6 +2671,7 @@ dependencies = [
"axum-extra",
"chrono",
"maud",
"rand",
"rust-i18n",
"serde",
"sqlx",

View File

@@ -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"] }

View File

@@ -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
);

112
src/admin.rs Normal file
View File

@@ -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<AppState>,
private_cookies: PrivateCookieJar,
Form(form): Form<LoginForm>,
) -> 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<AppState> {
Router::new()
.route("/admin/login", get(login_page))
.route("/admin/login", post(login))
.route("/admin/logout", post(logout))
.route("/protected", get(protected_page))
}

View File

@@ -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<Arc<Backend>>,
State(state): State<AppState>,
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<Backend>,
state: AppState,
cookies: PrivateCookieJar,
lang_cookies: CookieJar,
headers: HeaderMap,
message: Option<MyMessage>,
) -> 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("<mark id='ranking'>")) }
(rank.client.get_display_name())
@if rank.client == client { (PreEscaped("</mark>")) }
@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<Arc<Backend>>,
State(state): State<AppState>,
cookies: PrivateCookieJar,
lang_cookies: CookieJar,
headers: HeaderMap,
Path(uuid): Path<String>,
) -> 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<Arc<Backend>>,
State(state): State<AppState>,
cookies: PrivateCookieJar,
lang_cookies: CookieJar,
headers: HeaderMap,
Form(form): Form<NameForm>,
) -> 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<AppState>,
private_cookies: PrivateCookieJar,
_lang_cookies: CookieJar,
_headers: HeaderMap,
Form(form): Form<BanNameForm>,
) -> 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<AppState> {
Router::new()
.route("/game", get(index))
.route("/game", post(set_name))
.route("/game/ban-name", post(ban_name))
.route("/{*uuid}", get(game))
}

View File

@@ -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<String> {
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<Backend>,
pub key: Key,
pub admin_password: String,
}
impl axum::extract::FromRef<AppState> for Key {
@@ -232,32 +298,73 @@ impl axum::extract::FromRef<AppState> for Arc<Backend> {
#[derive(Serialize, Deserialize)]
struct Config {
key: Vec<u8>,
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<Key, Box<dyn std::error::Error>> {
fn load_or_create_config() -> Result<(Key, Config), Box<dyn std::error::Error>> {
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::<Config>(&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<u8>,
}
if let Ok(partial_config) = toml::from_str::<PartialConfig>(&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

View File

@@ -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"
)

View File

@@ -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);
}
}