diff options
author | metamuffin <metamuffin@disroot.org> | 2025-04-27 19:25:11 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-04-27 19:25:11 +0200 |
commit | 11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch) | |
tree | 44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/ui/admin | |
parent | d871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff) | |
download | jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.bz2 jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.zst |
move files around
Diffstat (limited to 'server/src/ui/admin')
-rw-r--r-- | server/src/ui/admin/log.rs | 258 | ||||
-rw-r--r-- | server/src/ui/admin/mod.rs | 288 | ||||
-rw-r--r-- | server/src/ui/admin/user.rs | 176 |
3 files changed, 722 insertions, 0 deletions
diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs new file mode 100644 index 0000000..dff6d1b --- /dev/null +++ b/server/src/ui/admin/log.rs @@ -0,0 +1,258 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use crate::{ + logic::session::AdminSession, + ui::{ + error::MyResult, + layout::{DynLayoutPage, LayoutPage}, + }, + uri, +}; +use chrono::{DateTime, Utc}; +use log::Level; +use markup::Render; +use rocket::get; +use rocket_ws::{Message, Stream, WebSocket}; +use serde_json::json; +use std::{ + collections::VecDeque, + fmt::Write, + sync::{Arc, LazyLock, RwLock}, +}; +use tokio::sync::broadcast; + +const MAX_LOG_LEN: usize = 4096; + +static LOGGER: LazyLock<Log> = LazyLock::new(Log::default); + +pub fn enable_logging() { + log::set_logger(&*LOGGER).unwrap(); + log::set_max_level(log::LevelFilter::Debug); +} + +type LogBuffer = VecDeque<Arc<LogLine>>; + +pub struct Log { + inner: env_logger::Logger, + stream: ( + broadcast::Sender<Arc<LogLine>>, + broadcast::Sender<Arc<LogLine>>, + ), + log: RwLock<(LogBuffer, LogBuffer)>, +} + +pub struct LogLine { + time: DateTime<Utc>, + module: Option<&'static str>, + level: Level, + message: String, +} + +#[get("/admin/log?<warnonly>", rank = 2)] +pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult<DynLayoutPage<'a>> { + Ok(LayoutPage { + title: "Log".into(), + class: Some("admin_log"), + content: markup::new! { + h1 { "Server Log" } + a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }} + code.log[id="log"] { + @let g = LOGGER.log.read().unwrap(); + table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } { + tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] { + td.time { @e.time.to_rfc3339() } + td.loglevel { @format_level(e.level) } + td.module { @e.module } + td { @markup::raw(vt100_to_html(&e.message)) } + } + }} + } + }, + }) +} + +#[get("/admin/log?stream&<warnonly>", rank = 1)] +pub fn r_admin_log_stream( + _session: AdminSession, + ws: WebSocket, + warnonly: bool, +) -> Stream!['static] { + let mut stream = if warnonly { + LOGGER.stream.1.subscribe() + } else { + LOGGER.stream.0.subscribe() + }; + Stream! { ws => + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(json!({ + "time": line.time, + "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(), + "level_html": format_level_string(line.level), + "module": line.module, + "message": vt100_to_html(&line.message), + }).to_string()); + } + } +} + +impl Default for Log { + fn default() -> Self { + Self { + inner: env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .parse_env("LOG") + .build(), + stream: ( + tokio::sync::broadcast::channel(1024).0, + tokio::sync::broadcast::channel(1024).0, + ), + log: Default::default(), + } + } +} +impl Log { + fn should_log(&self, metadata: &log::Metadata) -> bool { + let level = metadata.level(); + level + <= match metadata.target() { + x if x.starts_with("jelly") => Level::Debug, + x if x.starts_with("rocket::") => Level::Info, + _ => Level::Warn, + } + } + fn do_log(&self, record: &log::Record) { + let time = Utc::now(); + let line = Arc::new(LogLine { + time, + module: record.module_path_static(), + level: record.level(), + message: record.args().to_string(), + }); + let mut w = self.log.write().unwrap(); + w.0.push_back(line.clone()); + let _ = self.stream.0.send(line.clone()); + while w.0.len() > MAX_LOG_LEN { + w.0.pop_front(); + } + if record.level() <= Level::Warn { + let _ = self.stream.1.send(line.clone()); + w.1.push_back(line); + while w.1.len() > MAX_LOG_LEN { + w.1.pop_front(); + } + } + } +} + +impl log::Log for Log { + fn enabled(&self, metadata: &log::Metadata) -> bool { + self.inner.enabled(metadata) || self.should_log(metadata) + } + fn log(&self, record: &log::Record) { + match (record.module_path_static(), record.line()) { + // TODO is there a better way to ignore those? + (Some("rocket::rocket"), Some(670)) => return, + (Some("rocket::server"), Some(401)) => return, + _ => {} + } + if self.inner.enabled(record.metadata()) { + self.inner.log(record); + } + if self.should_log(record.metadata()) { + self.do_log(record) + } + } + fn flush(&self) { + self.inner.flush(); + } +} + +fn vt100_to_html(s: &str) -> String { + let mut out = HtmlOut::default(); + let mut st = vte::Parser::new(); + st.advance(&mut out, s.as_bytes()); + out.s +} + +fn format_level(level: Level) -> impl markup::Render { + let (s, c) = match level { + Level::Debug => ("DEBUG", "blue"), + Level::Error => ("ERROR", "red"), + Level::Warn => ("WARN", "yellow"), + Level::Info => ("INFO", "green"), + Level::Trace => ("TRACE", "lightblue"), + }; + markup::new! { span[style=format!("color:{c}")] {@s} } +} +fn format_level_string(level: Level) -> String { + let mut w = String::new(); + format_level(level).render(&mut w).unwrap(); + w +} + +#[derive(Default)] +pub struct HtmlOut { + s: String, + color: bool, +} +impl HtmlOut { + pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { + self.reset_color(); + self.color = true; + write!(self.s, "<span style=color:#{:02x}{:02x}{:02x}>", r, g, b).unwrap() + } + pub fn reset_color(&mut self) { + if self.color { + write!(self.s, "</span>").unwrap(); + self.color = false; + } + } +} +impl vte::Perform for HtmlOut { + fn print(&mut self, c: char) { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), + x => write!(self.s, "&#{};", x as u32).unwrap(), + } + } + fn execute(&mut self, _byte: u8) {} + fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} + fn put(&mut self, _byte: u8) {} + fn unhook(&mut self) {} + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} + fn csi_dispatch( + &mut self, + params: &vte::Params, + _intermediates: &[u8], + _ignore: bool, + action: char, + ) { + let mut k = params.iter(); + #[allow(clippy::single_match)] + match action { + 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { + c @ (30..=37 | 40..=47) => { + let c = if *c >= 40 { *c - 10 } else { *c }; + self.set_color(match c { + 30 => [0, 0, 0], + 31 => [255, 0, 0], + 32 => [0, 255, 0], + 33 => [255, 255, 0], + 34 => [0, 0, 255], + 35 => [255, 0, 255], + 36 => [0, 255, 255], + 37 => [255, 255, 255], + _ => unreachable!(), + }); + } + _ => (), + }, + _ => (), + } + } +} diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs new file mode 100644 index 0000000..de06610 --- /dev/null +++ b/server/src/ui/admin/mod.rs @@ -0,0 +1,288 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +pub mod log; +pub mod user; + +use super::assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}; +use crate::{ + database::Database, + logic::session::AdminSession, + ui::{ + admin::log::rocket_uri_macro_r_admin_log, + error::MyResult, + layout::{DynLayoutPage, FlashDisplay, LayoutPage}, + }, + uri, +}; +use anyhow::{anyhow, Context}; +use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; +use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; +use markup::DynRender; +use rand::Rng; +use rocket::{form::Form, get, post, FromForm, State}; +use std::time::Instant; +use tokio::{sync::Semaphore, task::spawn_blocking}; +use user::rocket_uri_macro_r_admin_users; + +#[get("/admin/dashboard")] +pub async fn r_admin_dashboard( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + admin_dashboard(database, None).await +} + +pub async fn admin_dashboard<'a>( + database: &Database, + flash: Option<MyResult<String>>, +) -> MyResult<DynLayoutPage<'a>> { + let invites = database.list_invites()?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + let last_import_err = IMPORT_ERRORS.read().await.to_owned(); + + let database = database.to_owned(); + Ok(LayoutPage { + title: "Admin Dashboard".to_string(), + content: markup::new! { + h1 { "Admin Panel" } + @FlashDisplay { flash: flash.clone() } + @if !last_import_err.is_empty() { + section.message.error { + details { + summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } } + ol { @for e in &last_import_err { + li.error { pre.error { @e } } + }} + } + } + } + ul { + li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }} + li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }} + } + h2 { "Library" } + @if is_importing() { + section.message { p.warn { "An import is currently running." } } + } + @if is_transcoding() { + section.message { p.warn { "Currently transcoding posters." } } + } + form[method="POST", action=uri!(r_admin_import(true))] { + input[type="submit", disabled=is_importing(), value="Start incremental import"]; + } + form[method="POST", action=uri!(r_admin_import(false))] { + input[type="submit", disabled=is_importing(), value="Start full import"]; + } + form[method="POST", action=uri!(r_admin_transcode_posters())] { + input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"]; + } + form[method="POST", action=uri!(r_admin_update_search())] { + input[type="submit", value="Regenerate full-text search index"]; + } + form[method="POST", action=uri!(r_admin_delete_cache())] { + input.danger[type="submit", value="Delete Cache"]; + } + h2 { "Users" } + p { a[href=uri!(r_admin_users())] "Manage Users" } + h2 { "Invitations" } + form[method="POST", action=uri!(r_admin_invite())] { + input[type="submit", value="Generate new invite code"]; + } + ul { @for t in &invites { + li { + form[method="POST", action=uri!(r_admin_remove_invite())] { + span { @t } + input[type="text", name="invite", value=&t, hidden]; + input[type="submit", value="Invalidate"]; + } + } + }} + + h2 { "Database" } + @match db_stats(&database) { + Ok(s) => { @s } + Err(e) => { pre.error { @format!("{e:?}") } } + } + }, + ..Default::default() + }) +} + +#[post("/admin/generate_invite")] +pub async fn r_admin_invite( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + let i = format!("{}", rand::rng().random::<u128>()); + database.create_invite(&i)?; + admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await +} + +#[derive(FromForm)] +pub struct DeleteInvite { + invite: String, +} + +#[post("/admin/remove_invite", data = "<form>")] +pub async fn r_admin_remove_invite( + session: AdminSession, + database: &State<Database>, + form: Form<DeleteInvite>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + if !database.delete_invite(&form.invite)? { + Err(anyhow!("invite does not exist"))?; + }; + admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await +} + +#[post("/admin/import?<incremental>")] +pub async fn r_admin_import( + session: AdminSession, + database: &State<Database>, + _federation: &State<Federation>, + incremental: bool, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let t = Instant::now(); + if !incremental { + database.clear_nodes()?; + } + let r = import_wrap((*database).clone(), incremental).await; + let flash = r + .map_err(|e| e.into()) + .map(|_| format!("Import successful; took {:?}", t.elapsed())); + admin_dashboard(database, Some(flash)).await +} + +#[post("/admin/update_search")] +pub async fn r_admin_update_search( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + let db2 = (*database).clone(); + let r = spawn_blocking(move || db2.search_create_index()) + .await + .unwrap(); + admin_dashboard( + database, + Some( + r.map_err(|e| e.into()) + .map(|_| "Search index updated".to_string()), + ), + ) + .await +} + +#[post("/admin/delete_cache")] +pub async fn r_admin_delete_cache( + session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let t = Instant::now(); + let r = tokio::fs::remove_dir_all(&CONF.cache_path).await; + tokio::fs::create_dir(&CONF.cache_path).await?; + admin_dashboard( + database, + Some( + r.map_err(|e| e.into()) + .map(|_| format!("Cache deleted; took {:?}", t.elapsed())), + ), + ) + .await +} + +static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1); +fn is_transcoding() -> bool { + SEM_TRANSCODING.available_permits() == 0 +} + +#[post("/admin/transcode_posters")] +pub async fn r_admin_transcode_posters( + session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let _permit = SEM_TRANSCODING + .try_acquire() + .context("transcoding in progress")?; + + let t = Instant::now(); + + { + let nodes = database.list_nodes_with_udata("")?; + for (node, _) in nodes { + if let Some(poster) = &node.poster { + let asset = AssetInner::deser(&poster.0)?; + if asset.is_federated() { + continue; + } + let source = resolve_asset(asset).await.context("resolving asset")?; + jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024) + .await + .context("transcoding asset")?; + } + } + } + drop(_permit); + + admin_dashboard( + database, + Some(Ok(format!( + "All posters pre-transcoded; took {:?}", + t.elapsed() + ))), + ) + .await +} + +fn db_stats(_db: &Database) -> anyhow::Result<DynRender> { + // TODO + // let txn = db.inner.begin_read()?; + // let stats = [ + // ("node", txn.open_table(T_NODE)?.stats()?), + // ("user", txn.open_table(T_USER_NODE)?.stats()?), + // ("user-node", txn.open_table(T_USER_NODE)?.stats()?), + // ("invite", txn.open_table(T_INVITE)?.stats()?), + // ]; + + // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats(); + // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?; + + Ok(markup::new! { + // h3 { "Key-Value-Store Statistics" } + // table.border { + // tbody { + // tr { + // th { "table name" } + // th { "tree height" } + // th { "stored bytes" } + // th { "metadata bytes" } + // th { "fragmented bytes" } + // th { "branch pages" } + // th { "leaf pages" } + // } + // @for (name, stats) in &stats { tr { + // td { @name } + // td { @stats.tree_height() } + // td { @format_size(stats.stored_bytes(), DECIMAL) } + // td { @format_size(stats.metadata_bytes(), DECIMAL) } + // td { @format_size(stats.fragmented_bytes(), DECIMAL) } + // td { @stats.branch_pages() } + // td { @stats.leaf_pages() } + // }} + // } + // } + // h3 { "Search Engine Statistics" } + // ul { + // li { "Total documents: " @ft_total_docs } + // li { "Cache misses: " @cache_stats.cache_misses } + // li { "Cache hits: " @cache_stats.cache_hits } + // } + }) +} diff --git a/server/src/ui/admin/user.rs b/server/src/ui/admin/user.rs new file mode 100644 index 0000000..c5239f7 --- /dev/null +++ b/server/src/ui/admin/user.rs @@ -0,0 +1,176 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use crate::{ + database::Database, + logic::session::AdminSession, + ui::{ + error::MyResult, + layout::{DynLayoutPage, FlashDisplay, LayoutPage}, + }, + uri, +}; +use anyhow::{anyhow, Context}; +use jellycommon::user::{PermissionSet, UserPermission}; +use rocket::{form::Form, get, post, FromForm, FromFormField, State}; + +#[get("/admin/users")] +pub fn r_admin_users( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + user_management(database, None) +} + +fn user_management<'a>( + database: &Database, + flash: Option<MyResult<String>>, +) -> MyResult<DynLayoutPage<'a>> { + // TODO this doesnt scale, pagination! + let users = database.list_users()?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + Ok(LayoutPage { + title: "User management".to_string(), + content: markup::new! { + h1 { "User Management" } + @FlashDisplay { flash: flash.clone() } + h2 { "All Users" } + ul { @for u in &users { + li { + a[href=uri!(r_admin_user(&u.name))] { @format!("{:?}", u.display_name) " (" @u.name ")" } + } + }} + }, + ..Default::default() + }) +} + +#[get("/admin/user/<name>")] +pub fn r_admin_user<'a>( + _session: AdminSession, + database: &State<Database>, + name: &'a str, +) -> MyResult<DynLayoutPage<'a>> { + manage_single_user(database, None, name.to_string()) +} + +fn manage_single_user<'a>( + database: &Database, + flash: Option<MyResult<String>>, + name: String, +) -> MyResult<DynLayoutPage<'a>> { + let user = database + .get_user(&name)? + .ok_or(anyhow!("user does not exist"))?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + Ok(LayoutPage { + title: "User management".to_string(), + content: markup::new! { + h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } + a[href=uri!(r_admin_users())] "Back to the User List" + @FlashDisplay { flash: flash.clone() } + form[method="POST", action=uri!(r_admin_remove_user())] { + input[type="text", name="name", value=&user.name, hidden]; + input.danger[type="submit", value="Remove user(!)"]; + } + + h2 { "Permissions" } + @PermissionDisplay { perms: &user.permissions } + + form[method="POST", action=uri!(r_admin_user_permission())] { + input[type="text", name="name", value=&user.name, hidden]; + fieldset.perms { + legend { "Permission" } + @for p in UserPermission::ALL_ENUMERABLE { + label { + input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()]; + @format!("{p}") + } br; + } + } + fieldset.perms { + legend { "Permission" } + label { input[type="radio", name="action", value="unset"]; "Unset" } br; + label { input[type="radio", name="action", value="grant"]; "Grant" } br; + label { input[type="radio", name="action", value="revoke"]; "Revoke" } br; + } + input[type="submit", value="Update"]; + } + + }, + ..Default::default() + }) +} + +markup::define! { + PermissionDisplay<'a>(perms: &'a PermissionSet) { + ul { @for (perm,grant) in &perms.0 { + @if *grant { + li[class="perm-grant"] { @format!("Allow {}", perm) } + } else { + li[class="perm-revoke"] { @format!("Deny {}", perm) } + } + }} + } +} + +#[derive(FromForm)] +pub struct DeleteUser { + name: String, +} +#[derive(FromForm)] +pub struct UserPermissionForm { + name: String, + permission: String, + action: GrantState, +} + +#[derive(FromFormField)] +pub enum GrantState { + Grant, + Revoke, + Unset, +} + +#[post("/admin/update_user_permission", data = "<form>")] +pub fn r_admin_user_permission( + session: AdminSession, + database: &State<Database>, + form: Form<UserPermissionForm>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let perm = serde_json::from_str::<UserPermission>(&form.permission) + .context("parsing provided permission")?; + + database.update_user(&form.name, |user| { + match form.action { + GrantState::Grant => drop(user.permissions.0.insert(perm.clone(), true)), + GrantState::Revoke => drop(user.permissions.0.insert(perm.clone(), false)), + GrantState::Unset => drop(user.permissions.0.remove(&perm)), + } + Ok(()) + })?; + + manage_single_user( + database, + Some(Ok("Permissions update".into())), + form.name.clone(), + ) +} + +#[post("/admin/remove_user", data = "<form>")] +pub fn r_admin_remove_user( + session: AdminSession, + database: &State<Database>, + form: Form<DeleteUser>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + if !database.delete_user(&form.name)? { + Err(anyhow!("user did not exist"))?; + } + user_management(database, Some(Ok("User removed".into()))) +} |