From 106ab96c563c0b601ecd3cc2ab7336ae24bfb3f1 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 17 Mar 2026 16:05:14 +0000 Subject: [PATCH] Improve web UI --- Cargo.lock | 18 +++-- furumi-client-core/Cargo.toml | 2 +- furumi-common/Cargo.toml | 2 +- furumi-mount-linux/Cargo.toml | 2 +- furumi-mount-macos/Cargo.toml | 2 +- furumi-server/Cargo.toml | 4 +- furumi-server/src/main.rs | 6 +- furumi-server/src/web/auth.rs | 113 +++++++++++++++++++-------- furumi-server/src/web/meta.rs | 39 ++++++++-- furumi-server/src/web/mod.rs | 4 +- furumi-server/src/web/player.html | 125 +++++++++++++++++++++++++++--- 11 files changed, 258 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45f1501..ecc2c7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,7 +910,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "furumi-client-core" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "async-trait", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "furumi-common" -version = "0.3.2" +version = "0.3.3" dependencies = [ "prost", "protobuf-src", @@ -942,7 +942,7 @@ dependencies = [ [[package]] name = "furumi-mount-linux" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "clap", @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "furumi-mount-macos" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "async-trait", @@ -977,7 +977,7 @@ dependencies = [ [[package]] name = "furumi-server" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "async-stream", @@ -986,6 +986,7 @@ dependencies = [ "base64 0.22.1", "bytes", "clap", + "encoding_rs", "furumi-common", "futures", "futures-core", @@ -1017,6 +1018,7 @@ dependencies = [ "tower 0.4.13", "tracing", "tracing-subscriber", + "urlencoding", ] [[package]] @@ -3949,6 +3951,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/furumi-client-core/Cargo.toml b/furumi-client-core/Cargo.toml index 18ca4d2..5953767 100644 --- a/furumi-client-core/Cargo.toml +++ b/furumi-client-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi-client-core" -version = "0.3.3" +version = "0.3.4" edition = "2024" [dependencies] diff --git a/furumi-common/Cargo.toml b/furumi-common/Cargo.toml index 4f2b3ed..d71737c 100644 --- a/furumi-common/Cargo.toml +++ b/furumi-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi-common" -version = "0.3.3" +version = "0.3.4" edition = "2024" [dependencies] diff --git a/furumi-mount-linux/Cargo.toml b/furumi-mount-linux/Cargo.toml index 41368f0..1186504 100644 --- a/furumi-mount-linux/Cargo.toml +++ b/furumi-mount-linux/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi-mount-linux" -version = "0.3.3" +version = "0.3.4" edition = "2024" [dependencies] diff --git a/furumi-mount-macos/Cargo.toml b/furumi-mount-macos/Cargo.toml index d5f3fe2..277960a 100644 --- a/furumi-mount-macos/Cargo.toml +++ b/furumi-mount-macos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi-mount-macos" -version = "0.3.3" +version = "0.3.4" edition = "2024" [dependencies] diff --git a/furumi-server/Cargo.toml b/furumi-server/Cargo.toml index 38f5546..0d2bfcc 100644 --- a/furumi-server/Cargo.toml +++ b/furumi-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi-server" -version = "0.3.3" +version = "0.3.4" edition = "2024" [dependencies] @@ -41,6 +41,8 @@ openidconnect = "3.4" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } hmac = "0.12" rand = "0.8" +encoding_rs = "0.8" +urlencoding = "2.1.3" [dev-dependencies] tempfile = "3.26.0" diff --git a/furumi-server/src/main.rs b/furumi-server/src/main.rs index 3f4157a..7af2666 100644 --- a/furumi-server/src/main.rs +++ b/furumi-server/src/main.rs @@ -61,6 +61,10 @@ struct Args { /// OIDC Redirect URL (e.g. https://music.example.com/auth/callback) #[arg(long, env = "FURUMI_OIDC_REDIRECT_URL")] oidc_redirect_url: Option, + + /// OIDC Session Secret (32+ chars, for HMAC). If not provided, a random one is generated on startup. + #[arg(long, env = "FURUMI_OIDC_SESSION_SECRET")] + oidc_session_secret: Option, } async fn metrics_handler() -> String { @@ -140,7 +144,7 @@ async fn main() -> Result<(), Box> { args.oidc_redirect_url, ) { println!("OIDC (SSO): enabled for web UI (issuer: {})", issuer); - match web::auth::oidc_init(issuer, client_id, secret, redirect).await { + match web::auth::oidc_init(issuer, client_id, secret, redirect, args.oidc_session_secret).await { Ok(state) => Some(Arc::new(state)), Err(e) => { eprintln!("Error initializing OIDC client: {}", e); diff --git a/furumi-server/src/web/auth.rs b/furumi-server/src/web/auth.rs index 626c309..4fe362b 100644 --- a/furumi-server/src/web/auth.rs +++ b/furumi-server/src/web/auth.rs @@ -1,29 +1,36 @@ use axum::{ body::Body, - extract::{Request, State}, - http::{HeaderMap, StatusCode, header}, + extract::{Form, Request, State}, + http::{header, HeaderMap, StatusCode}, middleware::Next, response::{Html, IntoResponse, Redirect, Response}, - Form, }; -use sha2::{Digest, Sha256}; -use serde::Deserialize; - -use base64::Engine; -use hmac::{Hmac, Mac}; -use rand::RngCore; -use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; -use openidconnect::reqwest::async_http_client; use openidconnect::{ + core::{CoreClient, CoreProviderMetadata, CoreResponseType}, + reqwest::async_http_client, AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, }; +use rand::RngCore; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use base64::Engine; +use hmac::{Hmac, Mac}; use super::{OidcState, WebState}; /// Cookie name used to store the session token. const SESSION_COOKIE: &str = "furumi_session"; +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + /// Compute SHA-256 of the token as hex string (stored in cookie, not raw token). pub fn token_hash(token: &str) -> String { let mut h = Sha256::new(); @@ -69,11 +76,12 @@ pub async fn require_auth( req.extensions_mut().insert(super::AuthUserInfo(user)); next.run(req).await } else { - let uri = req.uri().path(); + let uri = req.uri().to_string(); if uri.starts_with("/api/") { (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } else { - Redirect::to("/login").into_response() + let redirect_url = format!("/login?next={}", urlencoding::encode(&uri)); + Redirect::to(&redirect_url).into_response() } } } @@ -106,8 +114,16 @@ pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { } } +#[derive(Deserialize)] +pub struct LoginQuery { + pub next: Option, +} + /// GET /login β€” show login form. -pub async fn login_page(State(state): State) -> impl IntoResponse { +pub async fn login_page( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> impl IntoResponse { let token_enabled = !state.token.is_empty(); let oidc_enabled = state.oidc.is_some(); @@ -115,21 +131,31 @@ pub async fn login_page(State(state): State) -> impl IntoResponse { return Redirect::to("/").into_response(); } - let html = LOGIN_HTML.replace( - "", - if oidc_enabled { + let next_val = query.next.unwrap_or_else(|| "/".to_string()); + let next_encoded = urlencoding::encode(&next_val); + + let oidc_html = if oidc_enabled { + format!( r#"
OR
- Log in with Authentik (SSO)"# - } else { - "" - } - ); + Log in with Authentik (SSO)"#, + next_encoded + ) + } else { + "".to_string() + }; + + let next_input = format!(r#""#, esc(&next_val)); + + let html = LOGIN_HTML + .replace("", &oidc_html) + .replace("", &next_input); Html(html).into_response() } #[derive(Deserialize)] pub struct LoginForm { password: String, + next: Option, } /// POST /login β€” validate password, set session cookie. @@ -144,12 +170,13 @@ pub async fn login_submit( if form.password == *state.token { let hash = token_hash(&state.token); let cookie = format!( - "{}={}; HttpOnly; SameSite=Strict; Path=/", + "{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", SESSION_COOKIE, hash ); + let redirect_to = form.next.as_deref().unwrap_or("/"); let mut headers = HeaderMap::new(); headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); - headers.insert(header::LOCATION, "/".parse().unwrap()); + headers.insert(header::LOCATION, redirect_to.parse().unwrap()); (StatusCode::FOUND, headers, Body::empty()).into_response() } else { Html(LOGIN_ERROR_HTML).into_response() @@ -173,6 +200,7 @@ pub async fn oidc_init( client_id: String, client_secret: String, redirect: String, + session_secret_override: Option, ) -> anyhow::Result { let provider_metadata = CoreProviderMetadata::discover_async( IssuerUrl::new(issuer)?, @@ -188,8 +216,15 @@ pub async fn oidc_init( .set_auth_type(openidconnect::AuthType::RequestBody) .set_redirect_uri(RedirectUrl::new(redirect)?); - let mut session_secret = vec![0u8; 32]; - rand::thread_rng().fill_bytes(&mut session_secret); + let session_secret = if let Some(s) = session_secret_override { + let mut b = s.into_bytes(); + b.resize(32, 0); // Ensure at least 32 bytes for HMAC-SHA256 + b + } else { + let mut b = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut b); + b + }; Ok(OidcState { client, @@ -199,6 +234,7 @@ pub async fn oidc_init( pub async fn oidc_login( State(state): State, + axum::extract::Query(query): axum::extract::Query, req: Request, ) -> impl IntoResponse { let oidc = match &state.oidc { @@ -220,7 +256,8 @@ pub async fn oidc_login( .set_pkce_challenge(pkce_challenge) .url(); - let cookie_val = format!("{}:{}:{}", csrf_token.secret(), nonce.secret(), pkce_verifier.secret()); + let next_url = query.next.unwrap_or_else(|| "/".to_string()); + let cookie_val = format!("{}:{}:{}:{}", csrf_token.secret(), nonce.secret(), pkce_verifier.secret(), urlencoding::encode(&next_url)); // Determine if we are running behind an HTTPS proxy let is_https = req.headers().get("x-forwarded-proto") @@ -320,8 +357,20 @@ pub async fn oidc_callback( Err(e) => return (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)).into_response(), }; - let subject = claims.subject().as_str(); - let session_val = generate_sso_cookie(&oidc.session_secret, subject); + let user_id = claims + .preferred_username() + .map(|u| u.to_string()) + .or_else(|| claims.email().map(|e| e.to_string())) + .unwrap_or_else(|| claims.subject().to_string()); + + let session_val = generate_sso_cookie(&oidc.session_secret, &user_id); + + let parts: Vec<&str> = cookie_val.split(':').collect(); + let redirect_to = parts.get(3) + .and_then(|&s| urlencoding::decode(s).ok()) + .map(|v| v.into_owned()) + .unwrap_or_else(|| "/".to_string()); + let redirect_to = if redirect_to.is_empty() { "/".to_string() } else { redirect_to }; let is_https = req.headers().get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) @@ -334,13 +383,13 @@ pub async fn oidc_callback( "SameSite=Strict" }; - let session_cookie = format!("{}={}; HttpOnly; {}; Path=/", SESSION_COOKIE, session_val, session_attrs); + let session_cookie = format!("{}={}; HttpOnly; {}; Path=/; Max-Age=604800", SESSION_COOKIE, session_val, session_attrs); let clear_state_cookie = "furumi_oidc_state=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"; let mut headers = HeaderMap::new(); headers.insert(header::SET_COOKIE, session_cookie.parse().unwrap()); headers.append(header::SET_COOKIE, clear_state_cookie.parse().unwrap()); - headers.insert(header::LOCATION, "/".parse().unwrap()); + headers.insert(header::LOCATION, redirect_to.parse().unwrap()); (StatusCode::FOUND, headers, Body::empty()).into_response() } @@ -407,6 +456,7 @@ const LOGIN_HTML: &str = r#"
Enter access token to continue
+ @@ -461,6 +511,7 @@ const LOGIN_ERROR_HTML: &str = r#"
Enter access token to continue

❌ Invalid token. Please try again.

+ diff --git a/furumi-server/src/web/meta.rs b/furumi-server/src/web/meta.rs index ef3f106..919ac55 100644 --- a/furumi-server/src/web/meta.rs +++ b/furumi-server/src/web/meta.rs @@ -137,28 +137,29 @@ fn extract_tags( cover: &mut Option<(Vec, String)>, ) { for tag in tags { + let value = fix_encoding(tag.value.to_string()); if let Some(key) = tag.std_key { match key { StandardTagKey::TrackTitle => { - *title = Some(tag.value.to_string()); + *title = Some(value); } StandardTagKey::Artist | StandardTagKey::Performer => { if artist.is_none() { - *artist = Some(tag.value.to_string()); + *artist = Some(value); } } StandardTagKey::Album => { - *album = Some(tag.value.to_string()); + *album = Some(value); } StandardTagKey::TrackNumber => { if track.is_none() { - *track = tag.value.to_string().parse().ok(); + *track = value.parse().ok(); } } StandardTagKey::Date | StandardTagKey::OriginalDate => { if year.is_none() { // Parse first 4 characters as year - *year = tag.value.to_string()[..4.min(tag.value.to_string().len())].parse().ok(); + *year = value[..4.min(value.len())].parse().ok(); } } _ => {} @@ -173,3 +174,31 @@ fn extract_tags( } } } + +/// Heuristic to fix mojibake (CP1251 bytes interpreted as Latin-1/Windows-1252) +fn fix_encoding(s: String) -> String { + // If it's already a valid UTF-8 string that doesn't look like mojibake, return it. + // Mojibake looks like characters from Latin-1 Supplement (0xC0-0xFF) + // where they should be Cyrillic. + + let bytes: Vec = s.chars().map(|c| c as u32).filter(|&c| c <= 255).map(|c| c as u8).collect(); + + // If the length is different, it means there were characters > 255, so it's not simple Latin-1 mojibake. + if bytes.len() != s.chars().count() { + return s; + } + + // Check if it's likely CP1251. Russian characters in CP1251 are 0xC0-0xFF. + // In Latin-1 these are characters like Γ€-ΓΏ. + let has_mojibake = bytes.iter().any(|&b| b >= 0xC0); + if !has_mojibake { + return s; + } + + let (decoded, _, errors) = encoding_rs::WINDOWS_1251.decode(&bytes); + if errors { + return s; + } + + decoded.into_owned() +} diff --git a/furumi-server/src/web/mod.rs b/furumi-server/src/web/mod.rs index d2f4a2c..8efa678 100644 --- a/furumi-server/src/web/mod.rs +++ b/furumi-server/src/web/mod.rs @@ -59,6 +59,8 @@ pub struct AuthUserInfo(pub String); async fn player_html( axum::extract::Extension(user_info): axum::extract::Extension, ) -> axum::response::Html { - let html = include_str!("player.html").replace("", &user_info.0); + let html = include_str!("player.html") + .replace("", &user_info.0) + .replace("", env!("CARGO_PKG_VERSION")); axum::response::Html(html) } diff --git a/furumi-server/src/web/player.html b/furumi-server/src/web/player.html index 7a56701..b61931e 100644 --- a/furumi-server/src/web/player.html +++ b/furumi-server/src/web/player.html @@ -47,11 +47,23 @@ body { flex-shrink: 0; z-index: 10; } -.header-logo { - display: flex; align-items: center; gap: 0.5rem; - font-size: 1.15rem; font-weight: 700; color: var(--accent); -} -.header-logo svg { width: 22px; height: 22px; } + .header-logo { display: flex; align-items: center; gap: 0.75rem; font-weight: 700; font-size: 1.1rem; } + .header-logo svg { width: 22px; height: 22px; color: var(--primary); } + .header-version { + font-size: 0.7rem; + color: var(--text-muted); + text-decoration: none; + background: rgba(255,255,255,0.05); + padding: 0.1rem 0.4rem; + border-radius: 4px; + margin-left: 0.25rem; + font-weight: 500; + transition: all 0.2s; + } + .header-version:hover { + color: var(--primary); + background: rgba(124, 106, 247, 0.15); + } .btn-logout { font-size: 0.78rem; color: var(--text-muted); background: none; border: 1px solid var(--border); border-radius: 6px; @@ -339,6 +351,37 @@ body { } .toast.show { opacity: 1; transform: translateY(0); } +/* ─── Cover Preview ─── */ +.cover-preview-overlay { + position: fixed; + bottom: 80px; + left: 1.5rem; + width: 300px; + height: 300px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0,0,0,0.7); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: translateY(10px) scale(0.95); + pointer-events: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 50; +} +.cover-preview-overlay.show { + opacity: 1; + transform: translateY(0) scale(1); +} +.cover-preview-overlay img { + width: 100%; + height: 100%; + object-fit: cover; +} + /* ─── Responsive (Mobile) ─── */ @media (max-width: 768px) { .btn-menu { display: inline-block; } @@ -375,6 +418,7 @@ body { Furumi Player + v
@@ -421,7 +465,8 @@ body {
-
🎡
+
🎡
+
Nothing playing
β€”
@@ -673,14 +718,49 @@ function playIndex(i) { } function updateNowPlaying(track) { - document.getElementById('npTitle').textContent = track.meta?.title || displayName(track.name); - document.getElementById('npArtist').textContent = track.meta?.artist || 'β€”'; - if (track.meta?.cover_base64) { - document.getElementById('npCover').innerHTML = `cover`; + const title = track.meta?.title || displayName(track.name); + const artist = track.meta?.artist || 'β€”'; + const coverBase64 = track.meta?.cover_base64; + + document.getElementById('npTitle').textContent = title; + document.getElementById('npArtist').textContent = artist; + if (coverBase64) { + document.getElementById('npCover').innerHTML = `cover`; } else { document.getElementById('npCover').textContent = '🎡'; } - document.title = (track.meta?.title || displayName(track.name)) + ' β€” Furumi Player'; + document.title = title + ' β€” Furumi Player'; + + // Sync URL + history.replaceState(null, "", "?t=" + encodeURIComponent(track.path)); + + // Update OS Media Session (Hardware keys & OS Player UI) + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: title, + artist: artist !== 'β€”' ? artist : '', + album: track.meta?.album || 'Furumi', + artwork: coverBase64 ? [{ src: coverBase64, sizes: '512x512' }] : [] + }); + } + + // Update Preview + const preview = document.getElementById('coverPreview'); + if (coverBase64) { + preview.innerHTML = `cover full`; + } else { + preview.innerHTML = ''; + } +} + +function showCoverPreview(show) { + const preview = document.getElementById('coverPreview'); + const hasImage = preview.querySelector('img'); + if (show && hasImage) { + preview.classList.add('show'); + } else { + preview.classList.remove('show'); + } } async function loadMeta(track) { @@ -961,6 +1041,29 @@ function logout() { } // ─── Init ───────────────────────────────────────────────────────────────────── +if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', togglePlay); + navigator.mediaSession.setActionHandler('pause', togglePlay); + navigator.mediaSession.setActionHandler('previoustrack', prevTrack); + navigator.mediaSession.setActionHandler('nexttrack', nextTrack); + navigator.mediaSession.setActionHandler('seekto', (details) => { + if (details.fastSeek && 'fastSeek' in audio) { + audio.fastSeek(details.seekTime); + return; + } + audio.currentTime = details.seekTime; + }); +} + +// Deep linking on load +const urlParams = new URLSearchParams(window.location.search); +const trackPath = urlParams.get('t'); +if (trackPath) { + const parts = trackPath.split('/'); + const name = parts[parts.length - 1]; + addToQueue(trackPath, name, true); +} + navigate('');