diff options
29 files changed, 838 insertions, 602 deletions
@@ -875,14 +875,14 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", "env_filter", - "humantime", + "jiff", "log", ] @@ -1343,12 +1343,6 @@ dependencies = [ ] [[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - -[[package]] name = "hyper" version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1855,11 +1849,13 @@ dependencies = [ "argon2", "base64", "bincode", + "env_logger", "jellybase", "jellycommon", "log", "rand 0.9.1", "serde", + "tokio", ] [[package]] @@ -1933,7 +1929,6 @@ dependencies = [ "serde_json", "tokio", "tokio-util", - "vte", ] [[package]] @@ -1990,6 +1985,31 @@ dependencies = [ "markup", "serde", "serde_json", + "vte", +] + +[[package]] +name = "jiff" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2665,6 +2685,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" [[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/common/src/api.rs b/common/src/api.rs index aaff940..c4751a3 100644 --- a/common/src/api.rs +++ b/common/src/api.rs @@ -3,7 +3,12 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{url_enum, user::NodeUserData, Node, NodeKind}; +use crate::{ + url_enum, + user::{NodeUserData, User}, + Node, NodeKind, +}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, sync::Arc, time::Duration}; @@ -43,6 +48,30 @@ pub struct ApiStatsResponse { pub total: StatsBin, } +#[derive(Serialize, Deserialize)] +pub struct ApiAdminUsersResponse { + pub users: Vec<User>, +} + +#[derive(Serialize, Deserialize)] +pub struct LogLine { + pub time: DateTime<Utc>, + pub module: Option<&'static str>, + pub level: LogLevel, + pub message: String, +} + +url_enum!( + #[derive(Serialize, Deserialize, Clone, Copy, PartialEq)] + enum LogLevel { + Trace = "trace", + Debug = "debug", + Info = "info", + Warn = "warn", + Error = "error", + } +); + #[derive(Default, Serialize, Deserialize)] pub struct StatsBin { pub runtime: f64, diff --git a/common/src/routes.rs b/common/src/routes.rs index 9472c85..437f469 100644 --- a/common/src/routes.rs +++ b/common/src/routes.rs @@ -65,6 +65,24 @@ pub fn u_admin_user_permission(name: &str) -> String { pub fn u_admin_user_remove(name: &str) -> String { format!("/admin/user/{name}/remove") } +pub fn u_admin_log(warn_only: bool) -> String { + format!("/admin/log?warn_only={warn_only}") +} +pub fn u_admin_invite_create() -> String { + format!("/admin/generate_invite") +} +pub fn u_admin_invite_remove() -> String { + format!("/admin/remove_invite") +} +pub fn u_admin_import(incremental: bool) -> String { + format!("/admin/import?incremental={incremental}") +} +pub fn u_admin_transcode_posters() -> String { + format!("/admin/transcode_posters") +} +pub fn u_admin_update_search() -> String { + format!("/admin/update_search") +} pub fn u_account_register() -> String { "/account/register".to_owned() } @@ -86,3 +104,6 @@ pub fn u_stats() -> String { pub fn u_search() -> String { "/search".to_owned() } +pub fn u_asset(token: &str, width: usize) -> String { + format!("/asset/{token}?width={width}") +} diff --git a/logic/Cargo.toml b/logic/Cargo.toml index bd8a28d..ec5ee2b 100644 --- a/logic/Cargo.toml +++ b/logic/Cargo.toml @@ -14,3 +14,5 @@ aes-gcm-siv = "0.11.1" serde = { version = "1.0.217", features = ["derive", "rc"] } bincode = { version = "2.0.0-rc.3", features = ["serde", "derive"] } rand = "0.9.0" +env_logger = "0.11.8" +tokio = { workspace = true } diff --git a/logic/src/admin/log.rs b/logic/src/admin/log.rs new file mode 100644 index 0000000..64d23ca --- /dev/null +++ b/logic/src/admin/log.rs @@ -0,0 +1,129 @@ +/* + 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 jellycommon::{ + api::{LogLevel, LogLine}, + chrono::Utc, +}; +use log::Level; +use std::{ + collections::VecDeque, + 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 fn get_log_buffer(warn: bool) -> VecDeque<Arc<LogLine>> { + if warn { + LOGGER.log.read().unwrap().1.clone() + } else { + LOGGER.log.read().unwrap().0.clone() + } +} +pub fn get_log_stream(warn: bool) -> broadcast::Receiver<Arc<LogLine>> { + if warn { + LOGGER.stream.0.subscribe() + } else { + LOGGER.stream.1.subscribe() + } +} + +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: match record.level() { + Level::Error => LogLevel::Error, + Level::Warn => LogLevel::Warn, + Level::Info => LogLevel::Info, + Level::Debug => LogLevel::Debug, + Level::Trace => LogLevel::Trace, + }, + 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(); + } +} diff --git a/logic/src/admin/mod.rs b/logic/src/admin/mod.rs new file mode 100644 index 0000000..270a732 --- /dev/null +++ b/logic/src/admin/mod.rs @@ -0,0 +1,8 @@ +/* + 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 user; +pub mod log; diff --git a/logic/src/admin/user.rs b/logic/src/admin/user.rs new file mode 100644 index 0000000..2d788cb --- /dev/null +++ b/logic/src/admin/user.rs @@ -0,0 +1,17 @@ +/* + 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::session::AdminSession; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::api::ApiAdminUsersResponse; + +pub fn admin_users(db: &Database, _session: &AdminSession) -> Result<ApiAdminUsersResponse> { + // TODO dont return useless info like passwords + Ok(ApiAdminUsersResponse { + users: db.list_users()?, + }) +} diff --git a/logic/src/lib.rs b/logic/src/lib.rs index a47afc3..1dba95d 100644 --- a/logic/src/lib.rs +++ b/logic/src/lib.rs @@ -12,3 +12,4 @@ pub mod search; pub mod session; pub mod stats; pub mod items; +pub mod admin; diff --git a/server/Cargo.toml b/server/Cargo.toml index b658e61..57d0c29 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,7 +23,6 @@ env_logger = "0.11.6" rand = "0.9.0" base64 = "0.22.1" chrono = { version = "0.4.39", features = ["serde"] } -vte = "0.14.1" chashmap = "2.2.2" async-recursion = "1.1.1" diff --git a/server/src/compat/jellyfin/mod.rs b/server/src/compat/jellyfin/mod.rs index 9d5c93e..20f8c7e 100644 --- a/server/src/compat/jellyfin/mod.rs +++ b/server/src/compat/jellyfin/mod.rs @@ -6,25 +6,22 @@ pub mod models; use crate::{ - logic::session::Session, - ui::{ - account::login_logic, - assets::{ - rocket_uri_macro_r_asset, rocket_uri_macro_r_item_backdrop, - rocket_uri_macro_r_item_poster, - }, - error::MyResult, - node::{aspect_class, DatabaseNodeUserDataExt}, - sort::{filter_and_sort_nodes, FilterProperty, NodeFilterSort, SortOrder, SortProperty}, - }, + helper::A, + ui::{account::login_logic, error::MyResult}, }; use anyhow::{anyhow, Context}; use jellybase::{database::Database, CONF}; use jellycommon::{ + api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty}, + routes::{u_asset, u_node_slug_backdrop, u_node_slug_poster}, stream::{StreamContainer, StreamSpec}, user::{NodeUserData, WatchedState}, MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, }; +use jellylogic::{ + filter_sort::filter_and_sort_nodes, node::DatabaseNodeUserDataExt, session::Session, +}; +use jellyui::node_page::aspect_class; use models::*; use rocket::{ get, @@ -86,7 +83,7 @@ pub fn r_jellyfin_quickconnect_enabled() -> Json<Value> { } #[get("/System/Endpoint")] -pub fn r_jellyfin_system_endpoint(_session: Session) -> Json<Value> { +pub fn r_jellyfin_system_endpoint(_session: A<Session>) -> Json<Value> { Json(json!({ "IsLocal": false, "IsInNetwork": false, @@ -95,7 +92,7 @@ pub fn r_jellyfin_system_endpoint(_session: Session) -> Json<Value> { use rocket_ws::{Message, Stream, WebSocket}; #[get("/socket")] -pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] { +pub fn r_jellyfin_socket(_session: A<Session>, ws: WebSocket) -> Stream!['static] { Stream! { ws => for await message in ws { eprintln!("{message:?}"); @@ -105,7 +102,7 @@ pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] { } #[get("/System/Info")] -pub fn r_jellyfin_system_info(_session: Session) -> Json<Value> { +pub fn r_jellyfin_system_info(_session: A<Session>) -> Json<Value> { Json(json!({ "OperatingSystemDisplayName": "", "HasPendingRestart": false, @@ -135,7 +132,7 @@ pub fn r_jellyfin_system_info(_session: Session) -> Json<Value> { } #[get("/DisplayPreferences/usersettings")] -pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json<Value> { +pub fn r_jellyfin_displaypreferences_usersettings(_session: A<Session>) -> Json<Value> { Json(json!({ "Id": "3ce5b65d-e116-d731-65d1-efc4a30ec35c", "SortBy": "SortName", @@ -153,53 +150,53 @@ pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json<Val } #[post("/DisplayPreferences/usersettings")] -pub fn r_jellyfin_displaypreferences_usersettings_post(_session: Session) {} +pub fn r_jellyfin_displaypreferences_usersettings_post(_session: A<Session>) {} #[get("/Users/<id>")] -pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json<Value> { +pub fn r_jellyfin_users_id(session: A<Session>, id: &str) -> Json<Value> { let _ = id; - Json(user_object(session.user.name)) + Json(user_object(session.0.user.name)) } #[get("/Items/<id>/Images/Primary?<fillWidth>&<tag>")] #[allow(non_snake_case)] pub fn r_jellyfin_items_image_primary( - _session: Session, + _session: A<Session>, id: &str, fillWidth: Option<usize>, tag: String, ) -> Redirect { if tag == "poster" { - Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) + Redirect::permanent(u_node_slug_poster(id, fillWidth.unwrap_or(1024))) } else { - Redirect::permanent(rocket::uri!(r_asset(tag, fillWidth))) + Redirect::permanent(u_asset(&tag, fillWidth.unwrap_or(1024))) } } #[get("/Items/<id>/Images/Backdrop/0?<maxWidth>")] #[allow(non_snake_case)] pub fn r_jellyfin_items_images_backdrop( - _session: Session, + _session: A<Session>, id: &str, maxWidth: Option<usize>, ) -> Redirect { - Redirect::permanent(rocket::uri!(r_item_backdrop(id, maxWidth))) + Redirect::permanent(u_node_slug_backdrop(id, maxWidth.unwrap_or(1024))) } #[get("/Items/<id>")] #[allow(private_interfaces)] pub fn r_jellyfin_items_item( - session: Session, + session: A<Session>, database: &State<Database>, id: &str, ) -> MyResult<Json<JellyfinItem>> { - let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; + let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session.0)?; Ok(Json(item_object(&n, &ud))) } #[get("/Users/<uid>/Items/<id>")] #[allow(private_interfaces)] pub fn r_jellyfin_users_items_item( - session: Session, + session: A<Session>, database: &State<Database>, uid: &str, id: &str, @@ -228,7 +225,7 @@ struct JellyfinItemQuery { #[get("/Users/<uid>/Items?<query..>")] #[allow(private_interfaces)] pub fn r_jellyfin_users_items( - session: Session, + session: A<Session>, database: &State<Database>, uid: &str, query: JellyfinItemQuery, @@ -240,7 +237,7 @@ pub fn r_jellyfin_users_items( #[get("/Artists?<query..>")] #[allow(private_interfaces)] pub fn r_jellyfin_artists( - session: Session, + session: A<Session>, database: &State<Database>, mut query: JellyfinItemQuery, ) -> MyResult<Json<Value>> { @@ -256,7 +253,7 @@ pub fn r_jellyfin_artists( #[get("/Persons?<query..>")] #[allow(private_interfaces)] pub fn r_jellyfin_persons( - session: Session, + session: A<Session>, database: &State<Database>, mut query: JellyfinItemQuery, ) -> MyResult<Json<Value>> { @@ -272,7 +269,7 @@ pub fn r_jellyfin_persons( #[get("/Items?<query..>")] #[allow(private_interfaces)] pub fn r_jellyfin_items( - session: Session, + session: A<Session>, database: &State<Database>, query: JellyfinItemQuery, ) -> MyResult<Json<Value>> { @@ -320,7 +317,7 @@ pub fn r_jellyfin_items( let mut nodes = nodes .into_iter() - .map(|nid| database.get_node_with_userdata(nid, &session)) + .map(|nid| database.get_node_with_userdata(nid, &session.0)) .collect::<Result<Vec<_>, anyhow::Error>>()?; filter_and_sort_nodes( @@ -352,7 +349,7 @@ pub fn r_jellyfin_items( #[get("/UserViews?<userId>")] #[allow(non_snake_case)] pub fn r_jellyfin_users_views( - session: Session, + session: A<Session>, database: &State<Database>, userId: &str, ) -> MyResult<Json<Value>> { @@ -362,7 +359,7 @@ pub fn r_jellyfin_users_views( .get_node_children(NodeID::from_slug("library")) .context("root node missing")? .into_iter() - .map(|nid| database.get_node_with_userdata(nid, &session)) + .map(|nid| database.get_node_with_userdata(nid, &session.0)) .collect::<Result<Vec<_>, anyhow::Error>>()?; toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); @@ -382,7 +379,7 @@ pub fn r_jellyfin_users_views( } #[get("/Items/<id>/Similar")] -pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json<Value> { +pub fn r_jellyfin_items_similar(_session: A<Session>, id: &str) -> Json<Value> { let _ = id; Json(json!({ "Items": [], @@ -392,7 +389,7 @@ pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json<Value> { } #[get("/LiveTv/Programs/Recommended")] -pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json<Value> { +pub fn r_jellyfin_livetv_programs_recommended(_session: A<Session>) -> Json<Value> { Json(json!({ "Items": [], "TotalRecordCount": 0, @@ -401,7 +398,7 @@ pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json<Value> } #[get("/Users/<uid>/Items/<id>/Intros")] -pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json<Value> { +pub fn r_jellyfin_items_intros(_session: A<Session>, uid: &str, id: &str) -> Json<Value> { let _ = (uid, id); Json(json!({ "Items": [], @@ -411,7 +408,7 @@ pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json<V } #[get("/Shows/NextUp")] -pub fn r_jellyfin_shows_nextup(_session: Session) -> Json<Value> { +pub fn r_jellyfin_shows_nextup(_session: A<Session>) -> Json<Value> { Json(json!({ "Items": [], "TotalRecordCount": 0, @@ -421,7 +418,7 @@ pub fn r_jellyfin_shows_nextup(_session: Session) -> Json<Value> { #[post("/Items/<id>/PlaybackInfo")] pub fn r_jellyfin_items_playbackinfo( - _session: Session, + _session: A<Session>, database: &State<Database>, id: &str, ) -> MyResult<Json<Value>> { @@ -438,7 +435,7 @@ pub fn r_jellyfin_items_playbackinfo( #[get("/Videos/<id>/stream.webm")] pub fn r_jellyfin_video_stream( - _session: Session, + _session: A<Session>, database: &State<Database>, id: &str, ) -> MyResult<Redirect> { @@ -463,14 +460,14 @@ struct JellyfinProgressData { #[post("/Sessions/Playing/Progress", data = "<data>")] #[allow(private_interfaces)] pub fn r_jellyfin_sessions_playing_progress( - session: Session, + session: A<Session>, database: &State<Database>, data: Json<JellyfinProgressData>, ) -> MyResult<()> { let position = data.position_ticks / 10_000_000.; database.update_node_udata( NodeID::from_slug(&data.item_id), - &session.user.name, + &session.0.user.name, |udata| { udata.watched = match udata.watched { WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => { @@ -485,16 +482,16 @@ pub fn r_jellyfin_sessions_playing_progress( } #[post("/Sessions/Playing")] -pub fn r_jellyfin_sessions_playing(_session: Session) {} +pub fn r_jellyfin_sessions_playing(_session: A<Session>) {} #[get("/Playback/BitrateTest?<Size>")] #[allow(non_snake_case)] -pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec<u8> { +pub fn r_jellyfin_playback_bitratetest(_session: A<Session>, Size: usize) -> Vec<u8> { vec![0; Size.min(1_000_000)] } #[post("/Sessions/Capabilities/Full")] -pub fn r_jellyfin_sessions_capabilities_full(_session: Session) {} +pub fn r_jellyfin_sessions_capabilities_full(_session: A<Session>) {} #[derive(Deserialize)] #[serde(rename_all = "PascalCase")] diff --git a/server/src/compat/youtube.rs b/server/src/compat/youtube.rs index 1df2751..67a34fc 100644 --- a/server/src/compat/youtube.rs +++ b/server/src/compat/youtube.rs @@ -3,21 +3,15 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{ - logic::session::Session, - ui::{ - error::MyResult, - node::rocket_uri_macro_r_library_node, - player::{rocket_uri_macro_r_player, PlayerConfig}, - }, -}; +use crate::{helper::A, ui::error::MyResult}; use anyhow::anyhow; use jellybase::database::Database; -use jellycommon::NodeID; +use jellycommon::routes::{u_node_slug, u_node_slug_player}; +use jellylogic::session::Session; use rocket::{get, response::Redirect, State}; #[get("/watch?<v>")] -pub fn r_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> { +pub fn r_youtube_watch(_session: A<Session>, db: &State<Database>, v: &str) -> MyResult<Redirect> { if v.len() != 11 { Err(anyhow!("video id length incorrect"))? } @@ -25,14 +19,15 @@ pub fn r_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyRe Err(anyhow!("element not found"))? }; let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; - Ok(Redirect::to(rocket::uri!(r_player( - &node.slug, - PlayerConfig::default() - )))) + Ok(Redirect::to(u_node_slug_player(&node.slug))) } #[get("/channel/<id>")] -pub fn r_youtube_channel(_session: Session, db: &State<Database>, id: &str) -> MyResult<Redirect> { +pub fn r_youtube_channel( + _session: A<Session>, + db: &State<Database>, + id: &str, +) -> MyResult<Redirect> { let Some(id) = (if id.starts_with("UC") { db.get_node_external_id("youtube:channel", id)? } else if id.starts_with("@") { @@ -43,11 +38,11 @@ pub fn r_youtube_channel(_session: Session, db: &State<Database>, id: &str) -> M Err(anyhow!("channel not found"))? }; let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; - Ok(Redirect::to(rocket::uri!(r_library_node(&node.slug)))) + Ok(Redirect::to(u_node_slug(&node.slug))) } #[get("/embed/<v>")] -pub fn r_youtube_embed(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> { +pub fn r_youtube_embed(_session: A<Session>, db: &State<Database>, v: &str) -> MyResult<Redirect> { if v.len() != 11 { Err(anyhow!("video id length incorrect"))? } @@ -55,8 +50,5 @@ pub fn r_youtube_embed(_session: Session, db: &State<Database>, v: &str) -> MyRe Err(anyhow!("element not found"))? }; let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; - Ok(Redirect::to(rocket::uri!(r_player( - &node.slug, - PlayerConfig::default() - )))) + Ok(Redirect::to(u_node_slug_player(&node.slug))) } diff --git a/server/src/helper/mod.rs b/server/src/helper/mod.rs index 856f6b7..125b159 100644 --- a/server/src/helper/mod.rs +++ b/server/src/helper/mod.rs @@ -3,5 +3,9 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -pub mod cors; pub mod cache; +pub mod cors; +pub mod session; +pub mod node_id; + +pub struct A<T>(pub T); diff --git a/server/src/helper/node_id.rs b/server/src/helper/node_id.rs new file mode 100644 index 0000000..f891d62 --- /dev/null +++ b/server/src/helper/node_id.rs @@ -0,0 +1,17 @@ +/* + 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 super::A; +use jellycommon::NodeID; +use rocket::request::FromParam; +use std::str::FromStr; + +impl<'a> FromParam<'a> for A<NodeID> { + type Error = (); + fn from_param(param: &'a str) -> Result<Self, Self::Error> { + NodeID::from_str(param).map_err(|_| ()).map(A) + } +} diff --git a/server/src/logic/session.rs b/server/src/helper/session.rs index 105aa10..b77f9fa 100644 --- a/server/src/logic/session.rs +++ b/server/src/helper/session.rs @@ -16,7 +16,7 @@ use rocket::{ Request, State, }; -pub struct A<T>(pub T); +use super::A; impl A<Session> { async fn from_request_ut(req: &Request<'_>) -> Result<Self, MyError> { @@ -73,7 +73,7 @@ impl<'r> FromRequest<'r> for A<Session> { async fn from_request<'life0>( request: &'r Request<'life0>, ) -> request::Outcome<Self, Self::Error> { - match Session::from_request_ut(request).await { + match A::<Session>::from_request_ut(request).await { Ok(x) => Outcome::Success(x), Err(e) => { warn!("authentificated route rejected: {e:?}"); @@ -91,7 +91,7 @@ impl<'r> FromRequest<'r> for A<AdminSession> { ) -> request::Outcome<Self, Self::Error> { match A::<Session>::from_request_ut(request).await { Ok(x) => { - if x.user.admin { + if x.0.user.admin { Outcome::Success(A(AdminSession(x.0))) } else { Outcome::Error(( diff --git a/server/src/logic/mod.rs b/server/src/logic/mod.rs index 745d11b..26f45de 100644 --- a/server/src/logic/mod.rs +++ b/server/src/logic/mod.rs @@ -4,6 +4,6 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ pub mod playersync; -pub mod session; pub mod stream; pub mod userdata; + diff --git a/server/src/logic/stream.rs b/server/src/logic/stream.rs index f9cdb41..9d4db6d 100644 --- a/server/src/logic/stream.rs +++ b/server/src/logic/stream.rs @@ -3,7 +3,7 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{database::Database, ui::error::MyError}; +use crate::{database::Database, helper::A, ui::error::MyError}; use anyhow::{anyhow, Result}; use jellybase::{assetfed::AssetInner, federation::Federation}; use jellycommon::{stream::StreamSpec, TrackSource}; @@ -26,7 +26,7 @@ use tokio::io::{duplex, DuplexStream}; #[head("/n/<_id>/stream?<spec..>")] pub async fn r_stream_head( - _sess: Session, + _sess: A<Session>, _id: &str, spec: BTreeMap<String, String>, ) -> Result<Either<StreamResponse, Redirect>, MyError> { @@ -42,7 +42,7 @@ pub async fn r_stream_head( #[get("/n/<id>/stream?<spec..>")] pub async fn r_stream( - _session: Session, + _session: A<Session>, _federation: &State<Federation>, db: &State<Database>, id: &str, diff --git a/server/src/logic/userdata.rs b/server/src/logic/userdata.rs index 8da6be9..25d3893 100644 --- a/server/src/logic/userdata.rs +++ b/server/src/logic/userdata.rs @@ -3,10 +3,12 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::ui::error::MyResult; +use crate::{helper::A, ui::error::MyResult}; use jellybase::database::Database; use jellycommon::{ - routes::u_node_id, user::{NodeUserData, WatchedState}, NodeID + routes::u_node_id, + user::{NodeUserData, WatchedState}, + NodeID, }; use jellylogic::session::Session; use rocket::{ @@ -23,25 +25,25 @@ pub enum UrlWatchedState { #[get("/n/<id>/userdata")] pub fn r_node_userdata( - session: Session, + session: A<Session>, db: &State<Database>, - id: NodeID, + id: A<NodeID>, ) -> MyResult<Json<NodeUserData>> { let u = db - .get_node_udata(id, &session.user.name)? + .get_node_udata(id.0, &session.0.user.name)? .unwrap_or_default(); Ok(Json(u)) } #[post("/n/<id>/watched?<state>")] pub async fn r_node_userdata_watched( - session: Session, + session: A<Session>, db: &State<Database>, - id: NodeID, + id: A<NodeID>, state: UrlWatchedState, ) -> MyResult<Redirect> { // TODO perm - db.update_node_udata(id, &session.user.name, |udata| { + db.update_node_udata(id.0, &session.0.user.name, |udata| { udata.watched = match state { UrlWatchedState::None => WatchedState::None, UrlWatchedState::Watched => WatchedState::Watched, @@ -49,7 +51,7 @@ pub async fn r_node_userdata_watched( }; Ok(()) })?; - Ok(Redirect::found(u_node_id(id))) + Ok(Redirect::found(u_node_id(id.0))) } #[derive(FromForm)] @@ -60,28 +62,28 @@ pub struct UpdateRating { #[post("/n/<id>/update_rating", data = "<form>")] pub async fn r_node_userdata_rating( - session: Session, + session: A<Session>, db: &State<Database>, - id: NodeID, + id: A<NodeID>, form: Form<UpdateRating>, ) -> MyResult<Redirect> { // TODO perm - db.update_node_udata(id, &session.user.name, |udata| { + db.update_node_udata(id.0, &session.0.user.name, |udata| { udata.rating = form.rating; Ok(()) })?; - Ok(Redirect::found(u_node_id(id))) + Ok(Redirect::found(u_node_id(id.0))) } #[post("/n/<id>/progress?<t>")] pub async fn r_node_userdata_progress( - session: Session, + session: A<Session>, db: &State<Database>, - id: NodeID, + id: A<NodeID>, t: f64, ) -> MyResult<()> { // TODO perm - db.update_node_udata(id, &session.user.name, |udata| { + db.update_node_udata(id.0, &session.0.user.name, |udata| { udata.watched = match udata.watched { WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => { WatchedState::Progress(t) diff --git a/server/src/main.rs b/server/src/main.rs index b583823..6634143 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -10,10 +10,11 @@ use anyhow::Context; use database::Database; use jellybase::{federation::Federation, CONF, SECRETS}; +use jellylogic::admin::log::enable_logging; use log::{error, info, warn}; use routes::build_rocket; use tokio::fs::create_dir_all; -use ui::{account::hash_password, admin::log::enable_logging}; +use ui::account::hash_password; pub use jellybase::database; pub mod api; diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs index c1e5479..54fa4d0 100644 --- a/server/src/ui/account/mod.rs +++ b/server/src/ui/account/mod.rs @@ -9,12 +9,9 @@ use super::error::MyError; use crate::{ database::Database, locale::AcceptLanguage, - logic::session::{self, Session}, ui::{error::MyResult, home::rocket_uri_macro_r_home}, - uri, }; use anyhow::anyhow; -use argon2::{password_hash::Salt, Argon2, PasswordHasher}; use chrono::Duration; use jellycommon::user::{User, UserPermission}; use rocket::{ @@ -44,21 +41,7 @@ pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> LayoutPage { title: tr(lang, "account.register").to_string(), content: markup::new! { - form.account[method="POST", action=""] { - h1 { @trs(&lang, "account.register") } - - label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") } - input[type="text", id="inp-invitation", name="invitation"]; br; - - label[for="inp-username"] { @trs(&lang, "account.username") } - input[type="text", id="inp-username", name="username"]; br; - label[for="inp-password"] { @trs(&lang, "account.password") } - input[type="password", id="inp-password", name="password"]; br; - - input[type="submit", value=&*tr(lang, "account.register.submit")]; - - p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } } - } + }, ..Default::default() } diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs index 3b53bc4..e6d096f 100644 --- a/server/src/ui/account/settings.rs +++ b/server/src/ui/account/settings.rs @@ -4,27 +4,18 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ use super::{format_form_error, hash_password}; -use crate::{ - database::Database, - locale::AcceptLanguage, - ui::{ - account::{rocket_uri_macro_r_account_login, session::Session}, - error::MyResult, - layout::{trs, DynLayoutPage, LayoutPage}, - }, - uri, -}; -use jellybase::{ - locale::{tr, Language}, - permission::PermissionSetExt, -}; +use crate::{database::Database, locale::AcceptLanguage, ui::error::MyResult}; +use jellybase::permission::PermissionSetExt; use jellycommon::user::{PlayerKind, Theme, UserPermission}; -use markup::{Render, RenderAttributeValue}; +use jellylogic::session::Session; +use jellyui::locale::Language; use rocket::{ form::{self, validate::len, Contextual, Form}, get, http::uri::fmt::{Query, UriDisplay}, - post, FromForm, State, + post, + response::content::RawHtml, + FromForm, State, }; use std::ops::Range; @@ -47,24 +38,11 @@ fn settings_page( session: Session, flash: Option<MyResult<String>>, lang: Language, -) -> DynLayoutPage<'static> { - LayoutPage { - title: "Settings".to_string(), - class: Some("settings"), - content: - } -} - -struct A<T>(pub T); -impl<T: UriDisplay<Query>> RenderAttributeValue for A<T> {} -impl<T: UriDisplay<Query>> Render for A<T> { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay<Query>)) - } +) -> RawHtml<String> { } #[get("/account/settings")] -pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> { +pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> RawHtml<String> { let AcceptLanguage(lang) = lang; settings_page(session, None, lang) } @@ -75,7 +53,7 @@ pub fn r_account_settings_post( database: &State<Database>, form: Form<Contextual<SettingsForm>>, lang: AcceptLanguage, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<RawHtml<String>> { let AcceptLanguage(lang) = lang; session .user diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs index dff6d1b..bd6d7af 100644 --- a/server/src/ui/admin/log.rs +++ b/server/src/ui/admin/log.rs @@ -3,256 +3,35 @@ 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 crate::ui::error::MyResult; +use jellylogic::{admin::log::get_log_stream, session::AdminSession}; +use jellyui::admin::log::render_log_line; +use rocket::{get, response::content::RawHtml}; 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)) } - } - }} - } - }, - }) -} +pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult<RawHtml<String>> {} -#[get("/admin/log?stream&<warnonly>", rank = 1)] +#[get("/admin/log?stream&<warnonly>&<html>", rank = 1)] pub fn r_admin_log_stream( _session: AdminSession, ws: WebSocket, warnonly: bool, + html: bool, ) -> Stream!['static] { - let mut stream = if warnonly { - LOGGER.stream.1.subscribe() - } else { - LOGGER.stream.0.subscribe() - }; + let mut stream = get_log_stream(warnonly); 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, + if html { + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(render_log_line(&line)); } - } - 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(); + } else { + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(json!(line).to_string()); } } } } - -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 index d380ae2..b155121 100644 --- a/server/src/ui/admin/mod.rs +++ b/server/src/ui/admin/mod.rs @@ -10,108 +10,74 @@ use super::{ assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}, error::MyResult, }; -use crate::{database::Database, logic::session::AdminSession}; +use crate::{database::Database, locale::AcceptLanguage}; use anyhow::{anyhow, Context}; use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; -use jellyimport::{import_wrap, IMPORT_ERRORS}; +use jellycommon::routes::u_admin_dashboard; +use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; +use jellylogic::session::AdminSession; +use jellyui::{ + admin::AdminDashboardPage, + render_page, + scaffold::{RenderInfo, SessionInfo}, +}; use rand::Rng; -use rocket::{form::Form, get, post, FromForm, State}; +use rocket::{ + form::Form, + get, post, + response::{content::RawHtml, Redirect}, + FromForm, State, +}; use std::time::Instant; use tokio::{sync::Semaphore, task::spawn_blocking}; #[get("/admin/dashboard")] pub async fn r_admin_dashboard( - _session: AdminSession, + 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>> { + lang: AcceptLanguage, +) -> MyResult<RawHtml<String>> { + let AcceptLanguage(lang) = lang; let invites = database.list_invites()?; - let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + let flash = None; 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"]; - } - } - }} + let busy = if is_importing() { + Some("An import is currently running.") + } else if is_transcoding() { + Some("Currently transcoding posters.") + } else { + None + }; - h2 { "Database" } - @match db_stats(&database) { - Ok(s) => { @s } - Err(e) => { pre.error { @format!("{e:?}") } } - } + Ok(RawHtml(render_page( + &AdminDashboardPage { + busy, + last_import_err: &last_import_err, + invites: &invites, + flash, + lang: &lang, }, - ..Default::default() - }) + RenderInfo { + importing: is_importing(), + session: Some(SessionInfo { + user: session.0.user, + }), + }, + lang, + ))) } #[post("/admin/generate_invite")] pub async fn r_admin_invite( _session: AdminSession, database: &State<Database>, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<Redirect> { let i = format!("{}", rand::rng().random::<u128>()); database.create_invite(&i)?; - admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await + // admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await + Ok(Redirect::temporary(u_admin_dashboard())) } #[derive(FromForm)] @@ -124,12 +90,13 @@ pub async fn r_admin_remove_invite( session: AdminSession, database: &State<Database>, form: Form<DeleteInvite>, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<Redirect> { drop(session); if !database.delete_invite(&form.invite)? { Err(anyhow!("invite does not exist"))?; }; - admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await + // admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await + Ok(Redirect::temporary(u_admin_dashboard())) } #[post("/admin/import?<incremental>")] @@ -138,55 +105,58 @@ pub async fn r_admin_import( database: &State<Database>, _federation: &State<Federation>, incremental: bool, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<Redirect> { 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 + // let flash = r + // .map_err(|e| e.into()) + // .map(|_| format!("Import successful; took {:?}", t.elapsed())); + // admin_dashboard(database, Some(flash)).await + Ok(Redirect::temporary(u_admin_dashboard())) } #[post("/admin/update_search")] pub async fn r_admin_update_search( _session: AdminSession, database: &State<Database>, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<Redirect> { 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 + // admin_dashboard( + // database, + // Some( + // r.map_err(|e| e.into()) + // .map(|_| "Search index updated".to_string()), + // ), + // ) + // .await + Ok(Redirect::temporary(u_admin_dashboard())) } #[post("/admin/delete_cache")] pub async fn r_admin_delete_cache( session: AdminSession, database: &State<Database>, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<Redirect> { 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 + // admin_dashboard( + // database, + // Some( + // r.map_err(|e| e.into()) + // .map(|_| format!("Cache deleted; took {:?}", t.elapsed())), + // ), + // ) + // .await + Ok(Redirect::temporary(u_admin_dashboard())) } static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1); @@ -198,7 +168,7 @@ fn is_transcoding() -> bool { pub async fn r_admin_transcode_posters( session: AdminSession, database: &State<Database>, -) -> MyResult<DynLayoutPage<'static>> { +) -> MyResult<Redirect> { drop(session); let _permit = SEM_TRANSCODING .try_acquire() @@ -223,58 +193,59 @@ pub async fn r_admin_transcode_posters( } drop(_permit); - admin_dashboard( - database, - Some(Ok(format!( - "All posters pre-transcoded; took {:?}", - t.elapsed() - ))), - ) - .await + // admin_dashboard( + // database, + // Some(Ok(format!( + // "All posters pre-transcoded; took {:?}", + // t.elapsed() + // ))), + // ) + // .await + Ok(Redirect::temporary(u_admin_dashboard())) } -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()?), - // ]; +// 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()?; +// // 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 } - // } - }) -} +// 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 index 1af83d4..77bcc71 100644 --- a/server/src/ui/admin/user.rs +++ b/server/src/ui/admin/user.rs @@ -3,68 +3,68 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{database::Database, ui::error::MyResult}; +use crate::{database::Database, locale::AcceptLanguage, ui::error::MyResult}; use anyhow::{anyhow, Context}; use jellycommon::user::UserPermission; -use jellylogic::session::AdminSession; -use rocket::{form::Form, get, post, FromForm, FromFormField, State}; +use jellyimport::is_importing; +use jellylogic::{admin::user::admin_users, session::AdminSession}; +use jellyui::{ + admin::user::{AdminUserPage, AdminUsersPage}, + render_page, + scaffold::{RenderInfo, SessionInfo}, +}; +use rocket::{form::Form, get, post, response::content::RawHtml, FromForm, FromFormField, State}; #[get("/admin/users")] pub fn r_admin_users( - _session: AdminSession, + 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 ")" } - } - }} + lang: AcceptLanguage, +) -> MyResult<RawHtml<String>> { + let AcceptLanguage(lang) = lang; + let r = admin_users(database, &session)?; + Ok(RawHtml(render_page( + &AdminUsersPage { + flash: None, + lang: &lang, + users: &r.users, + }, + RenderInfo { + importing: is_importing(), + session: Some(SessionInfo { + user: session.0.user, + }), }, - ..Default::default() - }) + lang, + ))) } #[get("/admin/user/<name>")] pub fn r_admin_user<'a>( - _session: AdminSession, + 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>> { + lang: AcceptLanguage, +) -> MyResult<RawHtml<String>> { + let AcceptLanguage(lang) = lang; 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! {}, - ..Default::default() - }) + Ok(RawHtml(render_page( + &AdminUserPage { + flash: None, + lang: &lang, + user: &user, + }, + RenderInfo { + importing: is_importing(), + session: Some(SessionInfo { + user: session.0.user, + }), + }, + lang, + ))) } #[derive(FromForm)] @@ -86,8 +86,9 @@ pub fn r_admin_user_permission( database: &State<Database>, form: Form<UserPermissionForm>, name: &str, -) -> MyResult<DynLayoutPage<'static>> { - drop(session); + lang: AcceptLanguage, +) -> MyResult<RawHtml<String>> { + let AcceptLanguage(lang) = lang; let perm = serde_json::from_str::<UserPermission>(&form.permission) .context("parsing provided permission")?; @@ -100,11 +101,24 @@ pub fn r_admin_user_permission( Ok(()) })?; - manage_single_user( - database, - Some(Ok("Permissions update".into())), - form.name.clone(), - ) + let user = database + .get_user(&name)? + .ok_or(anyhow!("user does not exist"))?; + + Ok(RawHtml(render_page( + &AdminUserPage { + flash: Some(Ok("Permissions updated".to_string())), + lang: &lang, + user: &user, + }, + RenderInfo { + importing: is_importing(), + session: Some(SessionInfo { + user: session.0.user, + }), + }, + lang, + ))) } #[post("/admin/<name>/remove")] @@ -112,10 +126,26 @@ pub fn r_admin_remove_user( session: AdminSession, database: &State<Database>, name: &str, -) -> MyResult<DynLayoutPage<'static>> { - drop(session); + lang: AcceptLanguage, +) -> MyResult<RawHtml<String>> { + let AcceptLanguage(lang) = lang; if !database.delete_user(&name)? { Err(anyhow!("user did not exist"))?; } - user_management(database, Some(Ok("User removed".into()))) + let r = admin_users(database, &session)?; + + Ok(RawHtml(render_page( + &AdminUsersPage { + flash: Some(Ok("User removed".to_string())), + lang: &lang, + users: &r.users, + }, + RenderInfo { + importing: is_importing(), + session: Some(SessionInfo { + user: session.0.user, + }), + }, + lang, + ))) } diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 3868b1f..0a2fb5a 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -9,3 +9,4 @@ jellycommon = { path = "../common" } humansize = "2.1.3" serde = { version = "1.0.217", features = ["derive", "rc"] } serde_json = "1.0.140" +vte = "0.14.1" diff --git a/ui/src/account/mod.rs b/ui/src/account/mod.rs new file mode 100644 index 0000000..bc8d3ce --- /dev/null +++ b/ui/src/account/mod.rs @@ -0,0 +1,27 @@ +/* + 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::locale::{Language, tr, trs}; +use jellycommon::routes::u_account_login; + +markup::define! { + AccountRegister<'a>(lang: &'a Language) { + form.account[method="POST", action=""] { + h1 { @trs(&lang, "account.register") } + + label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") } + input[type="text", id="inp-invitation", name="invitation"]; br; + + label[for="inp-username"] { @trs(&lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @trs(&lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=&*tr(**lang, "account.register.submit")]; + + p { @trs(&lang, "account.register.login") " " a[href=u_account_login()] { @trs(&lang, "account.register.login_here") } } + } + } +} diff --git a/ui/src/admin/log.rs b/ui/src/admin/log.rs new file mode 100644 index 0000000..a69bdfa --- /dev/null +++ b/ui/src/admin/log.rs @@ -0,0 +1,117 @@ +/* + 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 jellycommon::{ + api::{LogLevel, LogLine}, + routes::u_admin_log, +}; +use markup::raw; +use std::fmt::Write; + +markup::define! { + ServerLogPage<'a>(warnonly: bool, messages: &'a [String]) { + h1 { "Server Log" } + a[href=u_admin_log(!warnonly)] { @if *warnonly { "Show everything" } else { "Show only warnings" }} + code.log[id="log"] { + table { @for e in *messages { + @raw(e) + }} + } + } + ServerLogLine<'a>(e: &'a LogLine) { + 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)) } + } + } +} + +pub fn render_log_line(line: &LogLine) -> String { + ServerLogLine { e: line }.to_string() +} + +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: LogLevel) -> impl markup::Render { + let (s, c) = match level { + LogLevel::Debug => ("DEBUG", "blue"), + LogLevel::Error => ("ERROR", "red"), + LogLevel::Warn => ("WARN", "yellow"), + LogLevel::Info => ("INFO", "green"), + LogLevel::Trace => ("TRACE", "lightblue"), + }; + markup::new! { span[style=format!("color:{c}")] {@s} } +} + +#[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/ui/src/admin/mod.rs b/ui/src/admin/mod.rs index 292e445..74a5e1a 100644 --- a/ui/src/admin/mod.rs +++ b/ui/src/admin/mod.rs @@ -5,3 +5,77 @@ */ pub mod user; +pub mod log; + +use crate::{Page, locale::Language, scaffold::FlashDisplay}; +use jellycommon::routes::{ + u_admin_import, u_admin_invite_create, u_admin_invite_remove, u_admin_log, + u_admin_transcode_posters, u_admin_update_search, u_admin_users, +}; + +impl Page for AdminDashboardPage<'_> { + fn title(&self) -> String { + "Admin Dashboard".to_string() + } + fn to_render(&self) -> markup::DynRender { + markup::new!(@self) + } +} + +markup::define!( + AdminDashboardPage<'a>(lang: &'a Language, busy: Option<&'static str>, last_import_err: &'a [String], flash: Option<Result<String, String>>, invites: &'a [String]) { + 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=u_admin_log(true)] { "Server Log (Warnings only)" }} + li{a[href=u_admin_log(false)] { "Server Log (Full) " }} + } + h2 { "Library" } + @if let Some(text) = busy { + section.message { p.warn { @text } } + } + form[method="POST", action=u_admin_import(true)] { + input[type="submit", disabled=busy.is_some(), value="Start incremental import"]; + } + form[method="POST", action=u_admin_import(false)] { + input[type="submit", disabled=busy.is_some(), value="Start full import"]; + } + form[method="POST", action=u_admin_transcode_posters()] { + input[type="submit", disabled=busy.is_some(), value="Transcode all posters with low resolution"]; + } + form[method="POST", action=u_admin_update_search()] { + input[type="submit", value="Regenerate full-text search index"]; + } + h2 { "Users" } + p { a[href=u_admin_users()] "Manage Users" } + h2 { "Invitations" } + form[method="POST", action=u_admin_invite_create()] { + input[type="submit", value="Generate new invite code"]; + } + ul { @for t in *invites { + li { + form[method="POST", action=u_admin_invite_remove()] { + 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:?}") } } + // } + } +); diff --git a/ui/src/admin/user.rs b/ui/src/admin/user.rs index 9878803..613fc08 100644 --- a/ui/src/admin/user.rs +++ b/ui/src/admin/user.rs @@ -4,13 +4,40 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{locale::Language, scaffold::FlashDisplay}; +use crate::{Page, locale::Language, scaffold::FlashDisplay}; use jellycommon::{ - routes::{u_admin_user_permission, u_admin_user_remove, u_admin_users}, + routes::{u_admin_user, u_admin_user_permission, u_admin_user_remove, u_admin_users}, user::{PermissionSet, User, UserPermission}, }; +impl Page for AdminUserPage<'_> { + fn title(&self) -> String { + "User Management".to_string() + } + fn to_render(&self) -> markup::DynRender { + markup::new!(@self) + } +} +impl Page for AdminUsersPage<'_> { + fn title(&self) -> String { + "User Management".to_string() + } + fn to_render(&self) -> markup::DynRender { + markup::new!(@self) + } +} + markup::define! { + AdminUsersPage<'a>(lang: &'a Language, users: &'a [User], flash: Option<Result<String, String>>) { + h1 { "User Management" } + @FlashDisplay { flash: flash.clone() } + h2 { "All Users" } + ul { @for u in *users { + li { + a[href=u_admin_user(&u.name)] { @format!("{:?}", u.display_name) " (" @u.name ")" } + } + }} + } AdminUserPage<'a>(lang: &'a Language, user: &'a User, flash: Option<Result<String, String>>) { h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } a[href=u_admin_users()] "Back to the User List" diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 2521054..8a4b950 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -16,6 +16,7 @@ pub mod settings; pub mod stats; pub mod items; pub mod admin; +pub mod account; use locale::Language; use markup::DynRender; |