Compare commits

...

28 Commits

Author SHA1 Message Date
256d12c9c8 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m11s
2026-05-12 00:55:10 +02:00
65edef47b2 user permission management over api 2026-05-12 00:54:52 +02:00
9b69a0a5ee actually use default permissions, misc 2026-05-12 00:08:20 +02:00
e2e9a3efb5 Admin permission, grant/revoke/reset permission helpers 2026-05-06 18:19:09 +02:00
1f07952973 proper permission checking 2026-05-06 03:07:03 +02:00
b1ccd21068 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m14s
2026-05-06 02:50:44 +02:00
7d284f0777 prioritize special uuids in user page display 2026-05-06 02:49:42 +02:00
84dde9cc4b require permission to delete quotes 2026-05-06 02:24:05 +02:00
e7c0523841 quotelink hover, also make dashboard quotes into links 2026-05-06 02:00:23 +02:00
7fe1b6f8be Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-06 00:58:28 +02:00
ca726c8e8b quote deletion confirmation 2026-05-06 00:58:07 +02:00
a08ba568cb Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-06 00:43:48 +02:00
dd75d89472 quote deletion 🎉😮 2026-05-06 00:43:20 +02:00
9eb3332576 forgot to make the 404 page return status 404 2026-05-06 00:13:38 +02:00
032d450af2 barebones quote-specific page 2026-05-05 23:52:09 +02:00
76ac36c4fb remove MnemoConf::new 2026-05-05 15:23:49 +02:00
29804e75e5 allow unused icons 2026-05-05 14:40:17 +02:00
0be4f11f66 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-05 11:01:29 +02:00
f876ff3f00 only show instance config link for permitted users, make nav markup
component async
2026-05-04 14:11:31 +02:00
47cd13f734 gitignore scripts for local work 2026-05-04 14:07:48 +02:00
cdd296ea84 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m11s
2026-05-01 16:33:03 +02:00
4d49a5c0b3 add options to choose if quote should be sent via discord webhook 2026-05-01 16:13:51 +02:00
4aa96dca01 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m7s
2026-04-30 20:02:34 +00:00
be462dc662 actually do db readwrites this time 2026-04-30 22:01:28 +02:00
05d4aca741 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m38s
2026-04-30 17:05:43 +00:00
ffe1a4d8d2 split language server entries for easier toggling 2026-04-30 19:01:15 +02:00
24df6054ea patch postgres mount location/type
this stops the db instances from being anonymous and from not
persevering between compose up-downs (i think?)
2026-04-30 17:53:59 +02:00
4229444f96 Add instance configuration UI and backend 2026-04-30 17:45:05 +02:00
38 changed files with 862 additions and 56 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
.DS_Store .DS_Store
/database /database
/mnemodata /mnemodata
/scripts
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal

View File

@@ -1,7 +1,11 @@
{ {
"languages": { "languages": {
"Rust": { "Rust": {
"language_servers": ["rust-analyzer", "tailwindcss-language-server"], "language_servers": [
"rust-analyzer",
//
"tailwindcss-language-server",
],
}, },
}, },
"lsp": { "lsp": {

3
Cargo.lock generated
View File

@@ -1490,6 +1490,7 @@ dependencies = [
"chrono-tz", "chrono-tz",
"dotenvy", "dotenvy",
"env_logger", "env_logger",
"http",
"log", "log",
"maud", "maud",
"rand 0.10.0", "rand 0.10.0",
@@ -1504,6 +1505,7 @@ dependencies = [
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
"url",
"uuid", "uuid",
] ]
@@ -2939,6 +2941,7 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]] [[package]]

View File

@@ -13,6 +13,7 @@ chrono = { version = "0.4.43", features = ["serde"] }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
dotenvy = "0.15.7" dotenvy = "0.15.7"
env_logger = "0.11.9" env_logger = "0.11.9"
http = "1.4.0"
log = "0.4.29" log = "0.4.29"
maud = { version = "0.27.0", features = ["axum"] } maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10.0" rand = "0.10.0"
@@ -27,4 +28,5 @@ thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
tower = { version = "0.5.3", features = ["full"] } tower = { version = "0.5.3", features = ["full"] }
tower-http = { version = "0.6.8", 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"] } uuid = { version = "1.21.0", features = ["serde", "v7"] }

View File

@@ -21,7 +21,7 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
volumes: volumes:
- pg_volume:/var/lib/postgresql/data:rw - pg_volume:/var/lib/postgresql
stop_grace_period: 120s stop_grace_period: 120s
environment: environment:
POSTGRES_USER: mnemo POSTGRES_USER: mnemo

View File

@@ -28,6 +28,10 @@ pub fn api_router() -> Router<MnemoState> {
.route("/api/users/@{handle}", get(users::get_by_handle)) .route("/api/users/@{handle}", get(users::get_by_handle))
.route("/api/users/{id}/setpassw", post(users::change_password)) .route("/api/users/{id}/setpassw", post(users::change_password))
.route("/api/users/{id}/sethandle", post(users::change_handle)) .route("/api/users/{id}/sethandle", post(users::change_handle))
.route(
"/api/users/{id}/permissions/{perm}",
get(users::get_permission).put(users::put_permission),
)
// sessions // sessions
.route("/api/sessions/{id}", get(sessions::get_by_id)) .route("/api/sessions/{id}", get(sessions::get_by_id))
.route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id)) .route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id))

View File

