7 Commits

Author SHA1 Message Date
Ultradesu
722183047d Improve web UI
Some checks failed
Build and Publish Deb Package / build-deb (push) Failing after 33s
Publish Server Image / build-and-push-image (push) Successful in 2m11s
2026-03-17 16:16:43 +00:00
Ultradesu
106ab96c56 Improve web UI
All checks were successful
Build and Publish Deb Package / build-deb (push) Successful in 35s
Publish Server Image / build-and-push-image (push) Successful in 2m9s
2026-03-17 16:05:14 +00:00
Ultradesu
cbc5639f99 Fixed UI
All checks were successful
Build and Publish Deb Package / build-deb (push) Successful in 47s
Publish Server Image / build-and-push-image (push) Successful in 2m19s
2026-03-17 15:17:30 +00:00
Ultradesu
754097f894 Fixed OIDC
All checks were successful
Build and Publish Deb Package / build-deb (push) Successful in 2m10s
Publish Server Image / build-and-push-image (push) Successful in 4m43s
2026-03-17 15:04:04 +00:00
Ultradesu
b761245fd0 Fixed OIDC 2026-03-17 15:03:36 +00:00
Ultradesu
0f49d8d079 Furumi: Added web ui with OIDC SSO
All checks were successful
Build and Publish Deb Package / build-deb (push) Successful in 59s
Publish Server Image / build-and-push-image (push) Successful in 4m28s
2026-03-17 14:53:16 +00:00
Ultradesu
a17ff322ad Added web ui with OIDC SSO
All checks were successful
Build and Publish Deb Package / build-deb (push) Successful in 1m3s
Publish Server Image / build-and-push-image (push) Successful in 11m44s
2026-03-17 14:25:58 +00:00
13 changed files with 349 additions and 77 deletions

18
Cargo.lock generated
View File

@@ -910,7 +910,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "furumi-client-core" name = "furumi-client-core"
version = "0.2.1" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -932,7 +932,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-common" name = "furumi-common"
version = "0.2.1" version = "0.3.4"
dependencies = [ dependencies = [
"prost", "prost",
"protobuf-src", "protobuf-src",
@@ -942,7 +942,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-mount-linux" name = "furumi-mount-linux"
version = "0.2.1" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -959,7 +959,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-mount-macos" name = "furumi-mount-macos"
version = "0.2.1" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -977,7 +977,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-server" name = "furumi-server"
version = "0.2.1" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@@ -986,6 +986,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"clap", "clap",
"encoding_rs",
"furumi-common", "furumi-common",
"futures", "futures",
"futures-core", "futures-core",
@@ -1017,6 +1018,7 @@ dependencies = [
"tower 0.4.13", "tower 0.4.13",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"urlencoding",
] ]
[[package]] [[package]]
@@ -3949,6 +3951,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "furumi-client-core" name = "furumi-client-core"
version = "0.2.2" version = "0.3.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "furumi-common" name = "furumi-common"
version = "0.2.2" version = "0.3.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "furumi-mount-linux" name = "furumi-mount-linux"
version = "0.2.2" version = "0.3.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -75,7 +75,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
MountOption::NoExec, // Better security for media mount MountOption::NoExec, // Better security for media mount
]; ];
println!("Mounting Furumi-ng to {:?}", args.mount); println!("Mounting Furumi-ng v{} to {:?}", env!("CARGO_PKG_VERSION"), args.mount);
// Use Session + BackgroundSession for graceful unmount on exit // Use Session + BackgroundSession for graceful unmount on exit
let session = Session::new(fuse_fs, &args.mount, &options)?; let session = Session::new(fuse_fs, &args.mount, &options)?;

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "furumi-mount-macos" name = "furumi-mount-macos"
version = "0.2.2" version = "0.3.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -108,7 +108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); std::process::exit(1);
} }
println!("Mounted Furumi-ng to {:?}", mount_path); println!("Mounted Furumi-ng v{} to {:?}", env!("CARGO_PKG_VERSION"), mount_path);
// Wait for shutdown signal // Wait for shutdown signal
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "furumi-server" name = "furumi-server"
version = "0.2.2" version = "0.3.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -41,6 +41,8 @@ openidconnect = "3.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
hmac = "0.12" hmac = "0.12"
rand = "0.8" rand = "0.8"
encoding_rs = "0.8"
urlencoding = "2.1.3"
[dev-dependencies] [dev-dependencies]
tempfile = "3.26.0" tempfile = "3.26.0"

