diff options
author | metamuffin <metamuffin@disroot.org> | 2025-04-28 18:27:03 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-04-28 18:27:03 +0200 |
commit | 51761cbdefa39107b9e1f931f1aa8df6aebb2a94 (patch) | |
tree | 957ca180786ece777e6e1153ada91da741d845ec | |
parent | 80d28b764c95891551e28c395783f5ff9d065743 (diff) | |
download | jellything-51761cbdefa39107b9e1f931f1aa8df6aebb2a94.tar jellything-51761cbdefa39107b9e1f931f1aa8df6aebb2a94.tar.bz2 jellything-51761cbdefa39107b9e1f931f1aa8df6aebb2a94.tar.zst |
many much more generic refactor
35 files changed, 654 insertions, 404 deletions
@@ -342,9 +342,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" [[package]] name = "binascii" @@ -1807,7 +1807,6 @@ dependencies = [ "blake3", "chrono", "hex", - "rocket", "serde", ] @@ -1848,6 +1847,22 @@ dependencies = [ ] [[package]] +name = "jellylogic" +version = "0.1.0" +dependencies = [ + "aes-gcm-siv", + "anyhow", + "argon2", + "base64", + "bincode", + "jellybase", + "jellycommon", + "log", + "rand 0.9.1", + "serde", +] + +[[package]] name = "jellymatroska" version = "0.1.0" dependencies = [ @@ -1893,9 +1908,7 @@ dependencies = [ name = "jellything" version = "0.1.1" dependencies = [ - "aes-gcm-siv", "anyhow", - "argon2", "async-recursion", "base64", "bincode", @@ -1908,8 +1921,10 @@ dependencies = [ "jellycache", "jellycommon", "jellyimport", + "jellylogic", "jellystream", "jellytranscoder", + "jellyui", "log", "rand 0.9.1", "rocket", @@ -2108,16 +2123,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] -name = "logic" -version = "0.1.0" -dependencies = [ - "anyhow", - "jellybase", - "jellycommon", - "log", -] - -[[package]] name = "loom" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/base/Cargo.toml b/base/Cargo.toml index 0ab4a4b..e19caff 100644 --- a/base/Cargo.toml +++ b/base/Cargo.toml @@ -24,4 +24,3 @@ humansize = "2.1.3" [features] db_json = [] -rocket = ["jellycommon/rocket"] diff --git a/common/Cargo.toml b/common/Cargo.toml index 775cd2a..90372bc 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -6,10 +6,6 @@ edition = "2021" [dependencies] serde = { version = "1.0.217", features = ["derive", "rc"] } bincode = { version = "2.0.0-rc.3", features = ["derive"] } -rocket = { workspace = true, optional = true } chrono = { version = "0.4.39", features = ["serde"] } blake3 = "1.5.5" hex = "0.4.3" - -[features] -rocket = ["dep:rocket"] diff --git a/common/src/api.rs b/common/src/api.rs index 6982e76..a58c445 100644 --- a/common/src/api.rs +++ b/common/src/api.rs @@ -3,11 +3,9 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{user::NodeUserData, Node}; -#[cfg(feature = "rocket")] -use rocket::{FromForm, FromFormField, UriDisplayQuery}; +use crate::{user::NodeUserData, Node, NodeKind}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; type NodesWithUdata = Vec<(Arc<Node>, NodeUserData)>; @@ -23,6 +21,7 @@ pub struct ApiNodeResponse { pub struct ApiSearchResponse { pub count: usize, pub results: NodesWithUdata, + pub duration: Duration, } #[derive(Serialize, Deserialize)] @@ -38,6 +37,21 @@ pub struct ApiHomeResponse { pub categories: Vec<(String, NodesWithUdata)>, } +#[derive(Serialize, Deserialize)] +pub struct ApiStatsResponse { + pub kinds: BTreeMap<NodeKind, StatsBin>, + pub total: StatsBin, +} + +#[derive(Default, Serialize, Deserialize)] +pub struct StatsBin { + pub runtime: f64, + pub size: u64, + pub count: usize, + pub max_runtime: (f64, String), + pub max_size: (u64, String), +} + #[derive(Debug, Default, Clone)] #[cfg_attr(feature = "rocket", derive(FromForm, UriDisplayQuery))] pub struct NodeFilterSort { @@ -46,24 +60,25 @@ pub struct NodeFilterSort { pub sort_order: Option<SortOrder>, } -#[rustfmt::skip] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "rocket", derive(FromFormField, UriDisplayQuery))] -pub enum SortOrder { - #[field(value = "ascending")] Ascending, - #[field(value = "descending")] Descending, -} -macro_rules! form_enum { +pub trait UrlEnum: Sized { + fn to_str(&self) -> &'static str; + fn from_str(s: &str) -> Option<Self>; +} +macro_rules! url_enum { (enum $i:ident { $($vi:ident = $vk:literal),*, }) => { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "rocket", derive(FromFormField, UriDisplayQuery))] pub enum $i { $(#[cfg_attr(feature = "rocket", field(value = $vk))] $vi),* } - impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; } + impl $i { pub const ALL: &'static [$i] = &[$($i::$vi),*]; } + impl UrlEnum for $i { + fn to_str(&self) -> &'static str { match self { $(Self::$vi => $vk),* } } + fn from_str(s: &str) -> Option<Self> { match s { $($vk => Some(Self::$vi) ),*, _ => None } } + } }; } -form_enum!( +url_enum!( enum FilterProperty { FederationLocal = "fed_local", FederationRemote = "fed_remote", @@ -82,8 +97,7 @@ form_enum!( KindEpisode = "kind_episode", } ); - -form_enum!( +url_enum!( enum SortProperty { ReleaseDate = "release_date", Title = "title", @@ -100,3 +114,9 @@ form_enum!( RatingLikesDivViews = "rating_loved", } ); +url_enum!( + enum SortOrder { + Ascending = "ascending", + Descending = "descending", + } +); diff --git a/common/src/lib.rs b/common/src/lib.rs index eaf5900..c606b86 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -11,6 +11,7 @@ pub mod r#impl; pub mod jhls; pub mod stream; pub mod user; +pub mod routes; pub use chrono; diff --git a/common/src/routes.rs b/common/src/routes.rs new file mode 100644 index 0000000..71ca3fa --- /dev/null +++ b/common/src/routes.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::NodeID; + +pub fn u_home() -> String { + "/home".to_owned() +} +pub fn u_node_id(node: NodeID) -> String { + format!("/n/{}", node) +} +pub fn u_node_slug(node: &str) -> String { + format!("/n/{}", node) +} diff --git a/logic/Cargo.toml b/logic/Cargo.toml index 4107c14..bd8a28d 100644 --- a/logic/Cargo.toml +++ b/logic/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "logic" +name = "jellylogic" version = "0.1.0" edition = "2024" @@ -8,3 +8,9 @@ jellybase = { path = "../base" } jellycommon = { path = "../common" } log = "0.4.27" anyhow = "1.0.98" +base64 = "0.22.1" +argon2 = "0.5.3" +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" diff --git a/server/src/ui/sort.rs b/logic/src/filter_sort.rs index 441bac6..3ccbc0d 100644 --- a/server/src/ui/sort.rs +++ b/logic/src/filter_sort.rs @@ -4,14 +4,10 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ use jellycommon::{ + Node, NodeKind, Rating, api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty}, helpers::SortAnyway, user::NodeUserData, - Node, NodeKind, Rating, -}; -use rocket::{ - http::uri::fmt::{Query, UriDisplay}, - FromForm, FromFormField, UriDisplayQuery, }; use std::sync::Arc; diff --git a/logic/src/home.rs b/logic/src/home.rs index f03173e..b774a9f 100644 --- a/logic/src/home.rs +++ b/logic/src/home.rs @@ -4,6 +4,7 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ +use crate::{node::DatabaseNodeUserDataExt, session::Session}; use anyhow::{Context, Result}; use jellybase::database::Database; use jellycommon::{ @@ -13,14 +14,14 @@ use jellycommon::{ user::WatchedState, }; -pub fn home(db: Database) -> Result<ApiHomeResponse> { - let mut items = db.list_nodes_with_udata(&sess.user.name)?; +pub fn home(db: &Database, session: &Session) -> Result<ApiHomeResponse> { + let mut items = db.list_nodes_with_udata(&session.user.name)?; let mut toplevel = db .get_node_children(NodeID::from_slug("library")) .context("root node missing")? .into_iter() - .map(|n| db.get_node_with_userdata(n, &sess)) + .map(|n| db.get_node_with_userdata(n, &session)) .collect::<anyhow::Result<Vec<_>>>()?; toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); diff --git a/logic/src/items.rs b/logic/src/items.rs new file mode 100644 index 0000000..67c45cb --- /dev/null +++ b/logic/src/items.rs @@ -0,0 +1,42 @@ +/* + 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::{filter_sort::filter_and_sort_nodes, session::Session}; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::{ + Visibility, + api::{ApiItemsResponse, NodeFilterSort, SortOrder, SortProperty}, +}; + +pub fn all_items( + db: &Database, + session: &Session, + page: Option<usize>, + filter: NodeFilterSort, +) -> Result<ApiItemsResponse> { + let mut items = db.list_nodes_with_udata(session.user.name.as_str())?; + + items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); + + filter_and_sort_nodes( + &filter, + (SortProperty::Title, SortOrder::Ascending), + &mut items, + ); + + let page_size = 100; + let page = page.unwrap_or(0); + let offset = page * page_size; + let from = offset.min(items.len()); + let to = (offset + page_size).min(items.len()); + let max_page = items.len().div_ceil(page_size); + Ok(ApiItemsResponse { + count: items.len(), + pages: max_page, + items: items[from..to].to_vec(), + }) +} diff --git a/logic/src/lib.rs b/logic/src/lib.rs index cc988d7..a47afc3 100644 --- a/logic/src/lib.rs +++ b/logic/src/lib.rs @@ -3,5 +3,12 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ +#![feature(duration_constructors)] +pub mod filter_sort; pub mod home; +pub mod node; +pub mod search; +pub mod session; +pub mod stats; +pub mod items; diff --git a/logic/src/node.rs b/logic/src/node.rs new file mode 100644 index 0000000..8a53bec --- /dev/null +++ b/logic/src/node.rs @@ -0,0 +1,112 @@ +/* + 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::{filter_sort::filter_and_sort_nodes, session::Session}; +use anyhow::{Result, anyhow}; +use jellybase::database::Database; +use jellycommon::{ + Node, NodeID, NodeKind, Visibility, + api::{ApiNodeResponse, NodeFilterSort, SortOrder, SortProperty}, + user::NodeUserData, +}; +use std::{cmp::Reverse, collections::BTreeMap, sync::Arc}; + +pub fn get_node( + db: &Database, + id: NodeID, + session: &Session, + children: bool, + parents: bool, + filter: NodeFilterSort, +) -> Result<ApiNodeResponse> { + let (node, udata) = db.get_node_with_userdata(id, &session)?; + + let mut children = if children { + db.get_node_children(id)? + .into_iter() + .map(|c| db.get_node_with_userdata(c, &session)) + .collect::<anyhow::Result<Vec<_>>>()? + } else { + Vec::new() + }; + + let mut parents = if parents { + node.parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::<anyhow::Result<Vec<_>>>()? + } else { + Vec::new() + }; + + let mut similar = get_similar_media(&node, db, &session)?; + + similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); + children.retain(|(n, _)| n.visibility >= Visibility::Reduced); + parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + filter_and_sort_nodes( + &filter, + match node.kind { + NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), + NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending), + _ => (SortProperty::Title, SortOrder::Ascending), + }, + &mut children, + ); + + Ok(ApiNodeResponse { + children, + parents, + node, + userdata: udata, + }) +} + +pub fn get_similar_media( + node: &Node, + db: &Database, + session: &Session, +) -> Result<Vec<(Arc<Node>, NodeUserData)>> { + let this_id = NodeID::from_slug(&node.slug); + let mut ranking = BTreeMap::<NodeID, usize>::new(); + for tag in &node.tags { + let nodes = db.get_tag_nodes(tag)?; + let weight = 1_000_000 / nodes.len(); + for n in nodes { + if n != this_id { + *ranking.entry(n).or_default() += weight; + } + } + } + let mut ranking = ranking.into_iter().collect::<Vec<_>>(); + ranking.sort_by_key(|(_, k)| Reverse(*k)); + ranking + .into_iter() + .take(32) + .map(|(pid, _)| db.get_node_with_userdata(pid, session)) + .collect::<anyhow::Result<Vec<_>>>() +} + +pub trait DatabaseNodeUserDataExt { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc<Node>, NodeUserData)>; +} +impl DatabaseNodeUserDataExt for Database { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc<Node>, NodeUserData)> { + Ok(( + self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, + self.get_node_udata(id, &session.user.name)? + .unwrap_or_default(), + )) + } +} diff --git a/logic/src/search.rs b/logic/src/search.rs new file mode 100644 index 0000000..8e41e27 --- /dev/null +++ b/logic/src/search.rs @@ -0,0 +1,31 @@ +/* + 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::{node::DatabaseNodeUserDataExt, session::Session}; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::{Visibility, api::ApiSearchResponse}; +use std::time::Instant; + +pub fn search( + db: &Database, + session: &Session, + query: &str, + page: Option<usize>, +) -> Result<ApiSearchResponse> { + let timing = Instant::now(); + let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; + let mut results = ids + .into_iter() + .map(|id| db.get_node_with_userdata(id, &session)) + .collect::<Result<Vec<_>, anyhow::Error>>()?; + results.retain(|(n, _)| n.visibility >= Visibility::Reduced); + let duration = timing.elapsed(); + Ok(ApiSearchResponse { + count, + results, + duration, + }) +} diff --git a/logic/src/session.rs b/logic/src/session.rs new file mode 100644 index 0000000..bc7f137 --- /dev/null +++ b/logic/src/session.rs @@ -0,0 +1,112 @@ +/* + 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 aes_gcm_siv::{ + KeyInit, + aead::{Aead, generic_array::GenericArray}, +}; +use anyhow::anyhow; +use base64::Engine; +use jellybase::SECRETS; +use jellycommon::{ + chrono::{DateTime, Utc}, + user::{PermissionSet, User}, +}; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::{sync::LazyLock, time::Duration}; + +pub struct Session { + pub user: User, +} + +pub struct AdminSession(pub Session); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + username: String, + expire: DateTime<Utc>, + permissions: PermissionSet, +} + +static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| { + if let Some(sk) = &SECRETS.session_key { + let r = base64::engine::general_purpose::STANDARD + .decode(sk) + .expect("key invalid; should be valid base64"); + r.try_into() + .expect("key has the wrong length; should be 32 bytes") + } else { + warn!("session_key not configured; generating a random one."); + [(); 32].map(|_| rand::random()) + } +}); + +pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String { + let session_data = SessionData { + expire: Utc::now() + expire, + username: username.to_owned(), + permissions, + }; + let mut plaintext = + bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap(); + + while plaintext.len() % 16 == 0 { + plaintext.push(0); + } + + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let nonce = [(); 12].map(|_| rand::random()); + let mut ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + ciphertext.extend(nonce); + + base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) +} + +pub fn validate(token: &str) -> anyhow::Result<String> { + let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?; + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12); + let plaintext = cipher + .decrypt(nonce.into(), ciphertext) + .map_err(|e| anyhow!("decryption failed: {e:?}"))?; + + let (session_data, _): (SessionData, _) = + bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; + + if session_data.expire < Utc::now() { + Err(anyhow!("session expired"))? + } + + Ok(session_data.username) +} + +#[test] +fn test() { + jellybase::use_test_config(); + let tok = create( + "blub".to_string(), + jellycommon::user::PermissionSet::default(), + Duration::from_days(1), + ); + validate(&tok).unwrap(); +} + +#[test] +fn test_crypto() { + jellybase::use_test_config(); + let nonce = [(); 12].map(|_| rand::random()); + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let plaintext = b"testing stuff---"; + let ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + let plaintext2 = cipher + .decrypt((&nonce).into(), ciphertext.as_slice()) + .unwrap(); + assert_eq!(plaintext, plaintext2.as_slice()); +} diff --git a/logic/src/stats.rs b/logic/src/stats.rs new file mode 100644 index 0000000..2569180 --- /dev/null +++ b/logic/src/stats.rs @@ -0,0 +1,48 @@ +/* + 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::Session; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::{ + Node, NodeKind, Visibility, + api::{ApiStatsResponse, StatsBin}, +}; +use std::collections::BTreeMap; + +pub fn stats(db: &Database, session: &Session) -> Result<ApiStatsResponse> { + let mut items = db.list_nodes_with_udata(session.user.name.as_str())?; + items.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + trait BinExt { + fn update(&mut self, node: &Node); + } + impl BinExt for StatsBin { + fn update(&mut self, node: &Node) { + self.count += 1; + self.size += node.storage_size; + if node.storage_size > self.max_size.0 { + self.max_size = (node.storage_size, node.slug.clone()) + } + if let Some(m) = &node.media { + self.runtime += m.duration; + if m.duration > self.max_runtime.0 { + self.max_runtime = (m.duration, node.slug.clone()) + } + } + } + + } + + let mut total = StatsBin::default(); + let mut kinds = BTreeMap::<NodeKind, StatsBin>::new(); + for (i, _) in items { + total.update(&i); + kinds.entry(i.kind).or_default().update(&i); + } + + Ok(ApiStatsResponse { kinds, total }) +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 669194a..b658e61 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,12 +4,14 @@ version = "0.1.1" edition = "2021" [dependencies] -jellycommon = { path = "../common", features = ["rocket"] } +jellycommon = { path = "../common" } jellybase = { path = "../base" } jellystream = { path = "../stream" } jellytranscoder = { path = "../transcoder" } jellyimport = { path = "../import" } jellycache = { path = "../cache" } +jellyui = { path = "../ui" } +jellylogic = { path = "../logic" } serde = { version = "1.0.217", features = ["derive", "rc"] } bincode = { version = "2.0.0-rc.3", features = ["serde", "derive"] } @@ -24,9 +26,6 @@ chrono = { version = "0.4.39", features = ["serde"] } vte = "0.14.1" chashmap = "2.2.2" -argon2 = "0.5.3" -aes-gcm-siv = "0.11.1" - async-recursion = "1.1.1" futures = "0.3.31" tokio = { workspace = true } diff --git a/server/src/logic/session.rs b/server/src/logic/session.rs index 790e070..d77c4fc 100644 --- a/server/src/logic/session.rs +++ b/server/src/logic/session.rs @@ -13,6 +13,7 @@ use base64::Engine; use chrono::{DateTime, Duration, Utc}; use jellybase::{database::Database, SECRETS}; use jellycommon::user::{PermissionSet, User}; +use jellylogic::session::validate; use log::warn; use rocket::{ async_trait, @@ -24,19 +25,6 @@ use rocket::{ use serde::{Deserialize, Serialize}; use std::sync::LazyLock; -pub struct Session { - pub user: User, -} - -pub struct AdminSession(pub Session); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionData { - username: String, - expire: DateTime<Utc>, - permissions: PermissionSet, -} - impl Session { pub async fn from_request_ut(req: &Request<'_>) -> Result<Self, MyError> { let username; @@ -126,83 +114,3 @@ impl<'r> FromRequest<'r> for AdminSession { } } } - -static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| { - if let Some(sk) = &SECRETS.session_key { - let r = base64::engine::general_purpose::STANDARD - .decode(sk) - .expect("key invalid; should be valid base64"); - r.try_into() - .expect("key has the wrong length; should be 32 bytes") - } else { - warn!("session_key not configured; generating a random one."); - [(); 32].map(|_| rand::random()) - } -}); - -pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String { - let session_data = SessionData { - expire: Utc::now() + expire, - username: username.to_owned(), - permissions, - }; - let mut plaintext = - bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap(); - - while plaintext.len() % 16 == 0 { - plaintext.push(0); - } - - let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); - let nonce = [(); 12].map(|_| rand::random()); - let mut ciphertext = cipher - .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) - .unwrap(); - ciphertext.extend(nonce); - - base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) -} - -pub fn validate(token: &str) -> anyhow::Result<String> { - let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?; - let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); - let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12); - let plaintext = cipher - .decrypt(nonce.into(), ciphertext) - .map_err(|e| anyhow!("decryption failed: {e:?}"))?; - - let (session_data, _): (SessionData, _) = - bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; - - if session_data.expire < Utc::now() { - Err(anyhow!("session expired"))? - } - - Ok(session_data.username) -} - -#[test] -fn test() { - jellybase::use_test_config(); - let tok = create( - "blub".to_string(), - jellycommon::user::PermissionSet::default(), - Duration::days(1), - ); - validate(&tok).unwrap(); -} - -#[test] -fn test_crypto() { - jellybase::use_test_config(); - let nonce = [(); 12].map(|_| rand::random()); - let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); - let plaintext = b"testing stuff---"; - let ciphertext = cipher - .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) - .unwrap(); - let plaintext2 = cipher - .decrypt((&nonce).into(), ciphertext.as_slice()) - .unwrap(); - assert_eq!(plaintext, plaintext2.as_slice()); -} diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs index 4047e4f..3b53bc4 100644 --- a/server/src/ui/account/settings.rs +++ b/server/src/ui/account/settings.rs @@ -51,57 +51,7 @@ fn settings_page( LayoutPage { title: "Settings".to_string(), class: Some("settings"), - content: markup::new! { - h1 { "Settings" } - @if let Some(flash) = &flash { - @match flash { - Ok(mesg) => { section.message { p.success { @mesg } } } - Err(err) => { section.message { p.error { @format!("{err}") } } } - } - } - h2 { @trs(&lang, "account") } - a.switch_account[href=uri!(r_account_login())] { "Switch Account" } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="username"] { @trs(&lang, "account.username") } - input[type="text", id="username", disabled, value=&session.user.name]; - input[type="submit", disabled, value=&*tr(lang, "settings.immutable")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="display_name"] { @trs(&lang, "account.display_name") } - input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; - input[type="submit", value=&*tr(lang, "settings.update")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="password"] { @trs(&lang, "account.password") } - input[type="password", id="password", name="password"]; - input[type="submit", value=&*tr(lang, "settings.update")]; - } - h2 { @trs(&lang, "settings.appearance") } - form[method="POST", action=uri!(r_account_settings_post())] { - fieldset { - legend { @trs(&lang, "settings.appearance.theme") } - @for (t, tlabel) in Theme::LIST { - label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br; - } - } - input[type="submit", value=&*tr(lang, "settings.apply")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - fieldset { - legend { @trs(&lang, "settings.player_preference") } - @for (t, tlabel) in PlayerKind::LIST { - label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br; - } - } - input[type="submit", value=&*tr(lang, "settings.apply")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="native_secret"] { "Native Secret" } - input[type="password", id="native_secret", name="native_secret"]; - input[type="submit", value=&*tr(lang, "settings.update")]; - p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } - } - }, + content: } } diff --git a/server/src/ui/browser.rs b/server/src/ui/browser.rs index f7eac93..b780934 100644 --- a/server/src/ui/browser.rs +++ b/server/src/ui/browser.rs @@ -30,29 +30,10 @@ pub fn r_all_items_filter( lang: AcceptLanguage, ) -> Result<Either<DynLayoutPage<'_>, Json<ApiItemsResponse>>, MyError> { let AcceptLanguage(lang) = lang; - let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; - - items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); - - filter_and_sort_nodes( - &filter, - (SortProperty::Title, SortOrder::Ascending), - &mut items, - ); - - let page_size = 100; - let page = page.unwrap_or(0); - let offset = page * page_size; - let from = offset.min(items.len()); - let to = (offset + page_size).min(items.len()); - let max_page = items.len().div_ceil(page_size); - + + let data = all_items()?; Ok(if *aj { - Either::Right(Json(ApiItemsResponse { - count: items.len(), - pages: max_page, - items: items[from..to].to_vec(), - })) + Either::Right(Json(data)) } else { Either::Left(LayoutPage { title: "All Items".to_owned(), diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs index 96b1dc2..2a79965 100644 --- a/server/src/ui/home.rs +++ b/server/src/ui/home.rs @@ -18,14 +18,12 @@ pub fn r_home( lang: AcceptLanguage, ) -> MyResult<Either<DynLayoutPage, Json<ApiHomeResponse>>> { let AcceptLanguage(lang) = lang; - + + let resp = jellylogic::home::home(&db, sess)?; Ok(if *aj { - Either::Right(Json(ApiHomeResponse { - toplevel, - categories, - })) + Either::Right(Json(resp)) } else { - Either::Left() + Either::Left(jellyui::home::home_page(resp)) }) } diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs index 89c0e9a..6728b81 100644 --- a/server/src/ui/mod.rs +++ b/server/src/ui/mod.rs @@ -38,7 +38,6 @@ pub mod home; pub mod node; pub mod player; pub mod search; -pub mod sort; pub mod stats; pub mod style; diff --git a/server/src/ui/node.rs b/server/src/ui/node.rs index 1efcc10..5d0f1ff 100644 --- a/server/src/ui/node.rs +++ b/server/src/ui/node.rs @@ -92,49 +92,3 @@ pub async fn r_library_node_filter<'a>( }) }) } - -pub fn get_similar_media( - node: &Node, - db: &Database, - session: &Session, -) -> Result<Vec<(Arc<Node>, NodeUserData)>> { - let this_id = NodeID::from_slug(&node.slug); - let mut ranking = BTreeMap::<NodeID, usize>::new(); - for tag in &node.tags { - let nodes = db.get_tag_nodes(tag)?; - let weight = 1_000_000 / nodes.len(); - for n in nodes { - if n != this_id { - *ranking.entry(n).or_default() += weight; - } - } - } - let mut ranking = ranking.into_iter().collect::<Vec<_>>(); - ranking.sort_by_key(|(_, k)| Reverse(*k)); - ranking - .into_iter() - .take(32) - .map(|(pid, _)| db.get_node_with_userdata(pid, session)) - .collect::<anyhow::Result<Vec<_>>>() -} - -pub trait DatabaseNodeUserDataExt { - fn get_node_with_userdata( - &self, - id: NodeID, - session: &Session, - ) -> Result<(Arc<Node>, NodeUserData)>; -} -impl DatabaseNodeUserDataExt for Database { - fn get_node_with_userdata( - &self, - id: NodeID, - session: &Session, - ) -> Result<(Arc<Node>, NodeUserData)> { - Ok(( - self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, - self.get_node_udata(id, &session.user.name)? - .unwrap_or_default(), - )) - } -} diff --git a/server/src/ui/search.rs b/server/src/ui/search.rs index bfe51a8..51fdcb8 100644 --- a/server/src/ui/search.rs +++ b/server/src/ui/search.rs @@ -25,21 +25,7 @@ pub async fn r_search<'a>( lang: AcceptLanguage, ) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiSearchResponse>>> { let AcceptLanguage(lang) = lang; - let results = if let Some(query) = query { - let timing = Instant::now(); - let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; - let mut nodes = ids - .into_iter() - .map(|id| db.get_node_with_userdata(id, &session)) - .collect::<Result<Vec<_>, anyhow::Error>>()?; - nodes.retain(|(n, _)| n.visibility >= Visibility::Reduced); - let search_dur = timing.elapsed(); - Some((count, nodes, search_dur)) - } else { - None - }; - let query = query.unwrap_or_default().to_string(); - + Ok(if *aj { let Some((count, results, _)) = results else { Err(anyhow!("no query"))? diff --git a/server/src/ui/stats.rs b/server/src/ui/stats.rs index 345586a..a91e670 100644 --- a/server/src/ui/stats.rs +++ b/server/src/ui/stats.rs @@ -8,21 +8,14 @@ use super::{ layout::{DynLayoutPage, LayoutPage}, }; use crate::{ - api::AcceptJson, - database::Database, - locale::AcceptLanguage, - logic::session::Session, - ui::{ - layout::trs, - node::{ - format_duration, format_duration_long, format_kind, format_size, - rocket_uri_macro_r_library_node, - }, - }, - uri, + api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session, uri, }; use jellybase::locale::tr; -use jellycommon::{Node, NodeID, NodeKind, Visibility}; +use jellycommon::{ + api::{ApiStatsResponse, StatsBin}, + Node, NodeID, NodeKind, Visibility, +}; +use jellylogic::stats::stats; use markup::raw; use rocket::{get, serde::json::Json, Either, State}; use serde::Serialize; @@ -35,54 +28,13 @@ pub fn r_stats( db: &State<Database>, aj: AcceptJson, lang: AcceptLanguage, -) -> Result<Either<DynLayoutPage<'_>, Json<Value>>, MyError> { +) -> Result<Either<DynLayoutPage<'_>, Json<ApiStatsResponse>>, MyError> { let AcceptLanguage(lang) = lang; - let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; - items.retain(|(n, _)| n.visibility >= Visibility::Reduced); - - #[derive(Default, Serialize)] - struct Bin { - runtime: f64, - size: u64, - count: usize, - max_runtime: (f64, String), - max_size: (u64, String), - } - impl Bin { - fn update(&mut self, node: &Node) { - self.count += 1; - self.size += node.storage_size; - if node.storage_size > self.max_size.0 { - self.max_size = (node.storage_size, node.slug.clone()) - } - if let Some(m) = &node.media { - self.runtime += m.duration; - if m.duration > self.max_runtime.0 { - self.max_runtime = (m.duration, node.slug.clone()) - } - } - } - fn average_runtime(&self) -> f64 { - self.runtime / self.count as f64 - } - fn average_size(&self) -> f64 { - self.size as f64 / self.count as f64 - } - } - - let mut all = Bin::default(); - let mut kinds = BTreeMap::<NodeKind, Bin>::new(); - for (i, _) in items { - all.update(&i); - kinds.entry(i.kind).or_default().update(&i); - } + let data = stats(db)?; Ok(if *aj { - Either::Right(Json(json!({ - "all": all, - "kinds": kinds, - }))) + Either::Right(Json(data)) } else { - Either::Left() + Either::Left(1) }) } diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 0e8f0fd..86f336c 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -5,5 +5,5 @@ edition = "2024" [dependencies] markup = "0.15.0" -jellycommon = { path = "../common", features = ["rocket"] } +jellycommon = { path = "../common" } humansize = "2.1.3" diff --git a/ui/src/filter_sort.rs b/ui/src/filter_sort.rs index 53d4ea3..ec83f6f 100644 --- a/ui/src/filter_sort.rs +++ b/ui/src/filter_sort.rs @@ -5,6 +5,7 @@ */ use jellycommon::api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty}; +use markup::RenderAttributeValue; use crate::locale::{Language, trs}; @@ -79,11 +80,11 @@ markup::define! { fieldset.filter { legend { "Filter" } .categories { - @for (cname, cat) in FilterProperty::CATS { + @for (cname, cat) in FILTER_CATS { .category { h3 { @trs(lang, cname) } @for (value, label) in *cat { - label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; + label { input[type="checkbox", name="filter_kind", value=A(*value), checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; } } } @@ -92,11 +93,11 @@ markup::define! { fieldset.sortby { legend { "Sort" } .categories { - @for (cname, cat) in SortProperty::CATS { + @for (cname, cat) in SORT_CATS { .category { h3 { @trs(lang, cname) } @for (value, label) in *cat { - label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; + label { input[type="radio", name="sort_by", value=A(*value), checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; } } } @@ -106,7 +107,7 @@ markup::define! { legend { "Sort Order" } @use SortOrder::*; @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { - label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br; + label { input[type="radio", name="sort_order", value=A(value), checked=Some(value)==f.sort_order]; @trs(lang, label) } br; } } input[type="submit", value="Apply"]; a[href="?"] { "Clear" } @@ -115,21 +116,22 @@ markup::define! { } } -impl markup::Render for SortProperty { +struct A<T>(pub T); +impl markup::Render for A<SortProperty> { fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) } } -impl markup::Render for SortOrder { +impl markup::Render for A<SortOrder> { fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) } } -impl markup::Render for FilterProperty { +impl markup::Render for A<FilterProperty> { fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) } } -impl RenderAttributeValue for SortOrder {} -impl RenderAttributeValue for FilterProperty {} -impl RenderAttributeValue for SortProperty {} +impl RenderAttributeValue for A<SortOrder> {} +impl RenderAttributeValue for A<FilterProperty> {} +impl RenderAttributeValue for A<SortProperty> {} diff --git a/ui/src/format.rs b/ui/src/format.rs index a374850..84e4c27 100644 --- a/ui/src/format.rs +++ b/ui/src/format.rs @@ -6,6 +6,7 @@ use crate::locale::{Language, TrString, tr, trs}; use jellycommon::{Chapter, MediaInfo, NodeKind, SourceTrackKind}; +use std::fmt::Write; pub fn format_duration(d: f64) -> String { format_duration_mode(d, false, Language::English) @@ -66,10 +67,10 @@ pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> { ) } -trait MediaInfoExt { +pub trait MediaInfoExt { fn resolution_name(&self) -> &'static str; } -impl MediaInfoExt for MediaInfo { +impl MediaInfoExt for &MediaInfo { fn resolution_name(&self) -> &'static str { let mut maxdim = 0; for t in &self.tracks { diff --git a/ui/src/home.rs b/ui/src/home.rs index 7b58179..ec0c634 100644 --- a/ui/src/home.rs +++ b/ui/src/home.rs @@ -3,20 +3,21 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ - use crate::{ + Page, locale::{Language, tr, trs}, node_card::NodeCard, - scaffold::LayoutPage, }; +use jellycommon::api::ApiHomeResponse; +use markup::DynRender; markup::define! { - HomePage<'a>(lang: &'a Language) { - h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) } - ul.children.hlist {@for (node, udata) in &toplevel { + HomePage<'a>(lang: &'a Language, r: &'a ApiHomeResponse) { + h2 { @trs(lang, "home.bin.root") } //.replace("{title}", &CONF.brand) } + ul.children.hlist {@for (node, udata) in &r.toplevel { li { @NodeCard { node, udata, lang: &lang } } }} - @for (name, nodes) in &categories { + @for (name, nodes) in &r.categories { @if !nodes.is_empty() { h2 { @trs(&lang, &name) } ul.children.hlist {@for (node, udata) in nodes { @@ -27,10 +28,11 @@ markup::define! { } } -pub fn home_page() { - LayoutPage { - title: tr(lang, "home").to_string(), - content: HomePage { lang: &lang }, - ..Default::default() +impl Page for HomePage<'_> { + fn title(&self) -> String { + tr(*self.lang, "home").to_string() + } + fn to_render(&self) -> DynRender { + markup::new!(@self) } } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 40d43dd..4298623 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -1,15 +1,34 @@ +use markup::DynRender; + /* 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 filter_sort; pub mod format; +pub mod home; pub mod locale; -pub mod node_page; pub mod node_card; -pub mod scaffold; +pub mod node_page; pub mod props; -pub mod filter_sort; +pub mod scaffold; pub mod search; +pub mod settings; pub mod stats; -pub mod home; + +/// render as supertrait would be possible but is not +/// dyn compatible and I really dont want to expose generics +/// that generate rendering code because of compile speed. +pub trait Page { + fn title(&self) -> String; + fn to_render(&self) -> DynRender; + fn class(&self) -> Option<&'static str> { + None + } +} + +pub fn render_page(page: &dyn Page) -> String { + // page.render() + "a".to_string() +} diff --git a/ui/src/node_page.rs b/ui/src/node_page.rs index 8848202..b48fca2 100644 --- a/ui/src/node_page.rs +++ b/ui/src/node_page.rs @@ -5,6 +5,7 @@ */ use crate::{ + Page, filter_sort::NodeFilterSortForm, format::format_chapter, locale::{Language, trs}, @@ -18,6 +19,12 @@ use jellycommon::{ }; use std::sync::Arc; +impl Page for NodePage<'_> { + fn title(&self) -> String { + self.node.title.clone().unwrap_or_default() + } +} + markup::define! { NodePage<'a>( node: &'a Node, diff --git a/ui/src/props.rs b/ui/src/props.rs index 7dbc0de..fbeddca 100644 --- a/ui/src/props.rs +++ b/ui/src/props.rs @@ -4,8 +4,8 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ use crate::{ - format::format_duration, - locale::{Language, trs}, + format::{MediaInfoExt, format_count, format_duration}, + locale::{Language, tr, trs}, }; use jellycommon::{ Node, Rating, Visibility, diff --git a/ui/src/scaffold.rs b/ui/src/scaffold.rs index ffd5fdf..cc5886b 100644 --- a/ui/src/scaffold.rs +++ b/ui/src/scaffold.rs @@ -4,8 +4,8 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::locale::{tr, trs, Language}; -use markup::{DynRender, Render}; +use crate::locale::{Language, escape, tr, trs}; +use markup::{DynRender, Render, raw}; use std::sync::LazyLock; static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); diff --git a/ui/src/search.rs b/ui/src/search.rs index 092ad57..f252620 100644 --- a/ui/src/search.rs +++ b/ui/src/search.rs @@ -4,28 +4,40 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ +use crate::{ + Page, + locale::{Language, tr, trs}, + node_card::NodeCard, +}; +use jellycommon::api::ApiSearchResponse; +use markup::DynRender; + +impl Page for SearchPage<'_> { + fn title(&self) -> String { + tr(*self.lang, "search.title").to_string() + } + fn class(&self) -> Option<&'static str> { + Some("search") + } + fn to_render(&self) -> DynRender { + markup::new!(@self) + } +} + markup::define! { - SearchPage { + SearchPage<'a>(lang: &'a Language, r: Option<ApiSearchResponse>, query: &'a Option<String>) { h1 { @trs(&lang, "search.title") } form[action="", method="GET"] { - input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query]; + input[type="text", name="query", placeholder=&*tr(**lang, "search.placeholder"), value=&query]; input[type="submit", value="Search"]; } - @if let Some((count, results, search_dur)) = &results { + @if let Some(r) = &r { h2 { @trs(&lang, "search.results.title") } - p.stats { @tr(lang, "search.results.stats").replace("{count}", &count.to_string()).replace("{dur}", &format!("{search_dur:?}")) } - ul.children {@for (node, udata) in results.iter() { + p.stats { @tr(**lang, "search.results.stats").replace("{count}", &r.count.to_string()).replace("{dur}", &format!("{:?}", r.duration)) } + ul.children {@for (node, udata) in r.results.iter() { li { @NodeCard { node, udata, lang: &lang } } }} // TODO pagination } } } - -pub fn search_page() { - LayoutPage { - title: tr(lang, "search.title").to_string(), - class: Some("search"), - content: SearchPage, - } -}
\ No newline at end of file diff --git a/ui/src/settings.rs b/ui/src/settings.rs new file mode 100644 index 0000000..9bc4b1d --- /dev/null +++ b/ui/src/settings.rs @@ -0,0 +1,76 @@ +/* + 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::user::{PlayerKind, Theme}; +use markup::RenderAttributeValue; + +markup::define! { + Settings<'a>(flash: Option<Result<String, String>>, lang: &'a Language) { + h1 { "Settings" } + @if let Some(flash) = &flash { + @match flash { + Ok(mesg) => { section.message { p.success { @mesg } } } + Err(err) => { section.message { p.error { @format!("{err}") } } } + } + } + h2 { @trs(&lang, "account") } + a.switch_account[href=uri!(r_account_login())] { "Switch Account" } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="username"] { @trs(&lang, "account.username") } + input[type="text", id="username", disabled, value=&session.user.name]; + input[type="submit", disabled, value=&*tr(**lang, "settings.immutable")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="display_name"] { @trs(lang, "account.display_name") } + input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; + input[type="submit", value=&*tr(**lang, "settings.update")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="password"] { @trs(lang, "account.password") } + input[type="password", id="password", name="password"]; + input[type="submit", value=&*tr(**lang, "settings.update")]; + } + h2 { @trs(&lang, "settings.appearance") } + form[method="POST", action=uri!(r_account_settings_post())] { + fieldset { + legend { @trs(&lang, "settings.appearance.theme") } + @for (t, tlabel) in Theme::LIST { + label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br; + } + } + input[type="submit", value=&*tr(**lang, "settings.apply")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + fieldset { + legend { @trs(&lang, "settings.player_preference") } + @for (t, tlabel) in PlayerKind::LIST { + label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br; + } + } + input[type="submit", value=&*tr(**lang, "settings.apply")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="native_secret"] { "Native Secret" } + input[type="password", id="native_secret", name="native_secret"]; + input[type="submit", value=&*tr(**lang, "settings.update")]; + p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } + } + } +} + +struct A<T>(pub T); +impl markup::Render for A<Theme> { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl markup::Render for A<PlayerKind> { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl RenderAttributeValue for A<Theme> {} +impl RenderAttributeValue for A<PlayerKind> {} diff --git a/ui/src/stats.rs b/ui/src/stats.rs index b4a2e23..3655245 100644 --- a/ui/src/stats.rs +++ b/ui/src/stats.rs @@ -7,24 +7,24 @@ use crate::{ format::{format_duration, format_duration_long, format_kind, format_size}, locale::{Language, tr, trs}, - scaffold::LayoutPage, }; +use jellycommon::api::{ApiStatsResponse, StatsBin}; use markup::raw; markup::define! { - StatsPage<'a>(lang: &'a Language) { + StatsPage<'a>(lang: &'a Language, r: ApiStatsResponse) { .page.stats { h1 { @trs(&lang, "stats.title") } - p { @raw(tr(lang, "stats.count") - .replace("{count}", &format!("<b>{}</b>", all.count)) + p { @raw(tr(**lang, "stats.count") + .replace("{count}", &format!("<b>{}</b>", r.total.count)) )} - p { @raw(tr(lang, "stats.runtime") - .replace("{dur}", &format!("<b>{}</b>", format_duration_long(all.runtime, lang))) - .replace("{size}", &format!("<b>{}</b>", format_size(all.size))) + p { @raw(tr(**lang, "stats.runtime") + .replace("{dur}", &format!("<b>{}</b>", format_duration_long(r.total.runtime, **lang))) + .replace("{size}", &format!("<b>{}</b>", format_size(r.total.size))) )} - p { @raw(tr(lang, "stats.average") - .replace("{dur}", &format!("<b>{}</b>", format_duration(all.average_runtime()))) - .replace("{size}", &format!("<b>{}</b>", format_size(all.average_size() as u64))) + p { @raw(tr(**lang, "stats.average") + .replace("{dur}", &format!("<b>{}</b>", format_duration(r.total.average_runtime()))) + .replace("{size}", &format!("<b>{}</b>", format_size(r.total.average_size() as u64))) )} h2 { @trs(&lang, "stats.by_kind.title") } @@ -39,8 +39,8 @@ markup::define! { th { @trs(&lang, "stats.by_kind.max_size") } th { @trs(&lang, "stats.by_kind.max_runtime") } } - @for (k,b) in &kinds { tr { - td { @format_kind(*k, lang) } + @for (k,b) in &r.kinds { tr { + td { @format_kind(*k, **lang) } td { @b.count } td { @format_size(b.size) } td { @format_duration(b.runtime) } @@ -54,10 +54,21 @@ markup::define! { } } -pub fn stats_page() { - LayoutPage { - title: tr(lang, "stats.title").to_string(), - content: StatsPage { lang: &lang }, - ..Default::default() +impl StatsPage<'_> { + pub fn title(&self) -> String { + tr(*self.lang, "stats.title").to_string() + } +} + +trait BinExt { + fn average_runtime(&self) -> f64; + fn average_size(&self) -> f64; +} +impl BinExt for StatsBin { + fn average_runtime(&self) -> f64 { + self.runtime / self.count as f64 + } + fn average_size(&self) -> f64 { + self.size as f64 / self.count as f64 } } |