From 4229444f96dacf164cfff406a4399c4517fe41d7 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Thu, 30 Apr 2026 17:45:05 +0200 Subject: [PATCH] Add instance configuration UI and backend --- Cargo.lock | 3 + Cargo.toml | 2 + src/config.rs | 21 +++ src/database/migrations/0002_conf.sql | 3 + src/logs.rs | 21 +++ src/main.rs | 10 +- src/users/permissions.rs | 1 + src/web/components/nav.rs | 9 +- src/web/icons/code.svg | 1 + src/web/icons/message-square-code.svg | 1 + src/web/icons/mod.rs | 4 + src/web/icons/settings.svg | 1 + src/web/icons/type.svg | 1 + src/web/pages/conf.rs | 209 ++++++++++++++++++++++++++ src/web/pages/mod.rs | 7 + src/web/pages/quotes/add.rs | 8 +- src/web/pages/usersettings.rs | 6 +- 17 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 src/database/migrations/0002_conf.sql create mode 100644 src/web/icons/code.svg create mode 100644 src/web/icons/message-square-code.svg create mode 100644 src/web/icons/settings.svg create mode 100644 src/web/icons/type.svg create mode 100644 src/web/pages/conf.rs diff --git a/Cargo.lock b/Cargo.lock index d01c35f..356e431 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1490,6 +1490,7 @@ dependencies = [ "chrono-tz", "dotenvy", "env_logger", + "http", "log", "maud", "rand 0.10.0", @@ -1504,6 +1505,7 @@ dependencies = [ "tokio", "tower", "tower-http", + "url", "uuid", ] @@ -2939,6 +2941,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6bd3f7f..86337be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ chrono = { version = "0.4.43", features = ["serde"] } chrono-tz = "0.10.4" dotenvy = "0.15.7" env_logger = "0.11.9" +http = "1.4.0" log = "0.4.29" maud = { version = "0.27.0", features = ["axum"] } rand = "0.10.0" @@ -27,4 +28,5 @@ thiserror = "2.0.18" tokio = { version = "1.49.0", features = ["full"] } tower = { version = "0.5.3", features = ["full"] } tower-http = { version = "0.6.8", features = ["full"] } +url = { version = "2.5.8", features = ["serde"] } uuid = { version = "1.21.0", features = ["serde", "v7"] } diff --git a/src/config.rs b/src/config.rs index debec4b..103c7a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,10 +8,31 @@ use std::{ use env_logger::fmt::Formatter; use log::{LevelFilter, Record}; use sqlx::PgPool; +use url::Url; /// Mnemosyne, the mother of the nine muses pub const DEFAULT_PORT: u16 = 0x9999; // 39321 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MnemoConf { + pub instance_name: String, + pub discord_webhook: Option, +} + +impl MnemoConf { + pub fn new() -> Self { + MnemoConf::default() + } +} +impl Default for MnemoConf { + fn default() -> Self { + Self { + instance_name: String::from("Mnemosyne"), + discord_webhook: None, + } + } +} + pub const REFERENCE_SPLASHES: &[&str] = &[ "quote engine", "powered by rust", diff --git a/src/database/migrations/0002_conf.sql b/src/database/migrations/0002_conf.sql new file mode 100644 index 0000000..f3a00ba --- /dev/null +++ b/src/database/migrations/0002_conf.sql @@ -0,0 +1,3 @@ +CREATE TABLE mnemoconf ( + config JSONB NOT NULL +); diff --git a/src/logs.rs b/src/logs.rs index 940bf69..5d0512f 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, Row}; use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames}; +use url::Url; use uuid::Uuid; use crate::{database::DatabaseError, users::User, web::icons}; @@ -154,11 +155,20 @@ pub enum LogAction { ManuallyRevokeSession { id: Uuid, }, + ChangeInstanceName { + old: String, + new: String, + }, + ChangeDiscordWebhookUrl { + old: Option, + new: Option, + }, } impl LogAction { pub fn get_target_id(&self) -> Option { match self { Self::Initialize | Self::RegenInfradmin => None, + Self::CreateUser { id, .. } | Self::CreateTag { id, .. } | Self::CreatePerson { id, .. } @@ -168,9 +178,14 @@ impl LogAction { | Self::RenameTag { id, .. } | Self::DeleteTag { id, .. } | Self::ManuallyChangeUsersPassword { id } => Some(*id), + Self::AddPersonName { pid, .. } | Self::DeletePersonName { pid, .. } | Self::SetPersonPrimaryName { pid, .. } => Some(*pid), + + Self::ChangeInstanceName { .. } | Self::ChangeDiscordWebhookUrl { .. } => { + Some(Uuid::nil()) + } } } pub fn get_humanreadable_payload(&self) -> String { @@ -213,6 +228,10 @@ impl LogAction { LogAction::ManuallyRevokeSession { id } => { format!("Revoked session of ID {id}") } + LogAction::ChangeInstanceName { old, new } => { + format!("Changed instance name from \"{old}\" to \"{new}\"") + } + LogAction::ChangeDiscordWebhookUrl { .. } => "Changed Discord webhook URL".into(), } } } @@ -235,6 +254,8 @@ impl LogActionDiscriminant { LAD::SetPersonPrimaryName => "Person Primary Name Set", LAD::CreateQuote => "Quote Creation", LAD::ManuallyRevokeSession => "Manual Session Revocation", + LAD::ChangeInstanceName => "Instance Name Change", + LAD::ChangeDiscordWebhookUrl => "Discord Webhook URL Change", } } pub fn icon(&self) -> &'static str { diff --git a/src/main.rs b/src/main.rs index ce25cc7..6ac2507 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ -use std::error::Error; +use std::{error::Error, sync::Arc}; use axum::Router; use sqlx::PgPool; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, sync::RwLock}; + +use crate::config::MnemoConf; mod api; mod config; @@ -21,6 +23,7 @@ const ISE_MSG: &str = "Internal server error"; #[derive(Debug, Clone)] pub struct MnemoState { pool: PgPool, + conf: Arc>, } #[tokio::main] @@ -31,6 +34,7 @@ async fn main() -> Result<(), Box> { let pool = config::init_pool().await?; sqlx::migrate!("src/database/migrations").run(&pool).await?; log::info!("Migrations applied successfully."); + let conf = Arc::new(RwLock::new(MnemoConf::new())); users::auth::init_password_dummies(); users::setup::initialise_reserved_users_if_needed(&pool).await?; @@ -38,7 +42,7 @@ async fn main() -> Result<(), Box> { let r = Router::new() .merge(api::api_router()) .merge(web::web_router()) - .with_state(MnemoState { pool }); + .with_state(MnemoState { pool, conf }); let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?; log::info!("Listener bound to {}", l.local_addr()?); diff --git a/src/users/permissions.rs b/src/users/permissions.rs index f24db99..0b8b5ea 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -19,6 +19,7 @@ pub enum Permission { ChangePersonPrimaryName, #[allow(unused)] BrowseServerLogs, + ConfigureInstance, } impl User { diff --git a/src/web/components/nav.rs b/src/web/components/nav.rs index 5d4f281..d799408 100644 --- a/src/web/components/nav.rs +++ b/src/web/components/nav.rs @@ -46,15 +46,20 @@ pub fn nav(user: Option<&User>, uri: &str) -> Markup { span class="hidden sm:block"{(u.handle)} div class="scale-[.75]" {(PreEscaped(icons::USER))} } - div class="absolute right-0 top-full pt-1 w-40 opacity-0 invisible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-100 z-50" { + div class="absolute right-0 top-full pt-1 w-44 opacity-0 invisible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-100 z-50" { div class="rounded bg-neutral-900 border border-neutral-200/25 shadow-lg flex flex-col overflow-hidden" { a href=(format!("/users/{}", u.id)) class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" { div class="scale-[.7]" {(PreEscaped(icons::USER))} p {"Profile"} } a href="/user-settings" class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" { + div class="scale-[.7]" {(PreEscaped(icons::SETTINGS))} + p {"User Settings"} + } + div class="h-px w-full bg-neutral-200/15" {} + a href="/instance-config" class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" { div class="scale-[.7]" {(PreEscaped(icons::SERVER))} - p {"Settings"} + p {"Instance Config"} } div class="h-px w-full bg-neutral-200/15" {} form action="/api/auth/logout-form" method="post" { diff --git a/src/web/icons/code.svg b/src/web/icons/code.svg new file mode 100644 index 0000000..5c81c6f --- /dev/null +++ b/src/web/icons/code.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/message-square-code.svg b/src/web/icons/message-square-code.svg new file mode 100644 index 0000000..599b383 --- /dev/null +++ b/src/web/icons/message-square-code.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/mod.rs b/src/web/icons/mod.rs index e84a59e..9c9b71c 100644 --- a/src/web/icons/mod.rs +++ b/src/web/icons/mod.rs @@ -5,6 +5,7 @@ pub const CALENDAR_ARROW_DOWN: &str = include_str!("calendar-arrow-down.svg"); pub const CIRCLE_MINUS: &str = include_str!("circle-minus.svg"); pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg"); pub const CLOCK: &str = include_str!("clock.svg"); +pub const CODE: &str = include_str!("code.svg"); pub const CONTACT: &str = include_str!("contact.svg"); pub const EYE: &str = include_str!("eye.svg"); pub const FILE_IMAGE: &str = include_str!("file-image.svg"); @@ -14,14 +15,17 @@ pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg"); pub const LINE_DOT_RIGHT_HORIZONTAL: &str = include_str!("line-dot-right-horizontal.svg"); pub const LOG_OUT: &str = include_str!("log-out.svg"); pub const MAP_PIN: &str = include_str!("map-pin.svg"); +pub const MESSAGE_SQUARE_CODE: &str = include_str!("message-square-code.svg"); pub const PEN: &str = include_str!("pen.svg"); pub const PLUS: &str = include_str!("plus.svg"); pub const QUOTE: &str = include_str!("quote.svg"); pub const REFRESH_CW: &str = include_str!("refresh-cw.svg"); pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg"); pub const SERVER: &str = include_str!("server.svg"); +pub const SETTINGS: &str = include_str!("settings.svg"); pub const SHIELD_USER: &str = include_str!("shield-user.svg"); pub const TAG: &str = include_str!("tag.svg"); +pub const TYPE: &str = include_str!("type.svg"); pub const USER: &str = include_str!("user.svg"); pub const USER_KEY: &str = include_str!("user-key.svg"); pub const USER_PLUS: &str = include_str!("user-plus.svg"); diff --git a/src/web/icons/settings.svg b/src/web/icons/settings.svg new file mode 100644 index 0000000..839ebd9 --- /dev/null +++ b/src/web/icons/settings.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/type.svg b/src/web/icons/type.svg new file mode 100644 index 0000000..d12597c --- /dev/null +++ b/src/web/icons/type.svg @@ -0,0 +1 @@ + diff --git a/src/web/pages/conf.rs b/src/web/pages/conf.rs new file mode 100644 index 0000000..edd49ff --- /dev/null +++ b/src/web/pages/conf.rs @@ -0,0 +1,209 @@ +use axum::{ + Form, + extract::{Request, State}, + http::HeaderMap, + response::{IntoResponse, Redirect, Response}, +}; +use http::StatusCode; +use maud::{Markup, PreEscaped, html}; +use serde::Deserialize; +use url::Url; + +use crate::{ + MnemoState, + error::CompositeError, + logs::{LogAction, LogEntry}, + users::{ + User, + auth::{UserAuthRequired, UserAuthenticate}, + permissions::Permission, + }, + web::{components::nav::nav, icons, pages::base}, +}; + +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { + Some(u) => Some(u), + None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), + }; + + let (current_name, current_webhook) = { + let conf = state.conf.read().await; + let current_name = conf.instance_name.clone(); + let current_webhook = conf + .discord_webhook + .as_ref() + .map(|u| u.to_string()) + .unwrap_or_default(); + (current_name, current_webhook) + }; + + Ok(base( + "Instance Config | Mnemosyne", + html!( + (nav(u.as_ref(), req.uri().path())) + + div class="max-w-4xl mx-auto px-2" { + div class="mx-auto max-w-4xl my-4 mb-8" { + p class="flex items-center gap-2" { + span class="text-neutral-500" {(PreEscaped(icons::SERVER))} + span class="text-2xl font-semibold font-lora" {"Mnemosyne Instance Settings"} + } + p class="text-neutral-500 text-sm font-light mt-1" { + "Manage global configuration for your Mnemosyne instance." + } + } + + (setting_block( + "Instance Name", + "The name of this instance. This is displayed on the dashboard, in page titles, and used in webhook payloads to identify this server.", + icons::PEN, + "/instance-config/name", + "instance_name", + "text", + "e.g. Mnemosyne", + ¤t_name, + )) + + hr class="mt-6 mb-4 border-neutral-600"; + + (setting_block( + "Discord Webhook URL", + "Mnemosyne will attempt to send a message to this webhook for all newly created quotes, regardless of whether they are public or not. Leave empty to disable.", + icons::MESSAGE_SQUARE_CODE, + "/instance-config/dsc-webhook", + "webhook_url", + "url", + "https://discord.com/api/webhooks/...", + ¤t_webhook, + )) + } + ), + ) + .into_response()) +} + +fn setting_block( + title: &str, + description: &str, + icon: &str, + form_action: &str, + input_name: &str, + input_type: &str, + input_placeholder: &str, + current_value: &str, +) -> Markup { + html! { + div class="mb-6" { + p class="flex items-center gap-1" { + span class="text-neutral-500 scale-[.8]" {(PreEscaped(icon))} + span class="text-lg font-semibold font-lora" {(title)} + } + p class="text-neutral-500 text-sm font-light mb-3" { + (description) + } + form action=(form_action) method="post" class="flex gap-2" { + input id=(input_name) name=(input_name) type=(input_type) placeholder=(input_placeholder) autocomplete="off" value=(current_value) + class="w-full max-w-md px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded outline-none focus:border-neutral-200/50"; + button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" { + "Save" + } + } + } + } +} + +#[derive(Deserialize)] +pub struct InstanceNameForm { + instance_name: String, +} +pub async fn change_name( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Result { + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; + if !u + .has_permission(&mut tx, Permission::ConfigureInstance) + .await? + { + return Ok(( + StatusCode::FORBIDDEN, + "You do not have permission to change this.", + ) + .into_response()); + } + + if form.instance_name.trim().is_empty() { + return Ok((StatusCode::BAD_REQUEST, "Instance name cannot be empty.").into_response()); + } + + LogEntry::new( + &mut tx, + u, + LogAction::ChangeInstanceName { + old: state.conf.read().await.instance_name.clone(), + new: form.instance_name.trim().to_string(), + }, + ) + .await?; + + tx.commit().await?; + state.conf.write().await.instance_name = form.instance_name.trim().to_string(); + Ok(Redirect::to("/instance-config").into_response()) +} + +#[derive(Deserialize)] +pub struct WebhookForm { + webhook_url: String, +} +pub async fn change_webhook( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Result { + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; + if !u + .has_permission(&mut tx, Permission::ConfigureInstance) + .await? + { + return Ok(( + StatusCode::FORBIDDEN, + "You do not have permission to change this.", + ) + .into_response()); + } + + let new_webhook = if form.webhook_url.trim().is_empty() { + None + } else { + match Url::parse(form.webhook_url.trim()) { + Ok(url) => Some(url), + Err(_) => { + return Ok( + (axum::http::StatusCode::BAD_REQUEST, "Invalid URL format.").into_response() + ); + } + } + }; + + LogEntry::new( + &mut tx, + u, + LogAction::ChangeDiscordWebhookUrl { + old: state.conf.read().await.discord_webhook.clone(), + new: new_webhook.clone(), + }, + ) + .await?; + + tx.commit().await?; + state.conf.write().await.discord_webhook = new_webhook; + Ok(Redirect::to("/instance-config").into_response()) +} diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index b7d352e..6cd271a 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -6,6 +6,7 @@ use maud::{DOCTYPE, Markup, html}; use crate::MnemoState; +pub mod conf; pub mod dashboard; pub mod index; pub mod login; @@ -22,9 +23,15 @@ pub fn pages() -> Router { .route("/", get(index::page)) .route("/login", get(login::page)) .route("/dashboard", get(dashboard::page)) + // + .route("/instance-config", get(conf::page)) + .route("/instance-config/name", post(conf::change_name)) + .route("/instance-config/dsc-webhook", post(conf::change_webhook)) + // .route("/user-settings", get(usersettings::page)) .route("/user-settings/handle", post(usersettings::change_handle)) .route("/user-settings/passwd", post(usersettings::change_password)) + // .route("/users", get(users::page)) .route("/users/{id}", get(users::profile::page)) .route("/users/create", get(users::create::page)) diff --git a/src/web/pages/quotes/add.rs b/src/web/pages/quotes/add.rs index 318e067..d986bed 100644 --- a/src/web/pages/quotes/add.rs +++ b/src/web/pages/quotes/add.rs @@ -6,7 +6,6 @@ use axum::{ use axum_extra::extract::Form; use chrono::NaiveDateTime; use maud::{Markup, PreEscaped, html}; -use reqwest::Url; use serde::Deserialize; use uuid::Uuid; @@ -179,11 +178,8 @@ pub async fn form( LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?; tx.commit().await?; - if let Ok(webhook_url) = std::env::var("DISCORD_WEBHOOK_URL") { - match Url::parse(&webhook_url) { - Ok(u) => q.post_msg_webhook(u), - Err(e) => log::error!("Tried to post webhook, failed to parse url: {e}"), - } + if let Some(ref url) = state.conf.read().await.discord_webhook { + q.post_msg_webhook(url.clone()); } Ok(Redirect::to("/dashboard").into_response()) diff --git a/src/web/pages/usersettings.rs b/src/web/pages/usersettings.rs index a7420a5..ac0309c 100644 --- a/src/web/pages/usersettings.rs +++ b/src/web/pages/usersettings.rs @@ -37,13 +37,11 @@ pub async fn page( div class="max-w-4xl mx-auto px-2" { div class="mx-auto max-w-4xl my-4" { p class="flex items-center gap-2" { - span class="text-neutral-500" {(PreEscaped(icons::SERVER))} + span class="text-neutral-500" {(PreEscaped(icons::SETTINGS))} span class="text-2xl font-semibold font-lora" {"Your User Settings"} } p class="text-neutral-500 text-sm font-light" { - // "Hi, " (u.handle) "!" " " "This is your user settings page." br; - "Looking for Mnemosyne settings?" " " - a class="text-blue-500 hover:text-blue-400 hover:underline" href="/mnemosyne-settings" {"Here."} + "Hi, " (u.handle) "!" " " "This is your user settings page." } }