View File

@@ -61,6 +61,10 @@ struct Args {
/// OIDC Redirect URL (e.g. https://music.example.com/auth/callback) /// OIDC Redirect URL (e.g. https://music.example.com/auth/callback)
#[arg(long, env = "FURUMI_OIDC_REDIRECT_URL")] #[arg(long, env = "FURUMI_OIDC_REDIRECT_URL")]
oidc_redirect_url: Option<String>, oidc_redirect_url: Option<String>,
/// 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<String>,
} }
async fn metrics_handler() -> String { async fn metrics_handler() -> String {
@@ -104,7 +108,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let svc = RemoteFileSystemServer::with_interceptor(remote_fs, auth.clone()); let svc = RemoteFileSystemServer::with_interceptor(remote_fs, auth.clone());
// Print startup info // Print startup info
println!("Furumi-ng Server listening on {}", addr); println!("Furumi-ng Server v{} listening on {}", env!("CARGO_PKG_VERSION"), addr);
if args.no_tls { if args.no_tls {
println!("WARNING: TLS is DISABLED — traffic is unencrypted"); println!("WARNING: TLS is DISABLED — traffic is unencrypted");
} else { } else {
@@ -140,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
args.oidc_redirect_url, args.oidc_redirect_url,
) { ) {
println!("OIDC (SSO): enabled for web UI (issuer: {})", issuer); 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)), Ok(state) => Some(Arc::new(state)),
Err(e) => { Err(e) => {
eprintln!("Error initializing OIDC client: {}", e); eprintln!("Error initializing OIDC client: {}", e);

View File

@@ -1,29 +1,36 @@
use axum::{ use axum::{
body::Body, body::Body,
extract::{Request, State}, extract::{Form, Request, State},
http::{HeaderMap, StatusCode, header}, http::{header, HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::{Html, IntoResponse, Redirect, Response}, 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::{ use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client,
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, 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}; use super::{OidcState, WebState};
/// Cookie name used to store the session token. /// Cookie name used to store the session token.
const SESSION_COOKIE: &str = "furumi_session"; const SESSION_COOKIE: &str = "furumi_session";
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// Compute SHA-256 of the token as hex string (stored in cookie, not raw token). /// Compute SHA-256 of the token as hex string (stored in cookie, not raw token).
pub fn token_hash(token: &str) -> String { pub fn token_hash(token: &str) -> String {
let mut h = Sha256::new(); let mut h = Sha256::new();
@@ -31,14 +38,14 @@ pub fn token_hash(token: &str) -> String {
format!("{:x}", h.finalize()) format!("{:x}", h.finalize())
} }
/// axum middleware: if token is configured, requires a valid session cookie.
pub async fn require_auth( pub async fn require_auth(
State(state): State<WebState>, State(state): State<WebState>,
req: Request, mut req: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
// Auth disabled when token is empty // Auth disabled when token is empty
if state.token.is_empty() { if state.token.is_empty() {
req.extensions_mut().insert(super::AuthUserInfo("Unauthenticated".to_string()));
return next.run(req).await; return next.run(req).await;
} }
@@ -49,30 +56,32 @@ pub async fn require_auth(
.unwrap_or(""); .unwrap_or("");
let expected = token_hash(&state.token); let expected = token_hash(&state.token);
let mut authed = false; let mut authed_user = None;
for c in cookies.split(';') { for c in cookies.split(';') {
let c = c.trim(); let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if val == expected { if val == expected {
authed = true; authed_user = Some("Master Token".to_string());
break; break;
} else if let Some(oidc) = &state.oidc { } else if let Some(oidc) = &state.oidc {
if verify_sso_cookie(&oidc.session_secret, val) { if let Some(user) = verify_sso_cookie(&oidc.session_secret, val) {
authed = true; authed_user = Some(user);
break; break;
} }
} }
} }
} }
if authed { if let Some(user) = authed_user {
req.extensions_mut().insert(super::AuthUserInfo(user));
next.run(req).await next.run(req).await
} else { } else {
let uri = req.uri().path(); let uri = req.uri().to_string();
if uri.starts_with("/api/") { if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response() (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else { } else {
Redirect::to("/login").into_response() let redirect_url = format!("/login?next={}", urlencoding::encode(&uri));
Redirect::to(&redirect_url).into_response()
} }
} }
} }
@@ -86,10 +95,10 @@ pub fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
format!("sso:{}:{}", user_id, sig) format!("sso:{}:{}", user_id, sig)
} }
pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> bool { pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
let parts: Vec<&str> = cookie_val.split(':').collect(); let parts: Vec<&str> = cookie_val.split(':').collect();
if parts.len() != 3 || parts[0] != "sso" { if parts.len() != 3 || parts[0] != "sso" {
return false; return None;
} }
let user_id = parts[1]; let user_id = parts[1];
let sig = parts[2]; let sig = parts[2];
@@ -98,11 +107,23 @@ pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> bool {
mac.update(user_id.as_bytes()); mac.update(user_id.as_bytes());
let expected_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); let expected_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
sig == expected_sig if sig == expected_sig {
Some(user_id.to_string())
} else {
None
}
}
#[derive(Deserialize)]
pub struct LoginQuery {
pub next: Option<String>,
} }
/// GET /login — show login form. /// GET /login — show login form.
pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse { pub async fn login_page(
State(state): State<WebState>,
axum::extract::Query(query): axum::extract::Query<LoginQuery>,
) -> impl IntoResponse {
let token_enabled = !state.token.is_empty(); let token_enabled = !state.token.is_empty();
let oidc_enabled = state.oidc.is_some(); let oidc_enabled = state.oidc.is_some();
@@ -110,21 +131,31 @@ pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
let html = LOGIN_HTML.replace( let next_val = query.next.unwrap_or_else(|| "/".to_string());
"<!-- OIDC_PLACEHOLDER -->", let next_encoded = urlencoding::encode(&next_val);
if oidc_enabled {
let oidc_html = if oidc_enabled {
format!(
r#"<div class="divider"><span>OR</span></div> r#"<div class="divider"><span>OR</span></div>
<a href="/auth/login" class="btn-oidc">Log in with Authentik (SSO)</a>"# <a href="/auth/login?next={}" class="btn-oidc">Log in with Authentik (SSO)</a>"#,
next_encoded
)
} else { } else {
"" "".to_string()
} };
);
let next_input = format!(r#"<input type="hidden" name="next" value="{}">"#, esc(&next_val));
let html = LOGIN_HTML
.replace("<!-- OIDC_PLACEHOLDER -->", &oidc_html)
.replace("<!-- NEXT_INPUT_PLACEHOLDER -->", &next_input);
Html(html).into_response() Html(html).into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LoginForm { pub struct LoginForm {
password: String, password: String,
next: Option<String>,
} }
/// POST /login — validate password, set session cookie. /// POST /login — validate password, set session cookie.
@@ -139,12 +170,13 @@ pub async fn login_submit(
if form.password == *state.token { if form.password == *state.token {
let hash = token_hash(&state.token); let hash = token_hash(&state.token);
let cookie = format!( let cookie = format!(
"{}={}; HttpOnly; SameSite=Strict; Path=/", "{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
SESSION_COOKIE, hash SESSION_COOKIE, hash
); );
let redirect_to = form.next.as_deref().unwrap_or("/");
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); 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() (StatusCode::FOUND, headers, Body::empty()).into_response()
} else { } else {
Html(LOGIN_ERROR_HTML).into_response() Html(LOGIN_ERROR_HTML).into_response()
@@ -168,6 +200,7 @@ pub async fn oidc_init(
client_id: String, client_id: String,
client_secret: String, client_secret: String,
redirect: String, redirect: String,
session_secret_override: Option<String>,
) -> anyhow::Result<OidcState> { ) -> anyhow::Result<OidcState> {
let provider_metadata = CoreProviderMetadata::discover_async( let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(issuer)?, IssuerUrl::new(issuer)?,
@@ -180,10 +213,18 @@ pub async fn oidc_init(
ClientId::new(client_id), ClientId::new(client_id),
Some(ClientSecret::new(client_secret)), Some(ClientSecret::new(client_secret)),
) )
.set_auth_type(openidconnect::AuthType::RequestBody)
.set_redirect_uri(RedirectUrl::new(redirect)?); .set_redirect_uri(RedirectUrl::new(redirect)?);
let mut session_secret = vec![0u8; 32]; let session_secret = if let Some(s) = session_secret_override {
rand::thread_rng().fill_bytes(&mut session_secret); 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 { Ok(OidcState {
client, client,
@@ -193,6 +234,7 @@ pub async fn oidc_init(
pub async fn oidc_login( pub async fn oidc_login(
State(state): State<WebState>, State(state): State<WebState>,
axum::extract::Query(query): axum::extract::Query<LoginQuery>,
req: Request, req: Request,
) -> impl IntoResponse { ) -> impl IntoResponse {
let oidc = match &state.oidc { let oidc = match &state.oidc {
@@ -214,7 +256,8 @@ pub async fn oidc_login(
.set_pkce_challenge(pkce_challenge) .set_pkce_challenge(pkce_challenge)
.url(); .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 // Determine if we are running behind an HTTPS proxy
let is_https = req.headers().get("x-forwarded-proto") let is_https = req.headers().get("x-forwarded-proto")
@@ -267,7 +310,7 @@ pub async fn oidc_callback(
let c = c.trim(); let c = c.trim();
if let Some(val) = c.strip_prefix("furumi_oidc_state=") { if let Some(val) = c.strip_prefix("furumi_oidc_state=") {
let parts: Vec<&str> = val.split(':').collect(); let parts: Vec<&str> = val.split(':').collect();
if parts.len() == 3 && parts[0] == query.state { if parts.len() >= 3 && parts[0] == query.state {
matching_val = Some(val.to_string()); matching_val = Some(val.to_string());
break; break;
} }
@@ -295,7 +338,13 @@ pub async fn oidc_callback(
let token_response = match token_response { let token_response = match token_response {
Ok(tr) => tr, Ok(tr) => tr,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("OIDC error: {}", e)).into_response(), Err(e) => {
tracing::error!("OIDC exchange code error: {:?}", e);
if let openidconnect::RequestTokenError::ServerResponse(err) = &e {
tracing::error!("OIDC Server returned error: {:?}", err);
}
return (StatusCode::INTERNAL_SERVER_ERROR, format!("OIDC error: {}", e)).into_response();
}
}; };
let id_token = match token_response.id_token() { let id_token = match token_response.id_token() {
@@ -308,8 +357,20 @@ pub async fn oidc_callback(
Err(e) => return (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)).into_response(), Err(e) => return (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)).into_response(),
}; };
let subject = claims.subject().as_str(); let user_id = claims
let session_val = generate_sso_cookie(&oidc.session_secret, subject); .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") let is_https = req.headers().get("x-forwarded-proto")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@@ -322,13 +383,13 @@ pub async fn oidc_callback(
"SameSite=Strict" "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 clear_state_cookie = "furumi_oidc_state=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT";
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, session_cookie.parse().unwrap()); headers.insert(header::SET_COOKIE, session_cookie.parse().unwrap());
headers.append(header::SET_COOKIE, clear_state_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() (StatusCode::FOUND, headers, Body::empty()).into_response()
} }
@@ -395,6 +456,7 @@ const LOGIN_HTML: &str = r#"<!DOCTYPE html>
<div class="logo">🎵 Furumi</div> <div class="logo">🎵 Furumi</div>
<div class="subtitle">Enter access token to continue</div> <div class="subtitle">Enter access token to continue</div>
<form method="POST" action="/login"> <form method="POST" action="/login">
<!-- NEXT_INPUT_PLACEHOLDER -->
<label for="password">Access Token</label> <label for="password">Access Token</label>
<input type="password" id="password" name="password" autofocus autocomplete="current-password"> <input type="password" id="password" name="password" autofocus autocomplete="current-password">
<button type="submit">Sign In</button> <button type="submit">Sign In</button>
@@ -449,6 +511,7 @@ const LOGIN_ERROR_HTML: &str = r#"<!DOCTYPE html>
<div class="subtitle">Enter access token to continue</div> <div class="subtitle">Enter access token to continue</div>
<p class="error">❌ Invalid token. Please try again.</p> <p class="error">❌ Invalid token. Please try again.</p>
<form method="POST" action="/login"> <form method="POST" action="/login">
<!-- NEXT_INPUT_PLACEHOLDER -->
<label for="password">Access Token</label> <label for="password">Access Token</label>
<input type="password" id="password" name="password" autofocus> <input type="password" id="password" name="password" autofocus>
<button type="submit">Sign In</button> <button type="submit">Sign In</button>

View File

@@ -137,28 +137,29 @@ fn extract_tags(
cover: &mut Option<(Vec<u8>, String)>, cover: &mut Option<(Vec<u8>, String)>,
) { ) {
for tag in tags { for tag in tags {
let value = fix_encoding(tag.value.to_string());
if let Some(key) = tag.std_key { if let Some(key) = tag.std_key {
match key { match key {
StandardTagKey::TrackTitle => { StandardTagKey::TrackTitle => {
*title = Some(tag.value.to_string()); *title = Some(value);
} }
StandardTagKey::Artist | StandardTagKey::Performer => { StandardTagKey::Artist | StandardTagKey::Performer => {
if artist.is_none() { if artist.is_none() {
*artist = Some(tag.value.to_string()); *artist = Some(value);
} }
} }
StandardTagKey::Album => { StandardTagKey::Album => {
*album = Some(tag.value.to_string()); *album = Some(value);
} }
StandardTagKey::TrackNumber => { StandardTagKey::TrackNumber => {
if track.is_none() { if track.is_none() {
*track = tag.value.to_string().parse().ok(); *track = value.parse().ok();
} }
} }
StandardTagKey::Date | StandardTagKey::OriginalDate => { StandardTagKey::Date | StandardTagKey::OriginalDate => {
if year.is_none() { if year.is_none() {
// Parse first 4 characters as year // 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<u8> = 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()
}

View File

@@ -53,6 +53,14 @@ pub fn build_router(root: PathBuf, token: String, oidc: Option<Arc<OidcState>>)
.with_state(state) .with_state(state)
} }
async fn player_html() -> axum::response::Html<&'static str> { #[derive(Clone)]
axum::response::Html(include_str!("player.html")) pub struct AuthUserInfo(pub String);
async fn player_html(
axum::extract::Extension(user_info): axum::extract::Extension<AuthUserInfo>,
) -> axum::response::Html<String> {
let html = include_str!("player.html")
.replace("<!-- USERNAME_PLACEHOLDER -->", &user_info.0)
.replace("<!-- VERSION_PLACEHOLDER -->", env!("CARGO_PKG_VERSION"));
axum::response::Html(html)
} }

View File

@@ -47,25 +47,53 @@ body {
flex-shrink: 0; flex-shrink: 0;
z-index: 10; z-index: 10;
} }
.header-logo { .header-logo { display: flex; align-items: center; gap: 0.75rem; font-weight: 700; font-size: 1.1rem; }
display: flex; align-items: center; gap: 0.5rem; .header-logo svg { width: 22px; height: 22px; color: var(--primary); }
font-size: 1.15rem; font-weight: 700; color: var(--accent); .header-version {
} font-size: 0.7rem;
.header-logo svg { width: 22px; height: 22px; } 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 { .btn-logout {
font-size: 0.78rem; color: var(--text-muted); background: none; font-size: 0.78rem; color: var(--text-muted); background: none;
border: 1px solid var(--border); border-radius: 6px; border: 1px solid var(--border); border-radius: 6px;
padding: 0.3rem 0.75rem; cursor: pointer; transition: all 0.2s; padding: 0.3rem 0.75rem; cursor: pointer; transition: all 0.2s;
} }
.btn-logout:hover { border-color: var(--danger); color: var(--danger); } .btn-logout:hover { border-color: var(--danger); color: var(--danger); }
.btn-menu {
display: none;
background: none; border: none; color: var(--text);
font-size: 1.2rem; cursor: pointer; padding: 0.1rem 0.5rem;
margin-right: 0.2rem; border-radius: 4px; transition: all 0.2s;
}
.btn-menu:hover { background: var(--bg-hover); }
/* ─── Main layout ─── */ /* ─── Main layout ─── */
.main { .main {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
position: relative;
} }
/* Mobile Overlay */
.sidebar-overlay {
display: none;
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 20;
}
.sidebar-overlay.show { display: block; }
/* ─── File browser ─── */ /* ─── File browser ─── */
.sidebar { .sidebar {
width: 280px; width: 280px;
@@ -322,6 +350,61 @@ body {
transition: all 0.25s; pointer-events: none; z-index: 100; transition: all 0.25s; pointer-events: none; z-index: 100;
} }
.toast.show { opacity: 1; transform: translateY(0); } .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; }
.header { padding: 0.75rem 1rem; }
.sidebar {
position: absolute;
top: 0; bottom: 0; left: -100%;
width: 85%; max-width: 320px;
z-index: 30;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 20px rgba(0,0,0,0.6);
}
.sidebar.open { left: 0; }
.queue-panel { flex: 1; min-width: 0; }
.player-bar {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.np-info { display: grid; grid-template-columns: auto 1fr; text-align: left; }
.volume-row { display: none; /* Hide volume on mobile to save space, rely on hardware buttons */ }
}
</style> </style>
</head> </head>
<body> <body>
@@ -329,19 +412,28 @@ body {
<!-- Header --> <!-- Header -->
<header class="header"> <header class="header">
<div class="header-logo"> <div class="header-logo">
<button class="btn-menu" id="btnMenu" onclick="toggleSidebar()"></button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="9" cy="18" r="3"/><circle cx="18" cy="15" r="3"/> <circle cx="9" cy="18" r="3"/><circle cx="18" cy="15" r="3"/>
<path d="M12 18V6l9-3v3"/> <path d="M12 18V6l9-3v3"/>
</svg> </svg>
Furumi Player Furumi Player
<a href="https://gt.hexor.cy/ab/furumi-ng" class="header-version" target="_blank">v<!-- VERSION_PLACEHOLDER --></a>
</div> </div>
<div style="display: flex; align-items: center; gap: 1rem;">
<span style="font-size: 0.8rem; color: var(--text-dim);">
<span style="opacity: 0.6; margin-right: 0.2rem;">👤</span>
<!-- USERNAME_PLACEHOLDER -->
</span>
<button class="btn-logout" onclick="logout()">Sign out</button> <button class="btn-logout" onclick="logout()">Sign out</button>
</div>
</header> </header>
<!-- Main --> <!-- Main -->
<div class="main"> <div class="main">
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<!-- Sidebar: file browser --> <!-- Sidebar: file browser -->
<aside class="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-header">📁 Library</div> <div class="sidebar-header">📁 Library</div>
<div class="breadcrumb" id="breadcrumb">/ <span onclick="navigate('')">root</span></div> <div class="breadcrumb" id="breadcrumb">/ <span onclick="navigate('')">root</span></div>
<div class="file-list" id="fileList"> <div class="file-list" id="fileList">
@@ -373,7 +465,8 @@ body {
<div class="player-bar"> <div class="player-bar">
<!-- Now playing --> <!-- Now playing -->
<div class="np-info"> <div class="np-info">
<div class="np-cover" id="npCover">🎵</div> <div class="np-cover" id="npCover" onmouseenter="showCoverPreview(true)" onmouseleave="showCoverPreview(false)">🎵</div>
<div class="cover-preview-overlay" id="coverPreview"></div>
<div class="np-text"> <div class="np-text">
<div class="np-title" id="npTitle">Nothing playing</div> <div class="np-title" id="npTitle">Nothing playing</div>
<div class="np-artist" id="npArtist"></div> <div class="np-artist" id="npArtist"></div>
@@ -625,14 +718,49 @@ function playIndex(i) {
} }
function updateNowPlaying(track) { function updateNowPlaying(track) {
document.getElementById('npTitle').textContent = track.meta?.title || displayName(track.name); const title = track.meta?.title || displayName(track.name);
document.getElementById('npArtist').textContent = track.meta?.artist || '—'; const artist = track.meta?.artist || '—';
if (track.meta?.cover_base64) { const coverBase64 = track.meta?.cover_base64;
document.getElementById('npCover').innerHTML = `<img src="${track.meta.cover_base64}" alt="cover">`;
document.getElementById('npTitle').textContent = title;
document.getElementById('npArtist').textContent = artist;
if (coverBase64) {
document.getElementById('npCover').innerHTML = `<img src="${coverBase64}" alt="cover">`;
} else { } else {
document.getElementById('npCover').textContent = '🎵'; 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 = `<img src="${coverBase64}" alt="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) { async function loadMeta(track) {
@@ -665,6 +793,13 @@ async function fetchMeta(path, idx) {
} catch(e) {} } catch(e) {}
} }
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('show');
}
function renderQueue() { function renderQueue() {
const listEl = document.getElementById('queueList'); const listEl = document.getElementById('queueList');
@@ -906,6 +1041,29 @@ function logout() {
} }
// ─── Init ───────────────────────────────────────────────────────────────────── // ─── 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(''); navigate('');
</script> </script>
</body> </body>