@@ -53,6 +53,8 @@ pub struct QuoteCreateForm {
pub context: Option<String>, pub context: Option<String>,
pub location: Option<String>, pub location: Option<String>,
pub public: bool, pub public: bool,
#[serde(default)]
pub discord_webhook: bool,
} }
pub async fn create( pub async fn create(
@@ -85,5 +87,12 @@ pub async fn create(
LogEntry::new(&mut tx, u, LogAction::CreateQuote { id: q.id }).await?; LogEntry::new(&mut tx, u, LogAction::CreateQuote { id: q.id }).await?;
tx.commit().await?; tx.commit().await?;
if form.discord_webhook {
if let Some(ref url) = state.conf.read().await.discord_webhook {
q.post_msg_webhook(url.clone());
}
}
Ok((StatusCode::CREATED, Json(q)).into_response()) Ok((StatusCode::CREATED, Json(q)).into_response())
} }

View File

@@ -15,13 +15,14 @@ use crate::{
User, User,
auth::{UserAuthRequired, UserAuthenticate}, auth::{UserAuthRequired, UserAuthenticate},
handle::UserHandle, handle::UserHandle,
permissions::Permission, permissions::{Permission, PermissionState},
}, },
}; };
const CANT_CHANGE_OTHERS_HANDLE: &str = "You don't have permission to change this user's handle."; const CANT_CHANGE_OTHERS_HANDLE: &str = "You don't have permission to change this user's handle.";
const CANT_CHANGE_OTHERS_PASSW: &str = "You don't have permission to change this user's password."; const CANT_CHANGE_OTHERS_PASSW: &str = "You don't have permission to change this user's password.";
const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually create new users."; const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually create new users.";
const GO_AWAY: &str = "You don't have permission to look into permissions!";
const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully."; const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully.";
const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully."; const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully.";
@@ -170,3 +171,82 @@ pub async fn change_password(
Ok(PASSW_CHANGED_SUCCESS.into_response()) Ok(PASSW_CHANGED_SUCCESS.into_response())
} }
pub async fn get_permission(
State(state): State<MnemoState>,
Path((uid, perm)): Path<(Uuid, Permission)>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = User::authenticate(&mut conn, &headers).await?.required()?;
if !u.has_permission(&mut conn, Permission::Admin).await? {
return Ok((StatusCode::FORBIDDEN, GO_AWAY).into_response());
}
let target = User::get_by_id(&mut conn, uid).await?;
let has: PermissionState = target.permission_dbstate(&mut conn, perm).await?.into();
Ok((StatusCode::OK, Json(has)).into_response())
}
pub async fn put_permission(
State(state): State<MnemoState>,
Path((uid, perm)): Path<(Uuid, Permission)>,
headers: HeaderMap,
Json(newstate): Json<PermissionState>,
) -> Result<Response, CompositeError> {
let mut tx = state.pool.begin().await?;
let u = User::authenticate(&mut tx, &headers).await?.required()?;
if !u.has_permission(&mut tx, Permission::Admin).await? {
return Ok((StatusCode::FORBIDDEN, GO_AWAY).into_response());
}
let target = User::get_by_id(&mut tx, uid).await?;
let os: PermissionState = target.permission_dbstate(&mut tx, perm).await?.into();
match newstate {
PermissionState::ExplicitlyGranted => {
target.grant_permission(&mut tx, perm).await?;
LogEntry::new(
&mut tx,
u,
LogAction::UpdatePermission {
id: target.id,
os,
ns: newstate,
p: perm,
},
)
.await?;
}
PermissionState::ExplicitlyRevoked => {
target.revoke_permission(&mut tx, perm).await?;
LogEntry::new(
&mut tx,
u,
LogAction::UpdatePermission {
id: target.id,
os,
ns: newstate,
p: perm,
},
)
.await?;
}
PermissionState::Implicit => {
target.reset_permission(&mut tx, perm).await?;
LogEntry::new(
&mut tx,
u,
LogAction::UpdatePermission {
id: target.id,
os,
ns: newstate,
p: perm,
},
)
.await?;
}
};
tx.commit().await?;
Ok((StatusCode::OK, Json(newstate)).into_response())
}

View File

