diff options
| author | metamuffin <metamuffin@disroot.org> | 2026-01-25 03:31:59 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2026-01-25 03:31:59 +0100 |
| commit | 53361f4c6027d1569a707ce58889bc2c2ea3749c (patch) | |
| tree | 65ccafebd8df99019b039cc0d94c61291b1fd437 /server | |
| parent | e3708145efd0bbed5d10320ee0a0deff0f38479e (diff) | |
| download | jellything-53361f4c6027d1569a707ce58889bc2c2ea3749c.tar jellything-53361f4c6027d1569a707ce58889bc2c2ea3749c.tar.bz2 jellything-53361f4c6027d1569a707ce58889bc2c2ea3749c.tar.zst | |
finish migrating auth code; refactor config/state more
Diffstat (limited to 'server')
| -rw-r--r-- | server/Cargo.toml | 8 | ||||
| -rw-r--r-- | server/src/auth.rs | 171 | ||||
| -rw-r--r-- | server/src/config.rs | 59 | ||||
| -rw-r--r-- | server/src/main.rs | 79 | ||||
| -rw-r--r-- | server/src/request_info.rs | 58 | ||||
| -rw-r--r-- | server/src/routes.rs | 105 |
6 files changed, 191 insertions, 289 deletions
diff --git a/server/Cargo.toml b/server/Cargo.toml index 01854b5..534dce5 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,10 +9,13 @@ jellystream = { path = "../stream" } jellytranscoder = { path = "../transcoder" } jellyimport = { path = "../import" } jellycache = { path = "../cache" } +jellydb = { path = "../database" } jellyui = { path = "../ui" } -jellykv = { path = "../kv" } +jellykv = { path = "../kv", features = ["rocksdb"] } +aes-gcm-siv = "0.11.1" anyhow = { workspace = true } +argon2 = "0.5.3" async-recursion = "1.1.1" base64 = "0.22.1" bincode = { version = "2.0.1", features = ["serde", "derive"] } @@ -29,6 +32,3 @@ serde_json = "1.0.145" serde_yaml_ng = "0.10.0" tokio = { workspace = true } tokio-util = { version = "0.7.17", features = ["io", "io-util"] } - -[features] -bypass-auth = [] diff --git a/server/src/auth.rs b/server/src/auth.rs index 03ff3a3..e84c4d1 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -3,132 +3,73 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::{CONF, DATABASE}; -use aes_gcm_siv::{ - KeyInit, - aead::{Aead, generic_array::GenericArray}, -}; -use anyhow::anyhow; -use base64::Engine; -use log::warn; -use serde::{Deserialize, Serialize}; -use std::{sync::LazyLock, time::Duration}; -pub struct Session { - pub user: User, -} - -#[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) = &CONF.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()) - } -}); +use crate::State; +use anyhow::{Result, anyhow}; +use jellycommon::jellyobject::ObjectBuffer; -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().is_multiple_of(16) { - plaintext.push(0); - } +pub fn token_to_user(state: &State, token: &str) -> Result<ObjectBuffer> { + let user_row = token::validate(&state.session_key, token)?; - 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); + let mut user = None; + state.database.read_transaction(&mut |txn| { + user = state.users.get(txn, user_row)?; + Ok(()) + })?; - base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) + user.ok_or(anyhow!("user was deleted")) } -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:?}"))?; +pub mod token { + use aes_gcm_siv::{ + Aes256GcmSiv, KeyInit, + aead::{Aead, generic_array::GenericArray}, + }; + use anyhow::{Result, anyhow, bail}; + use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE}, + }; + use chrono::Utc; + use jellydb::table::RowNum; + use std::time::Duration; - let (session_data, _): (SessionData, _) = - bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; + pub struct SessionKey(Aes256GcmSiv); - if session_data.expire < Utc::now() { - Err(anyhow!("session expired"))? + impl SessionKey { + pub fn parse(s: &str) -> Result<Self> { + let k = STANDARD.decode(s)?; + Ok(Self(Aes256GcmSiv::new_from_slice(&k)?)) + } } - Ok(session_data.username) -} - -pub fn token_to_session(token: &str) -> anyhow::Result<Session> { - let username = validate(token)?; - let user = DATABASE - .get_user(&username)? - .ok_or(anyhow!("user does not exist"))?; - Ok(Session { user }) -} -pub fn bypass_auth_session() -> anyhow::Result<Session> { - let user = DATABASE - .get_user(CONF.admin_username.as_ref().unwrap())? - .ok_or(anyhow!("user does not exist"))?; - Ok(Session { user }) -} - -#[cfg(test)] -fn load_test_config() { - use std::path::PathBuf; + pub fn create(sk: &SessionKey, user: RowNum, expire: Duration) -> String { + let expire_ts = Utc::now().timestamp() + expire.as_secs() as i64; + let mut plain = Vec::new(); + plain.extend(user.to_be_bytes()); + plain.extend(expire_ts.to_be_bytes()); - use crate::{CONF_PRELOAD, Config}; - *CONF_PRELOAD.lock().unwrap() = Some(Config { - database_path: PathBuf::default(), - login_expire: 10, - session_key: None, - admin_password: None, - admin_username: None, - }); -} + let nonce = [(); 12].map(|_| rand::random()); + let mut ciper = + sk.0.encrypt(&GenericArray::from(nonce), plain.as_slice()) + .unwrap(); + ciper.extend(nonce); + URL_SAFE.encode(&ciper) + } + pub fn validate(sk: &SessionKey, token: &str) -> Result<RowNum> { + let cipher = URL_SAFE.decode(token)?; + let (cipher, nonce) = cipher.split_at(cipher.len() - 12); + let plain = + sk.0.decrypt(nonce.into(), cipher) + .map_err(|_| anyhow!("invalid session"))?; -#[test] -fn test() { - load_test_config(); - let tok = create( - "blub".to_string(), - jellycommon::user::PermissionSet::default(), - Duration::from_days(1), - ); - validate(&tok).unwrap(); -} + let user = RowNum::from_be_bytes(plain[0..8].try_into().unwrap()); + let expire_ts = i64::from_be_bytes(plain[8..16].try_into().unwrap()); -#[test] -fn test_crypto() { - load_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()); + if Utc::now().timestamp() > expire_ts { + bail!("session expired") + } else { + Ok(user) + } + } } diff --git a/server/src/config.rs b/server/src/config.rs deleted file mode 100644 index 73d6b73..0000000 --- a/server/src/config.rs +++ /dev/null @@ -1,59 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2026 metamuffin <metamuffin.org> -*/ - -use anyhow::{Context, Result, anyhow}; -use jellycache::init_cache; -use serde::Deserialize; -use std::{ - env::{args, var}, - path::PathBuf, - sync::{LazyLock, Mutex}, -}; -use tokio::fs::read_to_string; - -static CONF_PRELOAD: Mutex<Option<AllConfigs>> = Mutex::new(None); -pub static CONF: LazyLock<AllConfigs> = - LazyLock::new(|| CONF_PRELOAD.lock().unwrap().take().unwrap()); - -pub struct AllConfigs { - pub ui: jellyui::Config, - pub transcoder: jellytranscoder::Config, - pub stream: jellystream::Config, - pub cache: jellycache::Config, - pub import: jellyimport::Config, - pub server: Config, -} - -#[derive(Debug, Deserialize)] -pub struct Config { - pub asset_path: PathBuf, - pub cookie_key: Option<String>, - pub tls: bool, - pub hostname: String, -} - -pub async fn load_config() -> Result<()> { - let path = args() - .nth(1) - .or_else(|| var("JELLYTHING_CONFIG").ok()) - .ok_or(anyhow!( - "No config supplied. Use first argument or JELLYTHING_CONFIG environment variable." - ))?; - - let config = read_to_string(path).await.context("reading main config")?; - *CONF_PRELOAD.lock().unwrap() = Some(AllConfigs { - ui: serde_yaml_ng::from_str(&config).context("ui config")?, - transcoder: serde_yaml_ng::from_str(&config).context("transcoder config")?, - stream: serde_yaml_ng::from_str(&config).context("stream config")?, - cache: serde_yaml_ng::from_str(&config).context("cache config")?, - import: serde_yaml_ng::from_str(&config).context("import config")?, - server: serde_yaml_ng::from_str(&config).context("server config")?, - }); - - init_cache()?; - - Ok(()) -} diff --git a/server/src/main.rs b/server/src/main.rs index 9b6463f..bd9901a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -7,38 +7,83 @@ #![allow(clippy::needless_borrows_for_generic_args)] #![recursion_limit = "4096"] -use crate::logger::setup_logger; -use config::load_config; -use log::{error, info, warn}; +use crate::{auth::token::SessionKey, logger::setup_logger}; +use anyhow::Result; +use jellycache::Cache; +use jellydb::table::Table; +use jellykv::Database; +use log::{error, info}; use routes::build_rocket; -use std::process::exit; +use serde::Deserialize; +use std::{env::args, fs::read_to_string, path::PathBuf, process::exit, sync::Arc}; pub mod api; +pub mod auth; pub mod compat; -pub mod config; pub mod logger; pub mod logic; pub mod request_info; pub mod responders; pub mod routes; pub mod ui; -pub mod auth; #[rocket::main] async fn main() { setup_logger(); - info!("loading config..."); - if let Err(e) = load_config().await { - error!("error {e:?}"); - exit(1); - } - - #[cfg(feature = "bypass-auth")] - logger::warn!("authentification bypass enabled"); - let r = build_rocket().launch().await; + let state = match create_state() { + Ok(s) => s, + Err(e) => { + error!("unable to start: {e:#}"); + exit(1); + } + }; + let r = build_rocket(state).launch().await; match r { - Ok(_) => warn!("server shutdown"), - Err(e) => error!("server exited: {e}"), + Ok(_) => info!("server shutdown"), + Err(e) => { + error!("server exited: {e}"); + exit(1); + } } } + +pub(crate) struct State { + pub config: Config, + + pub cache: Cache, + pub database: Arc<dyn Database>, + pub session_key: SessionKey, + + pub nodes: Table, + pub users: Table, +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub ui: jellyui::Config, + pub session_key: String, + pub asset_path: PathBuf, + pub database_path: PathBuf, + pub cache_path: PathBuf, + pub max_memory_cache_size: usize, + pub tls: bool, + pub hostname: String, +} + +pub fn create_state() -> Result<Arc<State>> { + let config_path = args().nth(1).unwrap(); + let config: Config = serde_yaml_ng::from_str(&read_to_string(config_path)?)?; + + let cache_storage = jellykv::rocksdb::new(&config.cache_path)?; + let db_storage = jellykv::rocksdb::new(&config.database_path)?; + + Ok(Arc::new(State { + cache: Cache::new(Box::new(cache_storage), config.max_memory_cache_size), + database: Arc::new(db_storage), + session_key: SessionKey::parse(&config.session_key)?, + nodes: Table::new(0), + users: Table::new(1), + config, + })) +} diff --git a/server/src/request_info.rs b/server/src/request_info.rs index 3a1023f..3468c58 100644 --- a/server/src/request_info.rs +++ b/server/src/request_info.rs @@ -4,8 +4,11 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::ui::error::{MyError, MyResult}; -use anyhow::anyhow; +use crate::{ + State, + auth::token_to_user, + ui::error::{MyError, MyResult}, +}; use jellycommon::jellyobject::ObjectBuffer; use jellyui::RenderInfo; use rocket::{ @@ -13,11 +16,13 @@ use rocket::{ http::{MediaType, Status}, request::{FromRequest, Outcome}, }; +use std::sync::Arc; pub struct RequestInfo<'a> { pub lang: &'a str, pub accept: Accept, pub user: Option<ObjectBuffer>, + pub state: Arc<State>, } #[async_trait] @@ -33,19 +38,20 @@ impl<'r> FromRequest<'r> for RequestInfo<'r> { impl<'a> RequestInfo<'a> { pub async fn from_request_ut(request: &'a Request<'_>) -> MyResult<Self> { + let state: &Arc<State> = request.rocket().state().unwrap(); Ok(Self { lang: accept_language(request), accept: Accept::from_request_ut(request), - user: None, - // session: session_from_request(request).await?, + user: user_from_request(state, request)?, + state: state.clone(), }) } - pub fn render_info(&self) -> RenderInfo<'a> { + pub fn render_info(&'a self) -> RenderInfo<'a> { RenderInfo { lang: self.lang, status_message: None, user: self.user.as_ref().map(|u| u.as_object()), - config: CONF.ui, + config: &self.state.config.ui, } } } @@ -77,7 +83,7 @@ impl Accept { } } -pub(super) fn accept_language<'a>(request: &'a Request<'_>) -> &'a str { +fn accept_language<'a>(request: &'a Request<'_>) -> &'a str { request .headers() .get_one("accept-language") @@ -93,27 +99,25 @@ pub(super) fn accept_language<'a>(request: &'a Request<'_>) -> &'a str { .unwrap_or("en") } -pub(super) async fn user_from_request(req: &Request<'_>) -> Result<Session, MyError> { - if cfg!(feature = "bypass-auth") { - Ok(bypass_auth_session()?) - } else { - let token = req - .query_value("session") - .map(|e| e.unwrap()) - .or_else(|| req.query_value("api_key").map(|e| e.unwrap())) - .or_else(|| req.headers().get_one("X-MediaBrowser-Token")) - .or_else(|| { - req.headers() - .get_one("Authorization") - .and_then(parse_jellyfin_auth) - }) // for jellyfin compat - .or(req.cookies().get("session").map(|cookie| cookie.value())) - .ok_or(anyhow!("not logged in"))?; +fn user_from_request(state: &State, req: &Request<'_>) -> Result<Option<ObjectBuffer>, MyError> { + let Some(token) = req + .query_value("session") + .map(|e| e.unwrap()) + .or_else(|| req.query_value("api_key").map(|e| e.unwrap())) + .or_else(|| req.headers().get_one("X-MediaBrowser-Token")) + .or_else(|| { + req.headers() + .get_one("Authorization") + .and_then(parse_jellyfin_auth) + }) // for jellyfin compat + .or(req.cookies().get("session").map(|cookie| cookie.value())) + else { + return Ok(None); + }; - // jellyfin urlescapes the token for *some* requests - let token = token.replace("%3D", "="); - Ok(token_to_user(&token)?) - } + // jellyfin urlescapes the token for *some* requests + let token = token.replace("%3D", "="); + Ok(Some(token_to_user(state, &token)?)) } fn parse_jellyfin_auth(h: &str) -> Option<&str> { diff --git a/server/src/routes.rs b/server/src/routes.rs index 3b410f7..62a70b7 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1,69 +1,49 @@ -use crate::config::CONF; /* 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) 2026 metamuffin <metamuffin.org> */ -use crate::logic::playersync::{PlayersyncChannels, r_playersync}; -use crate::ui::account::{r_account_login, r_account_logout, r_account_register}; -use crate::ui::admin::import::{r_admin_import, r_admin_import_post, r_admin_import_stream}; -use crate::ui::{ - account::{ - r_account_login_post, r_account_logout_post, r_account_register_post, - settings::{r_account_settings, r_account_settings_post}, - }, - admin::{ - log::{r_admin_log, r_admin_log_stream}, - r_admin_dashboard, r_admin_invite, r_admin_remove_invite, r_admin_update_search, - user::{r_admin_remove_user, r_admin_user, r_admin_user_permission, r_admin_users}, - }, - assets::{r_image, r_item_poster, r_node_thumbnail}, - error::{r_api_catch, r_catch}, - home::r_home, - items::r_items, - node::r_node, - player::r_player, - r_favicon, r_index, - search::r_search, - stats::r_stats, - style::{r_assets_font, r_assets_js, r_assets_js_map, r_assets_style}, -}; use crate::{ + State, api::{r_api_account_login, r_api_root, r_nodes_modified_since, r_version}, - compat::{ - // jellyfin::{ - // r_jellyfin_artists, r_jellyfin_branding_configuration, r_jellyfin_branding_css, - // r_jellyfin_displaypreferences_usersettings, - // r_jellyfin_displaypreferences_usersettings_post, r_jellyfin_items, - // r_jellyfin_items_image_primary, r_jellyfin_items_images_backdrop, - // r_jellyfin_items_intros, r_jellyfin_items_item, r_jellyfin_items_playbackinfo, - // r_jellyfin_items_similar, r_jellyfin_livetv_programs_recommended, r_jellyfin_persons, - // r_jellyfin_playback_bitratetest, r_jellyfin_quickconnect_enabled, - // r_jellyfin_sessions_capabilities_full, r_jellyfin_sessions_playing, - // r_jellyfin_sessions_playing_progress, r_jellyfin_shows_nextup, r_jellyfin_socket, - // r_jellyfin_system_endpoint, r_jellyfin_system_info, r_jellyfin_system_info_public, - // r_jellyfin_system_info_public_case, r_jellyfin_users_authenticatebyname, - // r_jellyfin_users_authenticatebyname_case, r_jellyfin_users_id, r_jellyfin_users_items, - // r_jellyfin_users_items_item, r_jellyfin_users_public, r_jellyfin_users_views, - // r_jellyfin_video_stream, - // }, - youtube::{r_youtube_channel, r_youtube_embed, r_youtube_watch}, - }, + compat::youtube::{r_youtube_channel, r_youtube_embed, r_youtube_watch}, logic::{ + playersync::{PlayersyncChannels, r_playersync}, stream::r_stream, userdata::{ r_node_userdata, r_node_userdata_progress, r_node_userdata_rating, r_node_userdata_watched, }, }, + ui::{ + account::{ + r_account_login, r_account_login_post, r_account_logout, r_account_logout_post, + r_account_register, r_account_register_post, + settings::{r_account_settings, r_account_settings_post}, + }, + admin::{ + import::{r_admin_import, r_admin_import_post, r_admin_import_stream}, + log::{r_admin_log, r_admin_log_stream}, + r_admin_dashboard, r_admin_invite, r_admin_remove_invite, r_admin_update_search, + user::{r_admin_remove_user, r_admin_user, r_admin_user_permission, r_admin_users}, + }, + assets::{r_image, r_item_poster, r_node_thumbnail}, + error::{r_api_catch, r_catch}, + home::r_home, + items::r_items, + node::r_node, + player::r_player, + r_favicon, r_index, + search::r_search, + stats::r_stats, + style::{r_assets_font, r_assets_js, r_assets_js_map, r_assets_style}, + }, }; -use base64::Engine; -use log::warn; -use rand::random; use rocket::{ - Build, Config, Rocket, catchers, config::SecretKey, fairing::AdHoc, fs::FileServer, - http::Header, routes, shield::Shield, + Build, Config, Rocket, catchers, fairing::AdHoc, fs::FileServer, http::Header, routes, + shield::Shield, }; +use std::sync::Arc; #[macro_export] macro_rules! uri { @@ -72,7 +52,7 @@ macro_rules! uri { }; } -pub fn build_rocket() -> Rocket<Build> { +pub fn build_rocket(state: Arc<State>) -> Rocket<Build> { rocket::build() .configure(Config { address: std::env::var("BIND_ADDR") @@ -81,34 +61,25 @@ pub fn build_rocket() -> Rocket<Build> { port: std::env::var("PORT") .map(|e| e.parse().unwrap()) .unwrap_or(8000), - secret_key: SecretKey::derive_from( - CONF.cookie_key - .clone() - .unwrap_or_else(|| { - warn!("cookie_key not configured, generating a random one."); - base64::engine::general_purpose::STANDARD.encode([(); 32].map(|_| random())) - }) - .as_bytes(), - ), ip_header: Some("x-real-ip".into()), ..Default::default() }) .manage(PlayersyncChannels::default()) + .manage(state.clone()) .attach(AdHoc::on_response("set server header", |_req, res| { res.set_header(Header::new("server", "jellything")); Box::pin(async {}) })) - // TODO this would be useful but needs to handle not only the entry-point - // .attach(AdHoc::on_response("frame options", |req, resp| { - // if !req.uri().path().as_str().starts_with("/embed") { - // resp.set_raw_header("X-Frame-Options", "SAMEORIGIN"); - // } - // Box::pin(async {}) - // })) + .attach(AdHoc::on_response("frame options", |req, resp| { + if !req.uri().path().as_str().starts_with("/embed") { + resp.set_raw_header("X-Frame-Options", "SAMEORIGIN"); + } + Box::pin(async {}) + })) .attach(Shield::new()) .register("/", catchers![r_catch]) .register("/api", catchers![r_api_catch]) - .mount("/assets", FileServer::from(&CONF.asset_path)) + .mount("/assets", FileServer::from(&state.config.asset_path)) .mount( "/", routes![ |