add function to be able to delete names
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2671,6 +2671,7 @@ dependencies = [
|
||||
"axum-extra",
|
||||
"chrono",
|
||||
"maud",
|
||||
"rand",
|
||||
"rust-i18n",
|
||||
"serde",
|
||||
"sqlx",
|
||||
|
@@ -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"] }
|
||||
|
@@ -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
112
src/admin.rs
Normal 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))
|
||||
}
|
58
src/game.rs
58
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<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))
|
||||
}
|
||||
|
139
src/main.rs
139
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<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
|
||||
|
@@ -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"
|
||||
)
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user