Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722183047d | ||
|
|
106ab96c56 |
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -910,7 +910,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-client-core"
|
name = "furumi-client-core"
|
||||||
version = "0.3.2"
|
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.3.2"
|
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.3.2"
|
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.3.2"
|
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.3.2"
|
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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi-client-core"
|
name = "furumi-client-core"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi-common"
|
name = "furumi-common"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi-mount-linux"
|
name = "furumi-mount-linux"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi-mount-macos"
|
name = "furumi-mount-macos"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi-server"
|
name = "furumi-server"
|
||||||
version = "0.3.3"
|
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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
/// 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();
|
||||||
@@ -69,11 +76,12 @@ pub async fn require_auth(
|
|||||||
req.extensions_mut().insert(super::AuthUserInfo(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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,8 +114,16 @@ pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|
||||||
@@ -115,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.
|
||||||
@@ -144,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()
|
||||||
@@ -173,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)?,
|
||||||
@@ -188,8 +216,15 @@ pub async fn oidc_init(
|
|||||||
.set_auth_type(openidconnect::AuthType::RequestBody)
|
.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,
|
||||||
@@ -199,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 {
|
||||||
@@ -220,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")
|
||||||
@@ -273,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;
|
||||||
}
|
}
|
||||||
@@ -320,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())
|
||||||
@@ -334,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()
|
||||||
}
|
}
|
||||||
@@ -407,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>
|
||||||
@@ -461,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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ pub struct AuthUserInfo(pub String);
|
|||||||
async fn player_html(
|
async fn player_html(
|
||||||
axum::extract::Extension(user_info): axum::extract::Extension<AuthUserInfo>,
|
axum::extract::Extension(user_info): axum::extract::Extension<AuthUserInfo>,
|
||||||
) -> axum::response::Html<String> {
|
) -> axum::response::Html<String> {
|
||||||
let html = include_str!("player.html").replace("<!-- USERNAME_PLACEHOLDER -->", &user_info.0);
|
let html = include_str!("player.html")
|
||||||
|
.replace("<!-- USERNAME_PLACEHOLDER -->", &user_info.0)
|
||||||
|
.replace("<!-- VERSION_PLACEHOLDER -->", env!("CARGO_PKG_VERSION"));
|
||||||
axum::response::Html(html)
|
axum::response::Html(html)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,23 @@ 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;
|
||||||
@@ -339,6 +351,37 @@ body {
|
|||||||
}
|
}
|
||||||
.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) ─── */
|
/* ─── Responsive (Mobile) ─── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.btn-menu { display: inline-block; }
|
.btn-menu { display: inline-block; }
|
||||||
@@ -375,6 +418,7 @@ body {
|
|||||||
<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;">
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
<span style="font-size: 0.8rem; color: var(--text-dim);">
|
<span style="font-size: 0.8rem; color: var(--text-dim);">
|
||||||
@@ -421,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>
|
||||||
@@ -673,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) {
|
||||||
@@ -961,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user