aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes/ui/admin
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/routes/ui/admin')
-rw-r--r--server/src/routes/ui/admin/log.rs258
-rw-r--r--server/src/routes/ui/admin/mod.rs290
-rw-r--r--server/src/routes/ui/admin/user.rs176
3 files changed, 0 insertions, 724 deletions
diff --git a/server/src/routes/ui/admin/log.rs b/server/src/routes/ui/admin/log.rs
deleted file mode 100644
index fc85b37..0000000
--- a/server/src/routes/ui/admin/log.rs
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- 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::{
- routes::ui::{
- account::session::AdminSession,
- 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/routes/ui/admin/mod.rs b/server/src/routes/ui/admin/mod.rs
deleted file mode 100644
index f44b36c..0000000
--- a/server/src/routes/ui/admin/mod.rs
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
- 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::{
- account::session::AdminSession,
- assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED},
-};
-use crate::{
- database::Database,
- routes::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/routes/ui/admin/user.rs b/server/src/routes/ui/admin/user.rs
deleted file mode 100644
index 7ba6d4e..0000000
--- a/server/src/routes/ui/admin/user.rs
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- 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,
- routes::ui::{
- account::session::AdminSession,
- 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())))
-}