@@ -8,10 +8,55 @@ use std::{
use env_logger::fmt::Formatter; use env_logger::fmt::Formatter;
use log::{LevelFilter, Record}; use log::{LevelFilter, Record};
use sqlx::PgPool; use sqlx::PgPool;
use url::Url;
/// Mnemosyne, the mother of the nine muses /// Mnemosyne, the mother of the nine muses
pub const DEFAULT_PORT: u16 = 0x9999; // 39321 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<Url>,
}
impl MnemoConf {
pub async fn load(conn: &mut sqlx::PgConnection) -> Result<Self, sqlx::Error> {
let row: Option<serde_json::Value> =
sqlx::query_scalar("SELECT config FROM mnemoconf LIMIT 1")
.fetch_optional(&mut *conn)
.await?;
Ok(match row {
Some(val) => serde_json::from_value(val).unwrap_or_default(),
None => {
let conf = MnemoConf::default();
conf.save(conn).await?;
conf
}
})
}
pub async fn save(&self, conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
let val = serde_json::to_value(self).unwrap();
sqlx::query("DELETE FROM mnemoconf")
.execute(&mut *conn)
.await?;
sqlx::query("INSERT INTO mnemoconf (config) VALUES ($1)")
.bind(val)
.execute(&mut *conn)
.await?;
Ok(())
}
}
impl Default for MnemoConf {
fn default() -> Self {
Self {
instance_name: String::from("Mnemosyne"),
discord_webhook: None,
}
}
}
pub const REFERENCE_SPLASHES: &[&str] = &[ pub const REFERENCE_SPLASHES: &[&str] = &[
"quote engine", "quote engine",
"powered by rust", "powered by rust",

View File

@@ -0,0 +1,3 @@
CREATE TABLE mnemoconf (
config JSONB NOT NULL
);

View File

@@ -1,9 +1,18 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, Row}; use sqlx::{PgConnection, Row};
use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames}; use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames};
use url::Url;
use uuid::Uuid; use uuid::Uuid;
use crate::{database::DatabaseError, users::User, web::icons}; use crate::{
database::DatabaseError,
quotes::Quote,
users::{
User,
permissions::{Permission, PermissionState},
},
web::icons,
};
#[derive(Debug)] #[derive(Debug)]
pub struct LogEntry { pub struct LogEntry {
@@ -105,6 +114,12 @@ pub enum LogAction {
id: Uuid, id: Uuid,
handle: String, handle: String,
}, },
UpdatePermission {
id: Uuid,
os: PermissionState,
ns: PermissionState,
p: Permission,
},
ManuallyChangeUsersPassword { ManuallyChangeUsersPassword {
id: Uuid, id: Uuid,
}, },
@@ -151,14 +166,26 @@ pub enum LogAction {
CreateQuote { CreateQuote {
id: Uuid, id: Uuid,
}, },
DeleteQuote {
quote: Quote,
},
ManuallyRevokeSession { ManuallyRevokeSession {
id: Uuid, id: Uuid,
}, },
ChangeInstanceName {
old: String,
new: String,
},
ChangeDiscordWebhookUrl {
old: Option<Url>,
new: Option<Url>,
},
} }
impl LogAction { impl LogAction {
pub fn get_target_id(&self) -> Option<Uuid> { pub fn get_target_id(&self) -> Option<Uuid> {
match self { match self {
Self::Initialize | Self::RegenInfradmin => None, Self::Initialize | Self::RegenInfradmin => None,
Self::CreateUser { id, .. } Self::CreateUser { id, .. }
| Self::CreateTag { id, .. } | Self::CreateTag { id, .. }
| Self::CreatePerson { id, .. } | Self::CreatePerson { id, .. }
@@ -167,10 +194,18 @@ impl LogAction {
| Self::ManuallyRevokeSession { id } | Self::ManuallyRevokeSession { id }
| Self::RenameTag { id, .. } | Self::RenameTag { id, .. }
| Self::DeleteTag { id, .. } | Self::DeleteTag { id, .. }
| Self::UpdatePermission { id, .. }
| Self::ManuallyChangeUsersPassword { id } => Some(*id), | Self::ManuallyChangeUsersPassword { id } => Some(*id),
Self::DeleteQuote { quote } => Some(quote.id),
Self::AddPersonName { pid, .. } Self::AddPersonName { pid, .. }
| Self::DeletePersonName { pid, .. } | Self::DeletePersonName { pid, .. }
| Self::SetPersonPrimaryName { pid, .. } => Some(*pid), | Self::SetPersonPrimaryName { pid, .. } => Some(*pid),
Self::ChangeInstanceName { .. } | Self::ChangeDiscordWebhookUrl { .. } => {
Some(Uuid::nil())
}
} }
} }
pub fn get_humanreadable_payload(&self) -> String { pub fn get_humanreadable_payload(&self) -> String {
@@ -180,6 +215,9 @@ impl LogAction {
LogAction::CreateUser { id, handle } => { LogAction::CreateUser { id, handle } => {
format!("Created user @{handle} (uid: {id})") format!("Created user @{handle} (uid: {id})")
} }
LogAction::UpdatePermission { id, os, ns, p } => {
format!("Updated permission {p} of user with id {id} from {os} to {ns}")
}
LogAction::ManuallyChangeUsersPassword { id } => { LogAction::ManuallyChangeUsersPassword { id } => {
format!("Manually changed password of user with id: {id}") format!("Manually changed password of user with id: {id}")
} }
@@ -210,9 +248,16 @@ impl LogAction {
LogAction::CreateQuote { id } => { LogAction::CreateQuote { id } => {
format!("Created quote of ID {id}") format!("Created quote of ID {id}")
} }
LogAction::DeleteQuote { quote } => {
format!("Deleted quote of ID {}", quote.id)
}
LogAction::ManuallyRevokeSession { id } => { LogAction::ManuallyRevokeSession { id } => {
format!("Revoked session of ID {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(),
} }
} }
} }
@@ -224,6 +269,7 @@ impl LogActionDiscriminant {
LAD::Initialize => "Mnemosyne Initialization", LAD::Initialize => "Mnemosyne Initialization",
LAD::RegenInfradmin => "Infradmin Regeneration", LAD::RegenInfradmin => "Infradmin Regeneration",
LAD::CreateUser => "User Creation", LAD::CreateUser => "User Creation",
LAD::UpdatePermission => "Permission Update",
LAD::ManuallyChangeUsersPassword => "Password Override", LAD::ManuallyChangeUsersPassword => "Password Override",
LAD::CreateTag => "Tag Creation", LAD::CreateTag => "Tag Creation",
LAD::RenameTag => "Tag Rename", LAD::RenameTag => "Tag Rename",
@@ -234,7 +280,10 @@ impl LogActionDiscriminant {
LAD::DeletePersonName => "Person Name Deletion", LAD::DeletePersonName => "Person Name Deletion",
LAD::SetPersonPrimaryName => "Person Primary Name Set", LAD::SetPersonPrimaryName => "Person Primary Name Set",
LAD::CreateQuote => "Quote Creation", LAD::CreateQuote => "Quote Creation",
LAD::DeleteQuote => "Quote Deletion",
LAD::ManuallyRevokeSession => "Manual Session Revocation", LAD::ManuallyRevokeSession => "Manual Session Revocation",
LAD::ChangeInstanceName => "Instance Name Change",
LAD::ChangeDiscordWebhookUrl => "Discord Webhook URL Change",
} }
} }
pub fn icon(&self) -> &'static str { pub fn icon(&self) -> &'static str {

View File

@@ -1,8 +1,10 @@
use std::error::Error; use std::{error::Error, sync::Arc};
use axum::Router; use axum::Router;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::net::TcpListener; use tokio::{net::TcpListener, sync::RwLock};
use crate::config::MnemoConf;
mod api; mod api;
mod config; mod config;
@@ -21,6 +23,7 @@ const ISE_MSG: &str = "Internal server error";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MnemoState { pub struct MnemoState {
pool: PgPool, pool: PgPool,
conf: Arc<RwLock<MnemoConf>>,
} }
#[tokio::main] #[tokio::main]
@@ -31,6 +34,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
let pool = config::init_pool().await?; let pool = config::init_pool().await?;
sqlx::migrate!("src/database/migrations").run(&pool).await?; sqlx::migrate!("src/database/migrations").run(&pool).await?;
log::info!("Migrations applied successfully."); log::info!("Migrations applied successfully.");
let conf = Arc::new(RwLock::new(
MnemoConf::load(&mut *pool.acquire().await?).await?,
));
users::auth::init_password_dummies(); users::auth::init_password_dummies();
users::setup::initialise_reserved_users_if_needed(&pool).await?; users::setup::initialise_reserved_users_if_needed(&pool).await?;
@@ -38,7 +44,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let r = Router::new() let r = Router::new()
.merge(api::api_router()) .merge(api::api_router())
.merge(web::web_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?; let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
log::info!("Listener bound to {}", l.local_addr()?); log::info!("Listener bound to {}", l.local_addr()?);

View File

@@ -2,7 +2,7 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Serialize; use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, Row}; use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
@@ -14,7 +14,7 @@ pub struct Person {
pub primary_name: String, pub primary_name: String,
} }
#[derive(Serialize)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Name { pub struct Name {
pub id: Uuid, pub id: Uuid,
pub is_primary: bool, pub is_primary: bool,

View File

@@ -3,7 +3,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Serialize; use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, Row}; use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
@@ -11,7 +11,7 @@ use crate::{database::DatabaseError, persons::Name};
mod webhook; mod webhook;
#[derive(Serialize)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Quote { pub struct Quote {
pub id: Uuid, pub id: Uuid,
pub lines: Vec<QuoteLine>, pub lines: Vec<QuoteLine>,
@@ -22,7 +22,7 @@ pub struct Quote {
pub public: bool, pub public: bool,
} }
#[derive(Serialize)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QuoteLine { pub struct QuoteLine {
pub id: Uuid, pub id: Uuid,
pub attribution: Vec<Name>, pub attribution: Vec<Name>,
@@ -290,6 +290,40 @@ impl Quote {
public, public,
}) })
} }
pub async fn delete(self, conn: &mut PgConnection) -> Result<(), QuoteError> {
sqlx::query(
r#"
DELETE FROM line_authors
WHERE line_id IN (SELECT id FROM lines WHERE quote_id = $1)
"#,
)
.bind(self.id)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM lines WHERE quote_id = $1")
.bind(self.id)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM quote_tags WHERE quote_id = $1")
.bind(self.id)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM user_quote_likes WHERE quote_id = $1")
.bind(self.id)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM quotes WHERE id = $1")
.bind(self.id)
.execute(&mut *conn)
.await?;
Ok(())
}
} }
impl From<sqlx::Error> for QuoteError { impl From<sqlx::Error> for QuoteError {

View File

@@ -1,9 +1,23 @@
use sqlx::PgConnection; use sqlx::PgConnection;
use strum::Display;
use crate::{database::DatabaseError, users::User}; use crate::{database::DatabaseError, users::User};
/// Infradmin and systemuser have all permissions. /// Infradmin and systemuser have all permissions.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
strum::IntoStaticStr,
Display,
serde::Deserialize,
serde::Serialize,
)]
pub enum Permission { pub enum Permission {
// Pass all the permission checks
// Additionally, only Admins can manage others' permissions.
Admin,
// All Users have the right to observe their own sessions // All Users have the right to observe their own sessions
ListOthersSessions, ListOthersSessions,
// All Users have the right to revoke their own sessions // All Users have the right to revoke their own sessions
@@ -16,23 +30,130 @@ pub enum Permission {
CreateTags, CreateTags,
RenameTags, RenameTags,
DeleteTags, DeleteTags,
CreateQuotes,
DeleteQuotes,
ChangePersonPrimaryName, ChangePersonPrimaryName,
#[allow(unused)]
BrowseServerLogs, BrowseServerLogs,
ConfigureInstance,
}
impl Permission {
pub fn is_default_permission(&self) -> bool {
match self {
Self::CreateTags | Self::CreateQuotes => true,
_ => false,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
strum::IntoStaticStr,
Display,
serde::Deserialize,
serde::Serialize,
)]
pub enum PermissionState {
ExplicitlyGranted,
ExplicitlyRevoked,
Implicit,
}
impl From<Option<bool>> for PermissionState {
fn from(state: Option<bool>) -> Self {
match state {
Some(true) => Self::ExplicitlyGranted,
Some(false) => Self::ExplicitlyRevoked,
None => Self::Implicit,
}
}
} }
impl User { impl User {
pub async fn permission_dbstate(
&self,
conn: &mut PgConnection,
permission: Permission,
) -> Result<Option<bool>, DatabaseError> {
let permission_key: &'static str = (&permission).into();
let state: Option<bool> = sqlx::query_scalar(
"SELECT state FROM user_permissions WHERE user_id = $1 AND permission = $2",
)
.bind(self.id)
.bind(permission_key)
.fetch_optional(&mut *conn)
.await?;
Ok(state)
}
pub async fn has_permission( pub async fn has_permission(
&self, &self,
#[allow(unused)] conn: &mut PgConnection, conn: &mut PgConnection,
#[allow(unused)] permission: Permission, permission: Permission,
) -> Result<bool, DatabaseError> { ) -> Result<bool, DatabaseError> {
// Infradmin and systemuser have all permissions
if self.is_infradmin() || self.is_systemuser() { if self.is_infradmin() || self.is_systemuser() {
return Ok(true); return Ok(true);
} }
if let Some(true) = self.permission_dbstate(conn, Permission::Admin).await? {
return Ok(true);
}
Ok(false) Ok(self
// todo!("Do the permission checking here once permissions are modeled in the DB") .permission_dbstate(conn, permission)
.await?
.unwrap_or(permission.is_default_permission()))
}
pub async fn grant_permission(
&self,
conn: &mut PgConnection,
permission: Permission,
) -> Result<(), DatabaseError> {
let permission_key: &'static str = (&permission).into();
sqlx::query(
"INSERT INTO user_permissions (user_id, permission, state) VALUES ($1, $2, TRUE) ON CONFLICT (user_id, permission) DO UPDATE SET state = EXCLUDED.state",
)
.bind(self.id)
.bind(permission_key)
.execute(&mut *conn)
.await?;
Ok(())
}
pub async fn revoke_permission(
&self,
conn: &mut PgConnection,
permission: Permission,
) -> Result<(), DatabaseError> {
let permission_key: &'static str = (&permission).into();
sqlx::query(
"INSERT INTO user_permissions (user_id, permission, state) VALUES ($1, $2, FALSE) ON CONFLICT (user_id, permission) DO UPDATE SET state = EXCLUDED.state",
)
.bind(self.id)
.bind(permission_key)
.execute(&mut *conn)
.await?;
Ok(())
}
pub async fn reset_permission(
&self,
conn: &mut PgConnection,
permission: Permission,
) -> Result<(), DatabaseError> {
let permission_key: &'static str = (&permission).into();
sqlx::query("DELETE FROM user_permissions WHERE user_id = $1 AND permission = $2")
.bind(self.id)
.bind(permission_key)
.execute(&mut *conn)
.await?;
Ok(())
} }
} }

View File

@@ -1,6 +1,10 @@
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use sqlx::PgConnection;
use crate::{users::User, web::icons}; use crate::{
users::{User, permissions::Permission},
web::icons,
};
// (SHOWTEXT, LINK, ICON, REQUIRES_LOG_IN) // (SHOWTEXT, LINK, ICON, REQUIRES_LOG_IN)
const LINKS: &[(&str, &str, &str, bool)] = &[ const LINKS: &[(&str, &str, &str, bool)] = &[
@@ -13,7 +17,12 @@ const LINKS: &[(&str, &str, &str, bool)] = &[
("Logs", "/logs", icons::CLIPBOARD_CLOCK, true), ("Logs", "/logs", icons::CLIPBOARD_CLOCK, true),
]; ];
pub fn nav(user: Option<&User>, uri: &str) -> Markup { pub async fn nav(conn: &mut PgConnection, user: Option<&User>, uri: &str) -> Markup {
#[rustfmt::skip]
let show_instance_conf = match user {
Some(u) if u.has_permission(conn, Permission::ConfigureInstance).await.is_ok_and(|r| r) => true,
_ => false,
};
html!( html!(
div class="flex items-center text-sm gap-4 border-b border-neutral-200/25 bg-neutral-200/5 px-4 py-2" { div class="flex items-center text-sm gap-4 border-b border-neutral-200/25 bg-neutral-200/5 px-4 py-2" {
a href="/dashboard" class="font-lora font-semibold hidden xs:block md:text-xl sm:mr-2" {"Mnemosyne"} a href="/dashboard" class="font-lora font-semibold hidden xs:block md:text-xl sm:mr-2" {"Mnemosyne"}
@@ -46,15 +55,22 @@ pub fn nav(user: Option<&User>, uri: &str) -> Markup {
span class="hidden sm:block"{(u.handle)} span class="hidden sm:block"{(u.handle)}
div class="scale-[.75]" {(PreEscaped(icons::USER))} 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" { 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" { 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))} div class="scale-[.7]" {(PreEscaped(icons::USER))}
p {"Profile"} 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" { 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::SERVER))} div class="scale-[.7]" {(PreEscaped(icons::SETTINGS))}
p {"Settings"} p {"User Settings"}
}
@if show_instance_conf {
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 {"Instance Config"}
}
} }
div class="h-px w-full bg-neutral-200/15" {} div class="h-px w-full bg-neutral-200/15" {}
form action="/api/auth/logout-form" method="post" { form action="/api/auth/logout-form" method="post" {

View File

@@ -4,7 +4,7 @@ use crate::{quotes::Quote, web::icons};
pub fn quote(quote: &Quote) -> Markup { pub fn quote(quote: &Quote) -> Markup {
html!( html!(
div class="border border-neutral-200/25 bg-neutral-200/5 p-3 pb-1 overflow-clip rounded-md relative flex flex-col" { div class="border border-neutral-200/25 bg-neutral-200/5 p-3 pb-1 overflow-clip rounded-md relative flex flex-col transition-colors group-hover/a:border-neutral-200/35 group-hover/a:bg-neutral-200/10" {
div class="absolute top-4 right-6 -rotate-12 opacity-[.025] scale-x-[4.5] scale-y-[4]" { div class="absolute top-4 right-6 -rotate-12 opacity-[.025] scale-x-[4.5] scale-y-[4]" {
(PreEscaped(icons::QUOTE)) (PreEscaped(icons::QUOTE))
} }

1
src/web/icons/code.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-icon lucide-code"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-square-code-icon lucide-message-square-code"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/><path d="m10 8-3 3 3 3"/><path d="m14 14 3-3-3-3"/></svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@@ -1,3 +1,4 @@
#![allow(unused)]
// Below icons sourced from https://lucide.dev // Below icons sourced from https://lucide.dev
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg"); pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
pub const CALENDAR_1: &str = include_str!("calendar-1.svg"); pub const CALENDAR_1: &str = include_str!("calendar-1.svg");
@@ -5,6 +6,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 CIRCLE_MINUS: &str = include_str!("circle-minus.svg");
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg"); pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg");
pub const CLOCK: &str = include_str!("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 CONTACT: &str = include_str!("contact.svg");
pub const EYE: &str = include_str!("eye.svg"); pub const EYE: &str = include_str!("eye.svg");
pub const FILE_IMAGE: &str = include_str!("file-image.svg"); pub const FILE_IMAGE: &str = include_str!("file-image.svg");
@@ -14,14 +16,18 @@ 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 LINE_DOT_RIGHT_HORIZONTAL: &str = include_str!("line-dot-right-horizontal.svg");
pub const LOG_OUT: &str = include_str!("log-out.svg"); pub const LOG_OUT: &str = include_str!("log-out.svg");
pub const MAP_PIN: &str = include_str!("map-pin.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 PEN: &str = include_str!("pen.svg");
pub const PLUS: &str = include_str!("plus.svg"); pub const PLUS: &str = include_str!("plus.svg");
pub const QUOTE: &str = include_str!("quote.svg"); pub const QUOTE: &str = include_str!("quote.svg");
pub const REFRESH_CW: &str = include_str!("refresh-cw.svg"); pub const REFRESH_CW: &str = include_str!("refresh-cw.svg");
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg"); pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
pub const SERVER: &str = include_str!("server.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 SHIELD_USER: &str = include_str!("shield-user.svg");
pub const TAG: &str = include_str!("tag.svg"); pub const TAG: &str = include_str!("tag.svg");
pub const TRASH: &str = include_str!("trash.svg");
pub const TYPE: &str = include_str!("type.svg");
pub const USER: &str = include_str!("user.svg"); pub const USER: &str = include_str!("user.svg");
pub const USER_KEY: &str = include_str!("user-key.svg"); pub const USER_KEY: &str = include_str!("user-key.svg");
pub const USER_PLUS: &str = include_str!("user-plus.svg"); pub const USER_PLUS: &str = include_str!("user-plus.svg");

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 610 B

1
src/web/icons/trash.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-icon lucide-trash"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>

After

Width:  |  Height:  |  Size: 355 B

1
src/web/icons/type.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-type-icon lucide-type"><path d="M12 4v16"/><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"/><path d="M9 20h6"/></svg>

After

Width:  |  Height:  |  Size: 322 B

232
src/web/pages/conf.rs Normal file
View File

@@ -0,0 +1,232 @@
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<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
if !u
.has_permission(&mut *conn, Permission::ConfigureInstance)
.await?
{
return Ok((
StatusCode::FORBIDDEN,
"You do not have permission to view this page.",
)
.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(&mut conn, Some(&u), req.uri().path()).await)
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",
&current_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/...",
&current_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<MnemoState>,
headers: HeaderMap,
Form(form): Form<InstanceNameForm>,
) -> Result<Response, CompositeError> {
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());
}
let new_name = form.instance_name.trim().to_string();
LogEntry::new(
&mut tx,
u,
LogAction::ChangeInstanceName {
old: state.conf.read().await.instance_name.clone(),
new: new_name.clone(),
},
)
.await?;
let mut new_conf = state.conf.read().await.clone();
new_conf.instance_name = new_name.clone();
new_conf.save(&mut tx).await?;
tx.commit().await?;
state.conf.write().await.instance_name = new_name;
Ok(Redirect::to("/instance-config").into_response())
}
#[derive(Deserialize)]
pub struct WebhookForm {
webhook_url: String,
}
pub async fn change_webhook(
State(state): State<MnemoState>,
headers: HeaderMap,
Form(form): Form<WebhookForm>,
) -> Result<Response, CompositeError> {
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?;
let mut new_conf = state.conf.read().await.clone();
new_conf.discord_webhook = new_webhook.clone();
new_conf.save(&mut tx).await?;
tx.commit().await?;
state.conf.write().await.discord_webhook = new_webhook;
Ok(Redirect::to("/instance-config").into_response())
}

View File

@@ -51,7 +51,7 @@ pub async fn page(
Ok(base( Ok(base(
"Dashboard | Mnemosyne", "Dashboard | Mnemosyne",
html!( html!(
(nav(u.as_ref(), req.uri().path())) (nav(&mut conn, u.as_ref(), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 mt-4 grid grid-cols-1 --sm:grid-cols-2 gap-4" { div class="mx-auto max-w-4xl px-2 mt-4 grid grid-cols-1 --sm:grid-cols-2 gap-4" {
div class="flex flex-col" { div class="flex flex-col" {
@@ -61,7 +61,9 @@ pub async fn page(
"This just in! This quote was added " "This just in! This quote was added "
(format_time_ago(q.get_creation_timestamp())) " ago." (format_time_ago(q.get_creation_timestamp())) " ago."
} }
div class="flex-1 [&>div]:h-full" {(quote(q))} div class="flex-1 [&>div]:h-full" {
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
}
} @else { } @else {
p class="text-neutral-500 font-light mb-4" {"No quotes yet."} p class="text-neutral-500 font-light mb-4" {"No quotes yet."}
} }
@@ -76,7 +78,9 @@ pub async fn page(
"This quote was added " "This quote was added "
(format_time_ago(q.get_creation_timestamp())) " ago." (format_time_ago(q.get_creation_timestamp())) " ago."
} }
div class="flex-1 [&>div]:h-full" {(quote(&q))} div class="flex-1 [&>div]:h-full" {
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(&q))}
}
} }
} }
} }

View File

@@ -42,7 +42,7 @@ pub async fn page(
Ok(base( Ok(base(
"Logs | Mnemosyne", "Logs | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut tx, Some(&u), req.uri().path()).await)
@if true {//let Ok(true) = u.has_permission(&mut *tx, Permission::BrowseServerLogs) { @if true {//let Ok(true) = u.has_permission(&mut *tx, Permission::BrowseServerLogs) {
div class="max-w-4xl mx-auto px-2" { div class="max-w-4xl mx-auto px-2" {

View File

@@ -6,6 +6,7 @@ use maud::{DOCTYPE, Markup, html};
use crate::MnemoState; use crate::MnemoState;
pub mod conf;
pub mod dashboard; pub mod dashboard;
pub mod index; pub mod index;
pub mod login; pub mod login;
@@ -22,9 +23,15 @@ pub fn pages() -> Router<MnemoState> {
.route("/", get(index::page)) .route("/", get(index::page))
.route("/login", get(login::page)) .route("/login", get(login::page))
.route("/dashboard", get(dashboard::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", get(usersettings::page))
.route("/user-settings/handle", post(usersettings::change_handle)) .route("/user-settings/handle", post(usersettings::change_handle))
.route("/user-settings/passwd", post(usersettings::change_password)) .route("/user-settings/passwd", post(usersettings::change_password))
//
.route("/users", get(users::page)) .route("/users", get(users::page))
.route("/users/{id}", get(users::profile::page)) .route("/users/{id}", get(users::profile::page))
.route("/users/create", get(users::create::page)) .route("/users/create", get(users::create::page))
@@ -43,6 +50,11 @@ pub fn pages() -> Router<MnemoState> {
.route("/logs", get(logs::page)) .route("/logs", get(logs::page))
// //
.route("/quotes", get(quotes::page)) .route("/quotes", get(quotes::page))
.route("/quotes/{id}", get(quotes::id::page))
.route(
"/quotes/{id}/delete",
get(quotes::id::delete_confirm).post(quotes::id::delete),
)
.route("/quotes/add", get(quotes::add::page)) .route("/quotes/add", get(quotes::add::page))
.route("/quotes/add-form", post(quotes::add::form)) .route("/quotes/add-form", post(quotes::add::form))
// //

View File

@@ -1,5 +1,9 @@
use axum::extract::{Request, State}; use axum::{
use maud::{Markup, html}; extract::{Request, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use maud::html;
use crate::{ use crate::{
MnemoState, MnemoState,
@@ -8,16 +12,19 @@ use crate::{
web::{components::nav::nav, pages::base}, web::{components::nav::nav, pages::base},
}; };
pub async fn page(State(state): State<MnemoState>, req: Request) -> Result<Markup, CompositeError> { pub async fn page(
State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?; let mut conn = state.pool.acquire().await?;
let u = User::authenticate(&mut *conn, req.headers()) let u = User::authenticate(&mut *conn, req.headers())
.await .await
.ok() .ok()
.flatten(); .flatten();
Ok(base( Ok((StatusCode::NOT_FOUND, base(
"Not Found | Mnemosyne", "Not Found | Mnemosyne",
html!( html!(
(nav(u.as_ref(), req.uri().path())) (nav(&mut conn, u.as_ref(), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 mt-8 mb-2" { div class="mx-auto max-w-4xl px-2 mt-8 mb-2" {
h1 class="text-4xl font-lora font-semibold mb-1" { "Not Found" } h1 class="text-4xl font-lora font-semibold mb-1" { "Not Found" }
@@ -27,5 +34,5 @@ pub async fn page(State(state): State<MnemoState>, req: Request) -> Result<Marku
} }
} }
), ),
)) )).into_response())
} }

View File

@@ -45,7 +45,7 @@ pub async fn page(
Ok(base( Ok(base(
"Persons | Mnemosyne", "Persons | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 my-4" { div class="mx-auto max-w-4xl px-2 my-4" {
p class="flex items-center gap-2" { p class="flex items-center gap-2" {

View File

@@ -51,7 +51,7 @@ pub async fn page(
Ok(base( Ok(base(
&title, &title,
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 my-4" { div class="mx-auto max-w-4xl px-2 my-4" {
@if let Ok(p) = p { @if let Ok(p) = p {

View File

@@ -18,6 +18,7 @@ use crate::{
}; };
pub mod add; pub mod add;
pub mod id;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PageQuery { pub struct PageQuery {
@@ -60,7 +61,7 @@ pub async fn page(
Ok(base( Ok(base(
"Quotes | Mnemosyne", "Quotes | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="max-w-4xl mx-auto px-2" { div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" { div class="my-4 flex justify-between" {
@@ -85,7 +86,7 @@ pub async fn page(
} }
div class="flex flex-col gap-4 mb-8" { div class="flex flex-col gap-4 mb-8" {
@for q in &quotes { @for q in &quotes {
(quote(q)) a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
} }
div class="flex justify-between items-center mt-4 text-neutral-400" { div class="flex justify-between items-center mt-4 text-neutral-400" {

View File

@@ -6,7 +6,6 @@ use axum::{
use axum_extra::extract::Form; use axum_extra::extract::Form;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use reqwest::Url;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -36,11 +35,12 @@ pub async fn page(
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let names = Name::get_all(&mut *conn).await?; let names = Name::get_all(&mut *conn).await?;
let feature_webhooks = state.conf.read().await.discord_webhook.is_some();
Ok(base( Ok(base(
"Add Quote | Mnemosyne", "Add Quote | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="max-w-4xl mx-auto px-2" { div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" { div class="my-4 flex justify-between" {
@@ -92,6 +92,15 @@ pub async fn page(
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25"; class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
} }
} }
@if feature_webhooks {
div class="flex flex-col justify-center mt-5" {
label class="flex items-center gap-2 cursor-pointer" {
input type="checkbox" name="discord_webhook" value="true" checked
class="w-4 h-4 cursor-pointer";
span {"Send to Discord"}
}
}
}
button type="submit" class="border mt-auto mb-2 cursor-pointer rounded h-fit px-2 py-1 bg-neutral-200/5 border-neutral-200/25 hover:border-neutral-200/45 hover:bg-neutral-200/15" { button type="submit" class="border mt-auto mb-2 cursor-pointer rounded h-fit px-2 py-1 bg-neutral-200/5 border-neutral-200/25 hover:border-neutral-200/45 hover:bg-neutral-200/15" {
"Submit" "Submit"
} }
@@ -141,6 +150,7 @@ pub struct IncomingQuote {
location: String, location: String,
time: String, time: String,
context: String, context: String,
discord_webhook: Option<String>,
} }
pub async fn form( pub async fn form(
State(state): State<MnemoState>, State(state): State<MnemoState>,
@@ -179,10 +189,10 @@ pub async fn form(
LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?; LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?;
tx.commit().await?; tx.commit().await?;
if let Ok(webhook_url) = std::env::var("DISCORD_WEBHOOK_URL") { let should_send_webhook = form.discord_webhook.as_deref() == Some("true");
match Url::parse(&webhook_url) { if should_send_webhook {
Ok(u) => q.post_msg_webhook(u), if let Some(ref url) = state.conf.read().await.discord_webhook {
Err(e) => log::error!("Tried to post webhook, failed to parse url: {e}"), q.post_msg_webhook(url.clone());
} }
} }

146
src/web/pages/quotes/id.rs Normal file
View File

@@ -0,0 +1,146 @@
use axum::{
extract::{Path, Request, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
use http::StatusCode;
use maud::{PreEscaped, html};
use uuid::Uuid;
use crate::{
MnemoState,
error::CompositeError,
logs::{LogAction, LogEntry},
quotes::Quote,
users::{
User,
auth::{UserAuthRequired, UserAuthenticate},
permissions::Permission,
},
web::{
components::{nav::nav, quote::quote},
icons,
pages::base,
},
};
pub async fn page(
State(state): State<MnemoState>,
Path(id): Path<Uuid>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
let q = Quote::get_by_id(&mut conn, id).await;
let can_delete = u
.has_permission(&mut conn, Permission::DeleteQuotes)
.await?;
Ok(base(
"Add Quote | Mnemosyne",
html!(
(nav(&mut conn, Some(&u), req.uri().path()).await)
div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" {
p class="flex items-center gap-2 text-neutral-500" {
(PreEscaped(icons::SCROLL_TEXT))
span class="font-lora" {"Quote of ID " (id)}
}
}
@if let Ok(q) = q {
(quote(&q))
div class="flex flex-row w-full flex-wrap justify-end gap-2 mt-2" {
a href="#" disabled class="opacity-[.5] cursor-not-allowed px-2 py-1 border rounded flex flex-row gap-1 bg-neutral-200/5 border-neutral-200/25 hover:bg-neutral-200/15 hover:border-neutral-200/45" {
span class="scale-[.75]" {(PreEscaped(icons::PEN))}
"Edit"
}
@if can_delete {
a href=(format!("/quotes/{id}/delete")) class="px-2 py-1 cursor-pointer border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
span class="scale-[.75]" {(PreEscaped(icons::TRASH))}
"Delete"
}
}
}
} @else {
"Failed to fetch quote. Are you sure it exists?"
}
}
),
)
.into_response())
}
pub async fn delete_confirm(
State(state): State<MnemoState>,
Path(id): Path<Uuid>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
let q = Quote::get_by_id(&mut conn, id).await;
Ok(base(
"Delete Quote | Mnemosyne",
html!(
(nav(&mut conn, Some(&u), req.uri().path()).await)
div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" {
p class="flex items-center gap-2 text-neutral-500" {
(PreEscaped(icons::TRASH))
span class="font-lora" {"Deleting quote of ID " (id)}
}
}
@if let Ok(q) = q {
div class="border border-pink-400/25 bg-pink-400/10 rounded-md p-3 mb-4" {
p class="flex flex-wrap items-center gap-2 text-pink-200" {
span class="font-semibold" {"Are you sure you want to delete this quote?"}
span class="text-pink-300/80" {"This cannot be undone."}
}
}
(quote(&q))
div class="flex flex-row w-full flex-wrap justify-start gap-2 mt-2" {
form method="post" action=(format!("/quotes/{id}/delete")) {
button type="submit" class="px-2 py-1 cursor-pointer border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
span class="scale-[.75]" {(PreEscaped(icons::TRASH))}
"Delete"
}
}
a href=(format!("/quotes/{id}")) class="px-2 py-1 border rounded flex flex-row gap-1 bg-neutral-200/5 border-neutral-200/25 hover:bg-neutral-200/15 hover:border-neutral-200/45" {
"Cancel"
}
}
} @else {
"Failed to fetch quote. Are you sure it exists?"
}
}
),
)
.into_response())
}
pub async fn delete(
State(state): State<MnemoState>,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
let mut tx = state.pool.begin().await?;
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
if !u.has_permission(&mut tx, Permission::DeleteQuotes).await? {
return Ok((StatusCode::FORBIDDEN, "No permission.").into_response());
}
let q = Quote::get_by_id(&mut *tx, id).await?;
LogEntry::new(&mut *tx, u, LogAction::DeleteQuote { quote: q.clone() }).await?;
q.delete(&mut tx).await?;
tx.commit().await?;
Ok(Redirect::to("/quotes").into_response())
}

View File

@@ -46,7 +46,7 @@ pub async fn page(
Ok(base( Ok(base(
"Tags | Mnemosyne", "Tags | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 my-4" { div class="mx-auto max-w-4xl px-2 my-4" {
p class="flex items-center gap-2" { p class="flex items-center gap-2" {

View File

@@ -3,6 +3,7 @@ use axum::{
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use maud::{PreEscaped, html}; use maud::{PreEscaped, html};
use uuid::Uuid;
use crate::{ use crate::{
MnemoState, MnemoState,
@@ -27,7 +28,14 @@ pub async fn page(
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let us = User::get_all(&mut *conn).await; let us = User::get_all(&mut *conn).await.map(|mut v| {
v.sort_by_key(|p| match p.id {
id if id == Uuid::nil() => (0, p.id),
id if id == Uuid::max() => (1, p.id),
_ => (2, p.id),
});
v
});
let can_create_users = u let can_create_users = u
.has_permission(&mut *conn, Permission::ManuallyCreateUsers) .has_permission(&mut *conn, Permission::ManuallyCreateUsers)
.await; .await;
@@ -35,7 +43,7 @@ pub async fn page(
Ok(base( Ok(base(
"Users | Mnemosyne", "Users | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 my-4" { div class="mx-auto max-w-4xl px-2 my-4" {
p class="flex items-center gap-2" { p class="flex items-center gap-2" {

View File

@@ -36,7 +36,7 @@ pub async fn page(
Ok(base( Ok(base(
"Create User | Mnemosyne", "Create User | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="mx-auto max-w-4xl px-2 my-4" { div class="mx-auto max-w-4xl px-2 my-4" {
p class="flex items-center gap-2" { p class="flex items-center gap-2" {

View File

@@ -33,7 +33,7 @@ pub async fn page(
return Ok(base( return Ok(base(
"No such user | Mnemosyne", "No such user | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut tx, Some(&u), req.uri().path()).await)
div class="mx-auto max-w-4xl mt-16 text-center" { div class="mx-auto max-w-4xl mt-16 text-center" {
div class="text-6xl mb-4" { "?" } div class="text-6xl mb-4" { "?" }
p class="text-red-400 text-lg" { "No such user found." } p class="text-red-400 text-lg" { "No such user found." }
@@ -48,7 +48,7 @@ pub async fn page(
} }
_ => { _ => {
return Ok(base("Error | Mnemosyne", html!( return Ok(base("Error | Mnemosyne", html!(
(nav(Some(&u), req.uri().path())) (nav(&mut tx, Some(&u), req.uri().path()).await)
p class="text-red-400 text-center my-4" { "An error occurred while loading this profile." } p class="text-red-400 text-center my-4" { "An error occurred while loading this profile." }
)).into_response()); )).into_response());
} }
@@ -70,7 +70,7 @@ pub async fn page(
Ok(base( Ok(base(
&format!("@{} | Mnemosyne", user.handle), &format!("@{} | Mnemosyne", user.handle),
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut tx, Some(&u), req.uri().path()).await)
// banner // banner
div class="relative w-full h-48 sm:h-56 md:h-64 bg-linear-to-b from-neutral-800 from-25% to-emerald-950 overflow-hidden" { div class="relative w-full h-48 sm:h-56 md:h-64 bg-linear-to-b from-neutral-800 from-25% to-emerald-950 overflow-hidden" {

View File

@@ -32,18 +32,16 @@ pub async fn page(
Ok(base( Ok(base(
"User Settings | Mnemosyne", "User Settings | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(&mut conn, Some(&u), req.uri().path()).await)
div class="max-w-4xl mx-auto px-2" { div class="max-w-4xl mx-auto px-2" {
div class="mx-auto max-w-4xl my-4" { div class="mx-auto max-w-4xl my-4" {
p class="flex items-center gap-2" { 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"} span class="text-2xl font-semibold font-lora" {"Your User Settings"}
} }
p class="text-neutral-500 text-sm font-light" { p class="text-neutral-500 text-sm font-light" {
// "Hi, " (u.handle) "!" " " "This is your user settings page." br; "Hi, " (u.handle) "!" " " "This is your user settings page."
"Looking for Mnemosyne settings?" " "
a class="text-blue-500 hover:text-blue-400 hover:underline" href="/mnemosyne-settings" {"Here."}
} }
} }