aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-29 15:19:36 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-29 15:19:36 +0200
commitf73aa32549743b2967160d38c1622199c41524a4 (patch)
tree0fa290fbf9b14d7bfd3803f8cc4618c6c9829330
parentf62c7f2a8cc143454779dc99334ca9fc80ddabd5 (diff)
downloadjellything-f73aa32549743b2967160d38c1622199c41524a4.tar
jellything-f73aa32549743b2967160d38c1622199c41524a4.tar.bz2
jellything-f73aa32549743b2967160d38c1622199c41524a4.tar.zst
aaaaaaa
-rw-r--r--Cargo.lock49
-rw-r--r--common/src/api.rs31
-rw-r--r--common/src/routes.rs21
-rw-r--r--logic/Cargo.toml2
-rw-r--r--logic/src/admin/log.rs129
-rw-r--r--logic/src/admin/mod.rs8
-rw-r--r--logic/src/admin/user.rs17
-rw-r--r--logic/src/lib.rs1
-rw-r--r--server/Cargo.toml1
-rw-r--r--server/src/compat/jellyfin/mod.rs85
-rw-r--r--server/src/compat/youtube.rs34
-rw-r--r--server/src/helper/mod.rs6
-rw-r--r--server/src/helper/node_id.rs17
-rw-r--r--server/src/helper/session.rs (renamed from server/src/logic/session.rs)6
-rw-r--r--server/src/logic/mod.rs2
-rw-r--r--server/src/logic/stream.rs6
-rw-r--r--server/src/logic/userdata.rs34
-rw-r--r--server/src/main.rs3
-rw-r--r--server/src/ui/account/mod.rs19
-rw-r--r--server/src/ui/account/settings.rs42
-rw-r--r--server/src/ui/admin/log.rs253
-rw-r--r--server/src/ui/admin/mod.rs283
-rw-r--r--server/src/ui/admin/user.rs140
-rw-r--r--ui/Cargo.toml1
-rw-r--r--ui/src/account/mod.rs27
-rw-r--r--ui/src/admin/log.rs117
-rw-r--r--ui/src/admin/mod.rs74
-rw-r--r--ui/src/admin/user.rs31
-rw-r--r--ui/src/lib.rs1
29 files changed, 838 insertions, 602 deletions
diff --git a/Cargo.lock b/Cargo.lock
index de6ac57..557e5ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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;