From 15cecb5d7db06cbba76a31c53100a6b5b54528a9 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:25:28 +0300 Subject: [PATCH] feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156) --- CHANGELOG.md | 8 + src-tauri/Cargo.lock | 2 + .../psysonic-analysis/src/analysis_runtime.rs | 21 +- .../crates/psysonic-audio/src/commands.rs | 11 +- src-tauri/crates/psysonic-audio/src/engine.rs | 81 ++++ .../crates/psysonic-audio/src/helpers.rs | 5 +- .../crates/psysonic-audio/src/play_input.rs | 16 +- .../psysonic-audio/src/preload_commands.rs | 12 +- .../crates/psysonic-audio/src/preview.rs | 12 +- .../psysonic-audio/src/stream/ranged_http.rs | 37 +- .../psysonic-audio/src/stream/track_stream.rs | 3 + src-tauri/crates/psysonic-core/Cargo.toml | 2 + src-tauri/crates/psysonic-core/src/lib.rs | 1 + .../crates/psysonic-core/src/log_sanitize.rs | 46 ++- .../crates/psysonic-core/src/server_http.rs | 366 ++++++++++++++++++ .../src/navidrome/client.rs | 38 +- .../src/navidrome/covers.rs | 118 ++++-- .../psysonic-integration/src/navidrome/mod.rs | 2 +- .../src/navidrome/playlists.rs | 119 ++++-- .../src/navidrome/probe.rs | 36 +- .../src/navidrome/queries.rs | 193 +++++++-- .../src/navidrome/users.rs | 132 +++++-- .../src/subsonic/client.rs | 41 +- .../psysonic-integration/src/subsonic/mod.rs | 3 +- .../crates/psysonic-library/src/commands.rs | 43 +- .../psysonic-library/src/sync/capability.rs | 32 +- .../crates/psysonic-library/src/sync/delta.rs | 10 + .../psysonic-library/src/sync/initial.rs | 16 + .../psysonic-library/src/sync/scheduler.rs | 11 +- .../psysonic-syncfs/src/cache/downloads.rs | 15 +- .../crates/psysonic-syncfs/src/cache/hot.rs | 14 +- .../crates/psysonic-syncfs/src/cache/local.rs | 15 +- .../psysonic-syncfs/src/cache/offline.rs | 25 +- .../psysonic-syncfs/src/file_transfer.rs | 14 + .../crates/psysonic-syncfs/src/sync/batch.rs | 44 ++- .../crates/psysonic-syncfs/src/sync/device.rs | 33 +- src-tauri/src/cover_cache/external.rs | 21 +- src-tauri/src/cover_cache/external_ensure.rs | 8 +- src-tauri/src/cover_cache/fetch.rs | 27 +- src-tauri/src/cover_cache/mod.rs | 14 +- src-tauri/src/lib.rs | 12 +- src-tauri/src/lib_commands/app_api/mod.rs | 5 +- src-tauri/src/lib_commands/app_api/network.rs | 33 ++ src/api/subsonic.async.test.ts | 38 +- src/api/subsonic.scheduleProbe.test.ts | 15 + src/api/subsonic.ts | 58 ++- src/api/subsonicClient.ts | 16 + src/api/subsonicEntityWithCredentials.ts | 11 +- src/api/subsonicOpenSubsonic.test.ts | 11 + src/api/subsonicOpenSubsonic.ts | 4 +- src/components/PlayerBar.test.tsx | 6 + .../settings/AddServerForm.test.tsx | 30 ++ src/components/settings/AddServerForm.tsx | 145 ++++++- src/components/settings/ServersTab.tsx | 33 +- src/config/settingsCredits.ts | 1 + src/hooks/useConnectionStatus.test.ts | 33 +- src/locales/de/settings.ts | 18 + src/locales/en/settings.ts | 18 + src/locales/es/settings.ts | 18 + src/locales/fr/settings.ts | 18 + src/locales/hu/settings.ts | 18 + src/locales/ja/settings.ts | 18 + src/locales/nb/settings.ts | 18 + src/locales/nl/settings.ts | 18 + src/locales/ro/settings.ts | 18 + src/locales/ru/settings.ts | 18 + src/locales/zh/settings.ts | 18 + src/store/authStore.ts | 2 + src/store/authStoreTypes.ts | 21 + src/store/playerStore.persistence.test.ts | 2 + src/utils/library/librarySession.ts | 2 + src/utils/perf/sanitizeLogLine.test.ts | 11 + src/utils/perf/sanitizeLogLine.ts | 10 +- src/utils/server/serverEndpoint.test.ts | 79 ++-- src/utils/server/serverEndpoint.ts | 13 +- src/utils/server/serverFingerprint.ts | 71 +++- src/utils/server/serverHttpHeaders.test.ts | 53 +++ src/utils/server/serverHttpHeaders.ts | 152 ++++++++ src/utils/server/switchActiveServer.ts | 2 + .../server/syncServerHttpContext.test.ts | 37 ++ src/utils/server/syncServerHttpContext.ts | 21 + .../share/enqueueShareSearchPayload.test.ts | 4 + src/utils/share/enqueueShareSearchPayload.ts | 3 + 83 files changed, 2452 insertions(+), 327 deletions(-) create mode 100644 src-tauri/crates/psysonic-core/src/server_http.rs create mode 100644 src/utils/server/serverHttpHeaders.test.ts create mode 100644 src/utils/server/serverHttpHeaders.ts create mode 100644 src/utils/server/syncServerHttpContext.test.ts create mode 100644 src/utils/server/syncServerHttpContext.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3ec690..3b70ff18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens. +### Custom HTTP headers for gated servers + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095) + +* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles. +* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings. +* Gate header values are redacted from application logs. + ## Changed diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 89c0b5b6..5e36562b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4219,8 +4219,10 @@ name = "psysonic-core" version = "1.49.0-dev" dependencies = [ "libc", + "reqwest", "serde", "tauri", + "url", ] [[package]] diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 66399c50..89c7ac6d 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use tauri::{Emitter, Manager}; use psysonic_core::ports::PlaybackQueryHandle; +use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry}; use psysonic_core::user_agent::subsonic_wire_user_agent; use psysonic_core::track_enrichment::TrackEnrichmentOutcome; @@ -679,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client { }) } -async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec, u64), String> { +async fn analysis_backfill_download_bytes( + app: &tauri::AppHandle, + server_id: &str, + url: &str, +) -> Result<(Vec, u64), String> { let fetch_started = std::time::Instant::now(); - let response = analysis_http_client() - .get(url) + let registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + let request = apply_optional_registry_headers( + registry.as_deref(), + Some(server_id), + url, + analysis_http_client().get(url), + ); + let response = request .send() .await .map_err(|e| e.to_string())?; @@ -781,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc, + server_id: Option<&str>, + url: &str, + req: reqwest::RequestBuilder, +) -> reqwest::RequestBuilder { + if let Some(reg) = registry { + if let Some(sid) = server_id.filter(|s| !s.is_empty()) { + return reg.apply_for_http_url(sid, url, req); + } + if let Some(ctx) = reg.get_for_server_url(url) { + return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url); + } + } + req +} + +/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks. +#[derive(Clone, Default)] +pub(crate) struct PlaybackHttpHeaders { + registry: Option>, + server_id: Option, +} + +impl PlaybackHttpHeaders { + pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self { + Self { + registry: app + .try_state::>() + .map(|s| Arc::clone(&*s)), + server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string), + } + } + + pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + apply_playback_request_headers( + self.registry.as_deref(), + self.server_id.as_deref(), + url, + req, + ) + } +} + +pub(crate) fn scoped_http_get( + state: &AudioEngine, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_id: Option<&str>, + url: &str, +) -> reqwest::RequestBuilder { + apply_playback_request_headers( + registry, + server_id, + url, + audio_http_client(state).get(url), + ) +} + +/// Resolve registry + server id for playback/preload HTTP GETs. +pub(crate) fn playback_scoped_get( + state: &AudioEngine, + app: &tauri::AppHandle, + url: &str, + server_id: Option<&str>, +) -> reqwest::RequestBuilder { + let registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + let sid = server_id + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| state.current_playback_server_id.lock().unwrap().clone()); + scoped_http_get( + state, + registry.as_deref(), + sid.as_deref(), + url, + ) +} diff --git a/src-tauri/crates/psysonic-audio/src/helpers.rs b/src-tauri/crates/psysonic-audio/src/helpers.rs index ed6a2ba1..c400e69a 100644 --- a/src-tauri/crates/psysonic-audio/src/helpers.rs +++ b/src-tauri/crates/psysonic-audio/src/helpers.rs @@ -708,7 +708,10 @@ pub(crate) async fn fetch_data( return Ok(Some(data)); } - let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?; + let response = crate::engine::playback_scoped_get(state, app, url, None) + .send() + .await + .map_err(|e| e.to_string())?; let status = response.status(); let ct = response.headers() .get(reqwest::header::CONTENT_TYPE) diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index 7a965de7..22b606f2 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -15,7 +15,7 @@ use super::analysis_dispatch::{ prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file, TrackAnalysisOrigin, }; -use super::engine::{audio_http_client, AudioEngine}; +use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders}; use super::helpers::{ content_type_to_hint, fetch_data, format_hint_from_content_disposition, normalize_stream_suffix_for_hint, sniff_stream_format_extension, @@ -217,7 +217,12 @@ async fn open_ranged_or_streaming_input( state: &State<'_, AudioEngine>, app: &AppHandle, ) -> Result, String> { - let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?; + let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id); + let response = http_headers + .apply(ctx.url, audio_http_client(state).get(ctx.url)) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { if state.generation.load(Ordering::SeqCst) != ctx.gen { return Ok(None); // superseded @@ -256,8 +261,8 @@ async fn open_ranged_or_streaming_input( let last = total_u64 .saturating_sub(1) .min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64); - if let Ok(pr) = audio_http_client(state) - .get(ctx.url) + if let Ok(pr) = http_headers + .apply(ctx.url, audio_http_client(state).get(ctx.url)) .header(reqwest::header::RANGE, format!("bytes=0-{last}")) .send() .await @@ -328,6 +333,7 @@ async fn open_ranged_or_streaming_input( state.loudness_pre_analysis_attenuation_db.clone(), ctx.cache_id_for_tasks.map(|s| s.to_string()), ctx.server_id.map(|s| s.to_string()), + http_headers.clone(), loudness_hold_for_defer, playback_armed, stream_hint.clone(), @@ -347,6 +353,7 @@ async fn open_ranged_or_streaming_input( total, state.generation.clone(), ctx.gen, + http_headers.clone(), ))); let reader = RangedHttpSource { buf, @@ -401,6 +408,7 @@ async fn open_ranged_or_streaming_input( state.loudness_pre_analysis_attenuation_db.clone(), ctx.cache_id_for_tasks.map(|s| s.to_string()), ctx.server_id.map(|s| s.to_string()), + http_headers, playback_armed, )); diff --git a/src-tauri/crates/psysonic-audio/src/preload_commands.rs b/src-tauri/crates/psysonic-audio/src/preload_commands.rs index 63ee9863..fcd86105 100644 --- a/src-tauri/crates/psysonic-audio/src/preload_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/preload_commands.rs @@ -16,7 +16,7 @@ use super::analysis_dispatch::{ dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file, TrackAnalysisOrigin, }; -use super::engine::{audio_http_client, AudioEngine}; +use super::engine::AudioEngine; use super::helpers::{analysis_cache_track_id, same_playback_target}; use super::state::PreloadedTrack; @@ -197,7 +197,15 @@ pub async fn audio_preload( } } - let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?; + let response = crate::engine::playback_scoped_get( + &state, + &app, + &url, + server_id.as_deref(), + ) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { emit_preload_cancelled(&app, url, track_id_for_events); return Ok(()); diff --git a/src-tauri/crates/psysonic-audio/src/preview.rs b/src-tauri/crates/psysonic-audio/src/preview.rs index b2defdf4..e82f2abf 100644 --- a/src-tauri/crates/psysonic-audio/src/preview.rs +++ b/src-tauri/crates/psysonic-audio/src/preview.rs @@ -8,7 +8,7 @@ use rodio::Source; use tauri::{AppHandle, Emitter, State}; use super::decode::SizedDecoder; -use super::engine::{audio_http_client, AudioEngine}; +use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders}; use super::helpers::{ content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES, @@ -155,9 +155,10 @@ async fn open_preview_decoder( state: &AudioEngine, app: &AppHandle, ) -> Result, String> { + let http_headers = PlaybackHttpHeaders::from_app(app, None); let preview_http = preview_http_client(state); - let response = preview_http - .get(url) + let response = http_headers + .apply(url, preview_http.get(url)) .send() .await .map_err(|e| format!("preview: connection failed: {e}"))? @@ -194,8 +195,8 @@ async fn open_preview_decoder( let last = total_u64 .saturating_sub(1) .min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64); - if let Ok(pr) = preview_http - .get(url) + if let Ok(pr) = http_headers + .apply(url, preview_http.get(url)) .header(reqwest::header::RANGE, format!("bytes=0-{last}")) .send() .await @@ -257,6 +258,7 @@ async fn open_preview_decoder( state.loudness_pre_analysis_attenuation_db.clone(), None, None, + http_headers.clone(), None, playback_armed, stream_hint.clone(), diff --git a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs index 1ff8b56c..c5ed59ce 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs @@ -21,6 +21,7 @@ use futures_util::StreamExt; use symphonia::core::io::MediaSource; use tauri::{AppHandle, Emitter}; +use super::super::engine::PlaybackHttpHeaders; use super::super::state::PreloadedTrack; use super::{ RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS, @@ -88,6 +89,7 @@ pub(crate) struct OnDemand { /// Bumped after every completed (success or failure) fetch so the read loop /// can reset its stall deadline while on-demand fetches make progress. progress: AtomicU64, + http_headers: PlaybackHttpHeaders, } impl OnDemand { @@ -100,6 +102,7 @@ impl OnDemand { total_size: u64, gen_arc: Arc, gen: u64, + http_headers: PlaybackHttpHeaders, ) -> Self { OnDemand { http, @@ -112,6 +115,7 @@ impl OnDemand { filled: Mutex::new(Vec::new()), inflight: Mutex::new(Vec::new()), progress: AtomicU64::new(0), + http_headers, } } @@ -154,6 +158,7 @@ impl OnDemand { end_inclusive, me.gen, &me.gen_arc, + &me.http_headers, ) .await; if let Ok(written) = res { @@ -367,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop( downloaded_to: &Arc, gen: u64, gen_arc: &Arc, + http_headers: &PlaybackHttpHeaders, mut on_partial: F, playback_armed: Option<&AtomicBool>, ) -> (usize, RangedHttpLoopOutcome) @@ -387,6 +393,7 @@ where if downloaded > 0 { req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-")); } + req = http_headers.apply(url, req); match req.send().await { Ok(r) => r, Err(err) => { @@ -487,6 +494,7 @@ where } /// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range). +#[allow(clippy::too_many_arguments)] async fn ranged_write_http_range( http_client: &reqwest::Client, url: &str, @@ -495,13 +503,18 @@ async fn ranged_write_http_range( end_inclusive: u64, gen: u64, gen_arc: &Arc, + http_headers: &PlaybackHttpHeaders, ) -> Result { if gen_arc.load(Ordering::SeqCst) != gen { return Err(()); } - let response = http_client - .get(url) - .header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")) + let response = http_headers + .apply( + url, + http_client + .get(url) + .header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")), + ) .send() .await .map_err(|_| ())?; @@ -555,6 +568,7 @@ async fn ranged_prefetch_mp4_tail( playback_armed: Arc, gen: u64, gen_arc: Arc, + http_headers: PlaybackHttpHeaders, ) { const MIN_TAIL: u64 = 256 * 1024; const MAX_TAIL: u64 = 8 * 1024 * 1024; @@ -573,6 +587,7 @@ async fn ranged_prefetch_mp4_tail( end_inclusive, gen, &gen_arc, + &http_headers, ) .await { @@ -622,6 +637,7 @@ pub(crate) async fn ranged_download_task( cache_track_id: Option, // Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). server_id: Option, + http_headers: PlaybackHttpHeaders, // When `Some`, ranged playback seeds on completion — defer HTTP backfill for that // track; `None` for large files where ranged skips seed (needs backfill). loudness_seed_hold: Option, @@ -705,6 +721,7 @@ pub(crate) async fn ranged_download_task( let tail_from_bg = tail_filled_from.clone(); let armed_bg = playback_armed.clone(); let gen_bg = gen_arc.clone(); + let headers_bg = http_headers.clone(); Some(tokio::spawn(async move { ranged_prefetch_mp4_tail( client, @@ -716,6 +733,7 @@ pub(crate) async fn ranged_download_task( armed_bg, gen, gen_bg, + headers_bg, ) .await; })) @@ -736,6 +754,7 @@ pub(crate) async fn ranged_download_task( &downloaded_to, gen, &gen_arc, + &http_headers, on_partial, linear_arm, ) @@ -1127,6 +1146,7 @@ mod tests { &dl, 1, &gen_arc, + &PlaybackHttpHeaders::default(), |_, _| {}, None, ) @@ -1162,6 +1182,7 @@ mod tests { &dl, 1, &gen_arc, + &PlaybackHttpHeaders::default(), |downloaded, total| calls.lock().unwrap().push((downloaded, total)), None, ) @@ -1190,7 +1211,7 @@ mod tests { let (buf, dl, gen_arc) = loop_state(1024); let (downloaded, outcome) = - ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None) + ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None) .await; assert_eq!(outcome, RangedHttpLoopOutcome::Aborted); @@ -1221,7 +1242,7 @@ mod tests { gen_arc.store(99, Ordering::SeqCst); let (downloaded, outcome) = - ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None) + ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None) .await; assert_eq!(outcome, RangedHttpLoopOutcome::Superseded); @@ -1280,7 +1301,7 @@ mod tests { let (buf, dl, gen_arc) = loop_state(body.len()); let (downloaded, outcome) = - ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None) + ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None) .await; // Stream finishes via a Range-resumed second request. @@ -1354,6 +1375,7 @@ mod tests { total as u64, gen_arc.clone(), 1, + PlaybackHttpHeaders::default(), ))); let mut src = RangedHttpSource { buf, @@ -1406,6 +1428,7 @@ mod tests { 2047, 1, &gen_arc, + &PlaybackHttpHeaders::default(), ) .await; @@ -1440,7 +1463,7 @@ mod tests { let (buf, dl, gen_arc) = loop_state(body.len()); let (downloaded, outcome) = - ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None) + ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None) .await; // Reconnect server returned 200 instead of 206 → Aborted, downloaded diff --git a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs index 2b91bb29..2c25cd2a 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs @@ -15,6 +15,7 @@ use ringbuf::HeapProd; use ringbuf::traits::Producer; use tauri::AppHandle; +use super::super::engine::PlaybackHttpHeaders; use super::super::state::PreloadedTrack; use super::{ maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES, @@ -37,6 +38,7 @@ pub(crate) async fn track_download_task( cache_track_id: Option, // Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). server_id: Option, + http_headers: PlaybackHttpHeaders, playback_armed: Arc, ) { let mut downloaded: u64 = 0; @@ -53,6 +55,7 @@ pub(crate) async fn track_download_task( if downloaded > 0 { req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-")); } + req = http_headers.apply(&url, req); match req.send().await { Ok(r) => r, Err(err) => { diff --git a/src-tauri/crates/psysonic-core/Cargo.toml b/src-tauri/crates/psysonic-core/Cargo.toml index 9b87bd35..6a659b13 100644 --- a/src-tauri/crates/psysonic-core/Cargo.toml +++ b/src-tauri/crates/psysonic-core/Cargo.toml @@ -9,6 +9,8 @@ publish = false [dependencies] tauri = { version = "2" } serde = { version = "1", features = ["derive"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +url = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/crates/psysonic-core/src/lib.rs b/src-tauri/crates/psysonic-core/src/lib.rs index 4891a3be..967391c3 100644 --- a/src-tauri/crates/psysonic-core/src/lib.rs +++ b/src-tauri/crates/psysonic-core/src/lib.rs @@ -4,6 +4,7 @@ //! macros) and the cross-crate port traits used to break dependency cycles //! between `psysonic-audio`, `psysonic-analysis`, and other domain crates. +pub mod server_http; pub mod cover_cache_layout; pub mod log_sanitize; pub mod media_layout; diff --git a/src-tauri/crates/psysonic-core/src/log_sanitize.rs b/src-tauri/crates/psysonic-core/src/log_sanitize.rs index c52f8d96..c8ad5bc0 100644 --- a/src-tauri/crates/psysonic-core/src/log_sanitize.rs +++ b/src-tauri/crates/psysonic-core/src/log_sanitize.rs @@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[ const SENSITIVE_KV_KEYS: &[&str] = &[ "password", "passwd", "token", "secret", "api_key", "apikey", "access_token", - "refresh_token", "authorization", "auth", + "refresh_token", "authorization", "auth", "cookie", "x-api-key", + "cf-access-client-secret", "cf-access-client-id", "x-auth-token", ]; /// Sanitize one runtime log line for display and export. pub fn sanitize_log_line(line: &str) -> String { let mut out = redact_bearer_tokens(line); + out = redact_pangolin_headers(&out); out = redact_sensitive_key_values(&out); out = redact_urls_in_text(&out); out @@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String { s } +fn redact_pangolin_headers(line: &str) -> String { + let lower = line.to_ascii_lowercase(); + let mut out = line.to_string(); + let mut search_from = 0; + while let Some(rel) = lower[search_from..].find("x-pangolin-") { + let idx = search_from + rel; + let after_prefix = &lower[idx..]; + let Some(sep_rel) = after_prefix.find([':', '=']) else { + search_from = idx + 1; + continue; + }; + let sep_idx = idx + sep_rel; + let val_start = sep_idx + 1; + let slice = &out[val_start..]; + let trimmed = slice.trim_start(); + let ws = slice.len().saturating_sub(trimmed.len()); + let val_start = val_start + ws; + let end = trimmed + .find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')') + .unwrap_or(trimmed.len()); + if end > 0 { + out.replace_range(val_start..val_start + end, "REDACTED"); + } + search_from = val_start + "REDACTED".len(); + if search_from >= out.len() { + break; + } + } + out +} + fn redact_sensitive_key_values(line: &str) -> String { let mut out = line.to_string(); for key in SENSITIVE_KV_KEYS { @@ -368,6 +401,17 @@ mod tests { assert!(!out.contains("user:pass")); } + #[test] + fn redacts_reverse_proxy_gate_headers() { + let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key"; + let out = sanitize_log_line(line); + assert!(out.contains("CF-Access-Client-Secret: REDACTED")); + assert!(!out.contains("gate-secret")); + assert!(!out.contains("tok123")); + assert!(out.contains("x-pangolin-auth: REDACTED")); + assert!(!out.contains("pangolin-key")); + } + #[test] fn stream_log_with_em_dash_does_not_panic() { let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")"; diff --git a/src-tauri/crates/psysonic-core/src/server_http.rs b/src-tauri/crates/psysonic-core/src/server_http.rs new file mode 100644 index 00000000..cbd60107 --- /dev/null +++ b/src-tauri/crates/psysonic-core/src/server_http.rs @@ -0,0 +1,366 @@ +//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access). +//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use reqwest::RequestBuilder; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EndpointKind { + Local, + Public, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CustomHeadersApplyTo { + Local, + #[default] + Public, + Both, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ServerHttpEndpointWire { + pub url: String, + pub kind: EndpointKind, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CustomHeaderEntryWire { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ServerHttpContextSyncWire { + #[serde(rename = "serverId")] + pub server_id: String, + #[serde(rename = "appServerId")] + pub app_server_id: String, + pub endpoints: Vec, + #[serde(rename = "customHeaders", default)] + pub custom_headers: Vec, + #[serde(rename = "customHeadersApplyTo", default)] + pub custom_headers_apply_to: Option, +} + +#[derive(Clone, Debug)] +pub struct ServerHttpContext { + pub endpoints: Vec<(String, EndpointKind)>, + pub headers: Vec<(String, String)>, + pub apply_to: CustomHeadersApplyTo, +} + +impl From for ServerHttpContext { + fn from(w: ServerHttpContextSyncWire) -> Self { + Self { + endpoints: w + .endpoints + .into_iter() + .map(|e| (normalize_server_base_url(&e.url), e.kind)) + .collect(), + headers: w + .custom_headers + .into_iter() + .map(|h| (h.name.trim().to_string(), h.value)) + .filter(|(n, _)| !n.is_empty()) + .collect(), + apply_to: w.custom_headers_apply_to.unwrap_or_default(), + } + } +} + +fn normalize_server_base_url(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return String::new(); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + } +} + +/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`. +pub fn request_base_url_from_http_url(raw_url: &str) -> String { + let trimmed = raw_url.trim(); + if trimmed.is_empty() { + return String::new(); + } + let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + let Ok(mut parsed) = url::Url::parse(&with_scheme) else { + return normalize_server_base_url(trimmed); + }; + parsed.set_query(None); + parsed.set_fragment(None); + let mut path = parsed.path().to_string(); + if let Some(idx) = path.find("/rest/") { + path.truncate(idx); + } else if path.ends_with("/rest") { + path.truncate(path.len().saturating_sub("/rest".len())); + } else { + for seg in ["/api/", "/auth/"] { + if let Some(idx) = path.find(seg) { + path.truncate(idx); + break; + } + } + } + while path.ends_with('/') && path.len() > 1 { + path.pop(); + } + parsed.set_path(if path.is_empty() { "/" } else { &path }); + let host = parsed.host_str().unwrap_or_default(); + if host.is_empty() { + return normalize_server_base_url(trimmed); + } + let mut out = format!("{}://{}", parsed.scheme(), host); + if let Some(port) = parsed.port() { + out.push(':'); + out.push_str(&port.to_string()); + } + if !path.is_empty() && path != "/" { + out.push_str(&path); + } + normalize_server_base_url(&out) +} + +pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap { + let mut map = HeaderMap::new(); + if ctx.headers.is_empty() { + return map; + } + let normalized = normalize_server_base_url(request_base_url); + let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else { + return map; + }; + let apply = match ctx.apply_to { + CustomHeadersApplyTo::Both => true, + CustomHeadersApplyTo::Public => *kind == EndpointKind::Public, + CustomHeadersApplyTo::Local => *kind == EndpointKind::Local, + }; + if !apply { + return map; + } + for (name, value) in &ctx.headers { + let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { + continue; + }; + let Ok(header_value) = HeaderValue::from_str(value) else { + continue; + }; + map.insert(header_name, header_value); + } + map +} + +pub fn apply_server_headers( + builder: RequestBuilder, + ctx: &ServerHttpContext, + request_base_url: &str, +) -> RequestBuilder { + let map = headers_for_request_base_url(ctx, request_base_url); + if map.is_empty() { + return builder; + } + builder.headers(map) +} + +pub fn apply_server_headers_for_http_url( + builder: RequestBuilder, + ctx: &ServerHttpContext, + full_http_url: &str, +) -> RequestBuilder { + let base = request_base_url_from_http_url(full_http_url); + apply_server_headers(builder, ctx, &base) +} + +#[derive(Default)] +pub struct ServerHttpRegistry { + contexts: Mutex>>, + ref_to_key: Mutex>, +} + +impl ServerHttpRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn sync(&self, wire: ServerHttpContextSyncWire) { + let index_key = wire.server_id.clone(); + let app_id = wire.app_server_id.clone(); + let ctx = Arc::new(ServerHttpContext::from(wire)); + if ctx.headers.is_empty() { + self.remove(&index_key, &app_id); + return; + } + { + let mut contexts = self.contexts.lock().unwrap(); + contexts.insert(index_key.clone(), Arc::clone(&ctx)); + } + let mut refs = self.ref_to_key.lock().unwrap(); + refs.insert(index_key.clone(), index_key.clone()); + refs.insert(app_id, index_key); + } + + pub fn sync_all(&self, entries: Vec) { + let mut new_contexts = HashMap::new(); + let mut new_refs = HashMap::new(); + for wire in entries { + let index_key = wire.server_id.clone(); + let app_id = wire.app_server_id.clone(); + let ctx = Arc::new(ServerHttpContext::from(wire)); + if ctx.headers.is_empty() { + continue; + } + new_contexts.insert(index_key.clone(), Arc::clone(&ctx)); + new_refs.insert(index_key.clone(), index_key.clone()); + new_refs.insert(app_id, index_key); + } + *self.contexts.lock().unwrap() = new_contexts; + *self.ref_to_key.lock().unwrap() = new_refs; + } + + pub fn remove(&self, index_key: &str, app_server_id: &str) { + self.contexts.lock().unwrap().remove(index_key); + let mut refs = self.ref_to_key.lock().unwrap(); + refs.remove(index_key); + refs.remove(app_server_id); + } + + pub fn get(&self, index_key: &str) -> Option> { + self.contexts.lock().unwrap().get(index_key).cloned() + } + + pub fn get_for_server_ref(&self, server_ref: &str) -> Option> { + if server_ref.is_empty() { + return None; + } + let key = { + let refs = self.ref_to_key.lock().unwrap(); + refs.get(server_ref).cloned() + }; + if let Some(k) = key { + return self.get(&k); + } + self.get(server_ref) + } + + /// Fallback when only a server base URL is known (Navidrome invoke paths). + pub fn get_for_server_url(&self, server_url: &str) -> Option> { + let base = request_base_url_from_http_url(server_url); + if base.is_empty() { + return None; + } + let contexts = self.contexts.lock().unwrap(); + for ctx in contexts.values() { + if ctx.endpoints.iter().any(|(u, _)| *u == base) { + return Some(Arc::clone(ctx)); + } + } + None + } + + pub fn apply_for_http_url( + &self, + server_ref: &str, + full_http_url: &str, + builder: RequestBuilder, + ) -> RequestBuilder { + let Some(ctx) = self.get_for_server_ref(server_ref) else { + return builder; + }; + apply_server_headers_for_http_url(builder, &ctx, full_http_url) + } + + pub fn apply_for_base_url( + &self, + server_ref: &str, + request_base_url: &str, + builder: RequestBuilder, + ) -> RequestBuilder { + let Some(ctx) = self.get_for_server_ref(server_ref) else { + return builder; + }; + apply_server_headers(builder, &ctx, request_base_url) + } +} + +/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match. +pub fn apply_optional_registry_headers( + registry: Option<&ServerHttpRegistry>, + server_ref: Option<&str>, + full_http_url: &str, + builder: RequestBuilder, +) -> RequestBuilder { + if let Some(reg) = registry { + if let Some(sid) = server_ref.filter(|s| !s.is_empty()) { + return reg.apply_for_http_url(sid, full_http_url, builder); + } + if let Some(ctx) = reg.get_for_server_url(full_http_url) { + return apply_server_headers_for_http_url(builder, &ctx, full_http_url); + } + } + builder +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_base_url_strips_rest_and_query() { + let url = "https://music.example/rest/stream.view?id=1&u=x"; + assert_eq!( + request_base_url_from_http_url(url), + "https://music.example" + ); + } + + #[test] + fn headers_apply_public_only_on_public_endpoint() { + let ctx = ServerHttpContext { + endpoints: vec![ + ("http://192.168.0.10".into(), EndpointKind::Local), + ("https://music.example".into(), EndpointKind::Public), + ], + headers: vec![("X-Gate".into(), "secret".into())], + apply_to: CustomHeadersApplyTo::Public, + }; + let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10"); + assert!(lan.is_empty()); + let pub_ = headers_for_request_base_url(&ctx, "https://music.example"); + assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret"))); + } + + #[test] + fn registry_resolves_app_id_alias() { + let reg = ServerHttpRegistry::new(); + reg.sync(ServerHttpContextSyncWire { + server_id: "music.example".into(), + app_server_id: "uuid-1".into(), + endpoints: vec![ServerHttpEndpointWire { + url: "https://music.example".into(), + kind: EndpointKind::Public, + }], + custom_headers: vec![CustomHeaderEntryWire { + name: "X-Gate".into(), + value: "tok".into(), + }], + custom_headers_apply_to: Some(CustomHeadersApplyTo::Public), + }); + assert!(reg.get("music.example").is_some()); + assert!(reg.get_for_server_ref("uuid-1").is_some()); + assert!(reg.get("uuid-1").is_none()); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/client.rs b/src-tauri/crates/psysonic-integration/src/navidrome/client.rs index 546a6845..4e6628b4 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/client.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/client.rs @@ -1,15 +1,31 @@ //! Auth + retry + HTTP client for Navidrome's native REST API. //! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls. +use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry}; + /// Authenticate with Navidrome's own REST API and return a Bearer token. pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { + navidrome_token_with_registry(None, server_url, username, password).await +} + +pub async fn navidrome_token_with_registry( + registry: Option<&ServerHttpRegistry>, + server_url: &str, + username: &str, + password: &str, +) -> Result { let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/auth/login", server_url)) - .json(&serde_json::json!({ "username": username, "password": password })) - .send() - .await - .map_err(|e| e.to_string())?; + let base = server_url.trim_end_matches('/'); + let login_url = format!("{base}/auth/login"); + let mut req = client + .post(&login_url) + .json(&serde_json::json!({ "username": username, "password": password })); + if let Some(reg) = registry { + if let Some(ctx) = reg.get_for_server_url(server_url) { + req = apply_server_headers_for_http_url(req, &ctx, &login_url); + } + } + let resp = req.send().await.map_err(|e| e.to_string())?; let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; data["token"] .as_str() @@ -17,6 +33,16 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) - .ok_or_else(|| "Navidrome auth: no token in response".to_string()) } +/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests. +pub fn nd_apply_request( + registry: Option<&ServerHttpRegistry>, + server_ref: Option<&str>, + full_url: &str, + builder: reqwest::RequestBuilder, +) -> reqwest::RequestBuilder { + apply_optional_registry_headers(registry, server_ref, full_url, builder) +} + /// Payload returned by Navidrome's `/auth/login`. #[derive(serde::Serialize)] pub struct NdLoginResult { diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs b/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs index 7ea0162f..78fce8e8 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs @@ -2,10 +2,16 @@ //! login (via `navidrome_token`) and then a multipart POST to the relevant //! `/api/{playlist|radio|artist}/{id}/image` endpoint. -use super::client::navidrome_token; +use std::sync::Arc; + +use psysonic_core::server_http::ServerHttpRegistry; +use tauri::State; + +use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client}; #[tauri::command] pub async fn upload_playlist_cover( + http_registry: State<'_, Arc>, server_url: String, playlist_id: String, username: String, @@ -13,26 +19,34 @@ pub async fn upload_playlist_cover( file_bytes: Vec, mime_type: String, ) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; + let reg = http_registry.as_ref(); + let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?; let part = reqwest::multipart::Part::bytes(file_bytes) .file_name("cover.jpg") .mime_str(&mime_type) .map_err(|e| e.to_string())?; let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/playlist/{}/image", server_url, playlist_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; + let url = format!("{}/api/playlist/{}/image", server_url, playlist_id); + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .post(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form), + ) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn upload_radio_cover( + http_registry: State<'_, Arc>, server_url: String, radio_id: String, username: String, @@ -40,26 +54,34 @@ pub async fn upload_radio_cover( file_bytes: Vec, mime_type: String, ) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; + let reg = http_registry.as_ref(); + let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?; let part = reqwest::multipart::Part::bytes(file_bytes) .file_name("cover.jpg") .mime_str(&mime_type) .map_err(|e| e.to_string())?; let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/radio/{}/image", server_url, radio_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; + let url = format!("{}/api/radio/{}/image", server_url, radio_id); + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .post(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form), + ) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn upload_artist_image( + http_registry: State<'_, Arc>, server_url: String, artist_id: String, username: String, @@ -67,40 +89,58 @@ pub async fn upload_artist_image( file_bytes: Vec, mime_type: String, ) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; + let reg = http_registry.as_ref(); + let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?; let part = reqwest::multipart::Part::bytes(file_bytes) .file_name("cover.jpg") .mime_str(&mime_type) .map_err(|e| e.to_string())?; let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/artist/{}/image", server_url, artist_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; + let url = format!("{}/api/artist/{}/image", server_url, artist_id); + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .post(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form), + ) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn delete_radio_cover( + http_registry: State<'_, Arc>, server_url: String, radio_id: String, username: String, password: String, ) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let resp = reqwest::Client::new() - .delete(format!("{}/api/radio/{}/image", server_url, radio_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| e.to_string())?; + let reg = http_registry.as_ref(); + let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?; + let url = format!("{}/api/radio/{}/image", server_url, radio_id); + let resp = nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .delete(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)), + ) + .send() + .await + .map_err(|e| e.to_string())?; // 404/503 = no image existed — treat as success - if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE { + if !resp.status().is_success() + && resp.status() != reqwest::StatusCode::NOT_FOUND + && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE + { resp.error_for_status().map_err(|e| e.to_string())?; } Ok(()) diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs b/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs index 72d1b096..8846ebd3 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs @@ -11,4 +11,4 @@ pub mod probe; pub mod queries; pub mod users; -pub use client::navidrome_token; +pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request}; diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs b/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs index 2d2d9c36..9a833d49 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs @@ -2,24 +2,41 @@ //! payload is forwarded as-is so the frontend can compose any rule the //! Navidrome version supports without backend changes. -use super::client::{nd_err, nd_http_client, nd_retry}; +use std::sync::Arc; + +use psysonic_core::server_http::ServerHttpRegistry; +use tauri::State; + +use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry}; /// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. #[tauri::command] pub async fn nd_list_playlists( + http_registry: State<'_, Arc>, server_url: String, token: String, smart: Option, ) -> Result { + let reg = http_registry.as_ref(); + let base = format!("{}/api/playlist", server_url); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - let client = nd_http_client(); - let mut req = client - .get(format!("{}/api/playlist", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)); - if let Some(s) = smart { - req = req.query(&[("smart", s)]); + let base = base.clone(); + let auth = auth.clone(); + async move { + let mut req = nd_apply_request( + Some(reg), + None, + &base, + nd_http_client() + .get(&base) + .header("X-ND-Authorization", auth), + ); + if let Some(s) = smart { + req = req.query(&[("smart", s)]); + } + req.send().await } - req.send() }) .await?; if !resp.status().is_success() { @@ -31,16 +48,31 @@ pub async fn nd_list_playlists( /// POST `/api/playlist` — create playlist (supports smart rules payload). #[tauri::command] pub async fn nd_create_playlist( + http_registry: State<'_, Arc>, server_url: String, token: String, body: serde_json::Value, ) -> Result { + let reg = http_registry.as_ref(); + let url = format!("{}/api/playlist", server_url); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/api/playlist", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) + let url = url.clone(); + let auth = auth.clone(); + let body = body.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .post(&url) + .header("X-ND-Authorization", auth) + .json(&body), + ) .send() + .await + } }) .await?; let status = resp.status(); @@ -54,17 +86,32 @@ pub async fn nd_create_playlist( /// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). #[tauri::command] pub async fn nd_update_playlist( + http_registry: State<'_, Arc>, server_url: String, token: String, id: String, body: serde_json::Value, ) -> Result { + let reg = http_registry.as_ref(); + let url = format!("{}/api/playlist/{}", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) + let url = url.clone(); + let auth = auth.clone(); + let body = body.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .put(&url) + .header("X-ND-Authorization", auth) + .json(&body), + ) .send() + .await + } }) .await?; let status = resp.status(); @@ -78,15 +125,29 @@ pub async fn nd_update_playlist( /// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). #[tauri::command] pub async fn nd_get_playlist( + http_registry: State<'_, Arc>, server_url: String, token: String, id: String, ) -> Result { + let reg = http_registry.as_ref(); + let url = format!("{}/api/playlist/{}", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .get(&url) + .header("X-ND-Authorization", auth), + ) .send() + .await + } }) .await?; let status = resp.status(); @@ -100,15 +161,29 @@ pub async fn nd_get_playlist( /// DELETE `/api/playlist/{id}` — delete playlist. #[tauri::command] pub async fn nd_delete_playlist( + http_registry: State<'_, Arc>, server_url: String, token: String, id: String, ) -> Result<(), String> { + let reg = http_registry.as_ref(); + let url = format!("{}/api/playlist/{}", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .delete(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .delete(&url) + .header("X-ND-Authorization", auth), + ) .send() + .await + } }) .await?; let status = resp.status(); diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs b/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs index 687a7bb3..d5b222fc 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs @@ -6,7 +6,7 @@ //! endpoint? — so this stays a free function rather than a client //! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b. -use super::client::{nd_err, nd_http_client}; +use super::client::{nd_apply_request, nd_err, nd_http_client}; /// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with /// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or @@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client}; /// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability /// flag. Wider call into the actual ingest path (`nd_list_songs` port) /// is PR-3b's job. -pub async fn native_bulk_available(server_url: &str, token: &str) -> Result { +pub async fn native_bulk_available( + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: Option<&str>, + server_url: &str, + token: &str, +) -> Result { let client = nd_http_client(); let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/')); - let resp = client - .get(url) - .header("X-ND-Authorization", format!("Bearer {token}")) - .send() - .await - .map_err(nd_err)?; + let resp = nd_apply_request( + registry, + server_ref, + &url, + client + .get(&url) + .header("X-ND-Authorization", format!("Bearer {token}")), + ) + .send() + .await + .map_err(nd_err)?; let status = resp.status(); if status.is_success() { @@ -56,7 +66,7 @@ mod tests { .mount(&server) .await; - let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap(); + let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap(); assert!(ok); } @@ -71,7 +81,7 @@ mod tests { .mount(&server) .await; - let ok = native_bulk_available(&server.uri(), "tok").await.unwrap(); + let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap(); assert!(!ok); } @@ -84,7 +94,7 @@ mod tests { .mount(&server) .await; - let ok = native_bulk_available(&server.uri(), "bad").await.unwrap(); + let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap(); assert!(!ok, "401 reads as `endpoint not available for this caller`"); } @@ -97,7 +107,7 @@ mod tests { .mount(&server) .await; - let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err(); + let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err(); assert!(err.contains("503")); } @@ -111,6 +121,6 @@ mod tests { .await; let with_slash = format!("{}/", server.uri()); - assert!(native_bulk_available(&with_slash, "tok").await.unwrap()); + assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap()); } } diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs index 934f239f..0dbf6d5f 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs @@ -2,13 +2,21 @@ //! incompletely: songs, role-filtered artist/album lists, libraries, //! per-user library assignment, and absolute song path resolution. -use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry}; +use std::sync::Arc; + +use psysonic_core::server_http::ServerHttpRegistry; +use tauri::State; + +use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry}; /// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated /// song list. Pure async helper used by the library-side N1 ingest /// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]` /// variant below for existing frontend callers. +#[allow(clippy::too_many_arguments)] pub async fn nd_list_songs_internal( + registry: Option<&ServerHttpRegistry>, + server_ref: Option<&str>, server_url: &str, token: &str, sort: &str, @@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal( "{}/api/song?_sort={}&_order={}&_start={}&_end={}", server_url, sort, order, start, end ); + let auth = format!("Bearer {token}"); let resp = nd_retry(|| { - nd_http_client() - .get(&url) - .header("X-ND-Authorization", format!("Bearer {token}")) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + registry, + server_ref, + &url, + nd_http_client() + .get(&url) + .header("X-ND-Authorization", auth), + ) .send() - }).await?; + .await + } + }) + .await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } @@ -36,6 +56,7 @@ pub async fn nd_list_songs_internal( /// surface unchanged for existing call sites in the WebView. #[tauri::command] pub async fn nd_list_songs( + http_registry: State<'_, Arc>, server_url: String, token: String, sort: String, @@ -43,7 +64,17 @@ pub async fn nd_list_songs( start: u32, end: u32, ) -> Result { - nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await + nd_list_songs_internal( + Some(http_registry.as_ref()), + None, + &server_url, + &token, + &sort, + &order, + start, + end, + ) + .await } /// Build the `_filters` JSON for native-API list calls. Optionally narrows the @@ -70,6 +101,7 @@ fn nd_build_filters(seed: serde_json::Map, library_id #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn nd_list_artists_by_role( + http_registry: State<'_, Arc>, server_url: String, token: String, role: String, @@ -79,24 +111,42 @@ pub async fn nd_list_artists_by_role( end: u32, library_id: Option, ) -> Result { + let reg = http_registry.as_ref(); let mut seed = serde_json::Map::new(); seed.insert("role".to_string(), serde_json::Value::String(role.clone())); let filters = nd_build_filters(seed, library_id.as_deref()); let start_s = start.to_string(); let end_s = end.to_string(); + let base = format!("{}/api/artist", server_url); let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/artist", server_url)) - .query(&[ - ("_filters", filters.as_str()), - ("_sort", sort.as_str()), - ("_order", order.as_str()), - ("_start", start_s.as_str()), - ("_end", end_s.as_str()), - ]) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let base = base.clone(); + let filters = filters.clone(); + let sort = sort.clone(); + let order = order.clone(); + let start_s = start_s.clone(); + let end_s = end_s.clone(); + let auth = format!("Bearer {}", token); + async move { + nd_apply_request( + Some(reg), + None, + &base, + nd_http_client() + .get(&base) + .query(&[ + ("_filters", filters.as_str()), + ("_sort", sort.as_str()), + ("_order", order.as_str()), + ("_start", start_s.as_str()), + ("_end", end_s.as_str()), + ]) + .header("X-ND-Authorization", auth), + ) .send() - }).await?; + .await + } + }) + .await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } @@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role( #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn nd_list_albums_by_artist_role( + http_registry: State<'_, Arc>, server_url: String, token: String, artist_id: String, @@ -121,25 +172,43 @@ pub async fn nd_list_albums_by_artist_role( end: u32, library_id: Option, ) -> Result { + let reg = http_registry.as_ref(); let filter_key = format!("role_{}_id", role); let mut seed = serde_json::Map::new(); seed.insert(filter_key, serde_json::Value::String(artist_id.clone())); let filters = nd_build_filters(seed, library_id.as_deref()); let start_s = start.to_string(); let end_s = end.to_string(); + let base = format!("{}/api/album", server_url); let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/album", server_url)) - .query(&[ - ("_filters", filters.as_str()), - ("_sort", sort.as_str()), - ("_order", order.as_str()), - ("_start", start_s.as_str()), - ("_end", end_s.as_str()), - ]) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let base = base.clone(); + let filters = filters.clone(); + let sort = sort.clone(); + let order = order.clone(); + let start_s = start_s.clone(); + let end_s = end_s.clone(); + let auth = format!("Bearer {}", token); + async move { + nd_apply_request( + Some(reg), + None, + &base, + nd_http_client() + .get(&base) + .query(&[ + ("_filters", filters.as_str()), + ("_sort", sort.as_str()), + ("_order", order.as_str()), + ("_start", start_s.as_str()), + ("_end", end_s.as_str()), + ]) + .header("X-ND-Authorization", auth), + ) .send() - }).await?; + .await + } + }) + .await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } @@ -149,15 +218,28 @@ pub async fn nd_list_albums_by_artist_role( /// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. #[tauri::command] pub async fn nd_list_libraries( + http_registry: State<'_, Arc>, server_url: String, token: String, ) -> Result { + let reg = http_registry.as_ref(); + let url = format!("{}/api/library", server_url); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/library", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client().get(&url).header("X-ND-Authorization", auth), + ) .send() - }).await?; + .await + } + }) + .await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } @@ -168,19 +250,35 @@ pub async fn nd_list_libraries( /// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400. #[tauri::command] pub async fn nd_set_user_libraries( + http_registry: State<'_, Arc>, server_url: String, token: String, id: String, library_ids: Vec, ) -> Result<(), String> { + let reg = http_registry.as_ref(); let body = serde_json::json!({ "libraryIds": library_ids }); + let url = format!("{}/api/user/{}/library", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/user/{}/library", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) + let url = url.clone(); + let auth = auth.clone(); + let body = body.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .put(&url) + .header("X-ND-Authorization", auth) + .json(&body), + ) .send() - }).await?; + .await + } + }) + .await?; let status = resp.status(); if !status.is_success() { let text = resp.text().await.unwrap_or_default(); @@ -202,18 +300,31 @@ pub async fn nd_set_user_libraries( /// it for non-admin users on some configurations. #[tauri::command] pub async fn nd_get_song_path( + http_registry: State<'_, Arc>, server_url: String, username: String, password: String, id: String, ) -> Result, String> { - let token = navidrome_token(&server_url, &username, &password).await?; + let reg = http_registry.as_ref(); + let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?; let url = format!("{}/api/song/{}", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .get(&url) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .get(&url) + .header("X-ND-Authorization", auth), + ) .send() + .await + } }) .await?; if !resp.status().is_success() { diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/users.rs b/src-tauri/crates/psysonic-integration/src/navidrome/users.rs index 43456d29..393d001d 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/users.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/users.rs @@ -2,22 +2,39 @@ //! `token` (obtained via `navidrome_login`); admin-only ones return 401/403 //! when the caller is not an admin. -use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult}; +use std::sync::Arc; + +use psysonic_core::server_http::ServerHttpRegistry; +use tauri::State; + +use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult}; /// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. #[tauri::command] pub async fn navidrome_login( + http_registry: State<'_, Arc>, server_url: String, username: String, password: String, ) -> Result { + let reg = http_registry.as_ref(); let body = serde_json::json!({ "username": username, "password": password }); + let login_url = format!("{}/auth/login", server_url.trim_end_matches('/')); let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/auth/login", server_url)) - .json(&body) + let login_url = login_url.clone(); + let body = body.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &login_url, + nd_http_client().post(&login_url).json(&body), + ) .send() - }).await?; + .await + } + }) + .await?; if !resp.status().is_success() { return Err(format!("Navidrome login failed: HTTP {}", resp.status())); } @@ -25,21 +42,40 @@ pub async fn navidrome_login( let token = data["token"].as_str().ok_or("no token in response")?.to_string(); let user_id = data["id"].as_str().unwrap_or("").to_string(); let is_admin = data["isAdmin"].as_bool().unwrap_or(false); - Ok(NdLoginResult { token, user_id, is_admin }) + Ok(NdLoginResult { + token, + user_id, + is_admin, + }) } /// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields. #[tauri::command] pub async fn nd_list_users( + http_registry: State<'_, Arc>, server_url: String, token: String, ) -> Result { + let reg = http_registry.as_ref(); + let url = format!("{}/api/user", server_url); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/user", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .get(&url) + .header("X-ND-Authorization", auth), + ) .send() - }).await?; + .await + } + }) + .await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } @@ -48,7 +84,9 @@ pub async fn nd_list_users( /// POST `/api/user` — create a user. #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn nd_create_user( + http_registry: State<'_, Arc>, server_url: String, token: String, user_name: String, @@ -57,6 +95,7 @@ pub async fn nd_create_user( password: String, is_admin: bool, ) -> Result { + let reg = http_registry.as_ref(); let body = serde_json::json!({ "userName": user_name, "name": name, @@ -64,13 +103,27 @@ pub async fn nd_create_user( "password": password, "isAdmin": is_admin, }); + let url = format!("{}/api/user", server_url); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/api/user", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) + let url = url.clone(); + let auth = auth.clone(); + let body = body.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .post(&url) + .header("X-ND-Authorization", auth) + .json(&body), + ) .send() - }).await?; + .await + } + }) + .await?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { @@ -83,6 +136,7 @@ pub async fn nd_create_user( #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn nd_update_user( + http_registry: State<'_, Arc>, server_url: String, token: String, id: String, @@ -92,6 +146,7 @@ pub async fn nd_update_user( password: String, is_admin: bool, ) -> Result { + let reg = http_registry.as_ref(); let mut body = serde_json::json!({ "id": id, "userName": user_name, @@ -102,13 +157,27 @@ pub async fn nd_update_user( if !password.is_empty() { body["password"] = serde_json::Value::String(password); } + let url = format!("{}/api/user/{}", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/user/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) + let url = url.clone(); + let auth = auth.clone(); + let body = body.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .put(&url) + .header("X-ND-Authorization", auth) + .json(&body), + ) .send() - }).await?; + .await + } + }) + .await?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { @@ -120,16 +189,31 @@ pub async fn nd_update_user( /// DELETE `/api/user/{id}`. #[tauri::command] pub async fn nd_delete_user( + http_registry: State<'_, Arc>, server_url: String, token: String, id: String, ) -> Result<(), String> { + let reg = http_registry.as_ref(); + let url = format!("{}/api/user/{}", server_url, id); + let auth = format!("Bearer {}", token); let resp = nd_retry(|| { - nd_http_client() - .delete(format!("{}/api/user/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) + let url = url.clone(); + let auth = auth.clone(); + async move { + nd_apply_request( + Some(reg), + None, + &url, + nd_http_client() + .delete(&url) + .header("X-ND-Authorization", auth), + ) .send() - }).await?; + .await + } + }) + .await?; let status = resp.status(); if !status.is_success() { let text = resp.text().await.unwrap_or_default(); diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/client.rs b/src-tauri/crates/psysonic-integration/src/subsonic/client.rs index d4104bb0..8a0232c5 100644 --- a/src-tauri/crates/psysonic-integration/src/subsonic/client.rs +++ b/src-tauri/crates/psysonic-integration/src/subsonic/client.rs @@ -12,6 +12,7 @@ use serde::Deserialize; use super::auth::SubsonicCredentials; use super::error::{flatten_reqwest_error, SubsonicError}; use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song}; +use psysonic_core::server_http::{apply_server_headers, ServerHttpContext}; /// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that /// Navidrome and other servers in the wild support. OpenSubsonic @@ -42,6 +43,7 @@ pub struct SubsonicClient { base_url: String, credentials: CredentialsMode, http: reqwest::Client, + http_context: Option, } impl SubsonicClient { @@ -75,9 +77,28 @@ impl SubsonicClient { password: password.into(), }, http, + http_context: None, } } + pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self { + self.http_context = Some(ctx); + self + } + + /// Production helper — attach registry context when present for `server_ref` + /// (app server id or index key). + pub fn with_registry( + self, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: &str, + ) -> Self { + registry + .and_then(|r| r.get_for_server_ref(server_ref)) + .map(|ctx| self.clone().with_http_context((*ctx).clone())) + .unwrap_or(self) + } + /// Test-/cache-friendly constructor — re-uses the same /// `SubsonicCredentials` triple on every call. Wiremock tests rely on /// this for deterministic `s=` and `t=` query params; production code @@ -95,6 +116,7 @@ impl SubsonicClient { base_url: url, credentials: CredentialsMode::Static(credentials), http, + http_context: None, } } @@ -301,10 +323,15 @@ impl SubsonicClient { let mut query: Vec<(&str, &str)> = auth.to_vec(); query.extend_from_slice(extra); - let resp = self + let mut req = self .http .get(format!("{}/rest/{method}.view", self.base_url)) - .query(&query) + .query(&query); + if let Some(ctx) = &self.http_context { + req = apply_server_headers(req, ctx, &self.base_url); + } + + let resp = req .send() .await .map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?; @@ -331,6 +358,16 @@ fn default_http_client() -> reqwest::Client { .unwrap_or_else(|_| reqwest::Client::new()) } +pub fn subsonic_client_with_registry( + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: &str, + base_url: impl Into, + username: impl Into, + password: impl Into, +) -> SubsonicClient { + SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref) +} + /// Validate the Subsonic envelope and return the raw `serde_json::Value` /// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound` /// variant; surfaces every other failed status as `Api { code, message }`. diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs b/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs index ee80c203..44f2051e 100644 --- a/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs +++ b/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs @@ -10,7 +10,8 @@ pub mod types; pub use auth::SubsonicCredentials; pub use client::{ - fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID, + fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION, + SUBSONIC_CLIENT_ID, }; pub use stream_url::{build_stream_view_url, rest_base_from_url}; pub use error::SubsonicError; diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index cc62d656..dbaa0d24 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -11,8 +11,9 @@ use rusqlite::params; use serde_json::Value; use tauri::{AppHandle, Emitter, Manager, State}; -use psysonic_integration::navidrome::navidrome_token; -use psysonic_integration::subsonic::SubsonicClient; +use psysonic_core::server_http::ServerHttpRegistry; +use psysonic_integration::navidrome::navidrome_token_with_registry; +use psysonic_integration::subsonic::subsonic_client_with_registry; use crate::advanced_search; use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto}; @@ -657,13 +658,14 @@ fn normalize_base_url(raw: &str) -> String { /// caller falls back to a cached bearer / the Subsonic-only path. Never logs /// the token or credentials. async fn navidrome_token_with_retry( + registry: Option<&ServerHttpRegistry>, base_url: &str, username: &str, password: &str, ) -> Option { const ATTEMPTS: u32 = 3; for attempt in 1..=ATTEMPTS { - match navidrome_token(base_url, username, password).await { + match navidrome_token_with_registry(registry, base_url, username, password).await { Ok(tok) => return Some(tok), Err(_) if attempt < ATTEMPTS => { tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await; @@ -677,6 +679,7 @@ async fn navidrome_token_with_retry( #[tauri::command] pub async fn library_sync_bind_session( runtime: State<'_, LibraryRuntime>, + http_registry: State<'_, Arc>, server_id: String, base_url: String, username: String, @@ -690,8 +693,13 @@ pub async fn library_sync_bind_session( // keep a bearer cached from a prior bind rather than dropping to // Subsonic-only — a transient miss must not strip an N1-capable server // (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic. - let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password) - .await + let navidrome_token_cached = match navidrome_token_with_retry( + Some(http_registry.as_ref()), + &base_url, + &username, + &password, + ) + .await { Some(tok) => Some(tok), None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token), @@ -709,7 +717,13 @@ pub async fn library_sync_bind_session( // Run the probe + persist capability flags. Failure to probe is a // bind-time error — caller should fix credentials / URL. - let subsonic = SubsonicClient::new(base_url, username, password); + let subsonic = subsonic_client_with_registry( + Some(http_registry.as_ref()), + &server_id, + base_url, + username, + password, + ); let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials { server_url: subsonic_base_url_from(&runtime, &server_id), bearer_token: tok, @@ -719,6 +733,7 @@ pub async fn library_sync_bind_session( &runtime.store, &subsonic, navidrome_creds.as_ref(), + Some(http_registry.as_ref()), &server_id, scope, ) @@ -872,8 +887,12 @@ async fn library_sync_start_inner( let job_id_for_task = job_id.clone(); let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint()); + let app_for_runner = app.clone(); let runner_handle: tokio::task::JoinHandle> = tokio::task::spawn(async move { - let subsonic = SubsonicClient::new( + let registry = app_for_runner.state::>(); + let subsonic = subsonic_client_with_registry( + Some(registry.as_ref()), + &session_clone.server_id, session_clone.base_url.clone(), session_clone.username.clone(), session_clone.password.clone(), @@ -895,7 +914,8 @@ async fn library_sync_start_inner( ) .with_cancellation(Arc::clone(&cancel_for_task)) .with_progress(Arc::clone(&progress)) - .with_parallelism_budget(parallelism); + .with_parallelism_budget(parallelism) + .with_http_registry(Some(Arc::clone(®istry))); if let Some(creds) = navidrome_creds.clone() { runner = runner.with_navidrome_credentials(creds); } @@ -919,7 +939,8 @@ async fn library_sync_start_inner( capability_flags, ) .with_cancellation(Arc::clone(&cancel_for_task)) - .with_progress(Arc::clone(&progress)); + .with_progress(Arc::clone(&progress)) + .with_http_registry(Some(Arc::clone(®istry))); if tombstone_budget > 0 { runner = runner.with_tombstone_budget(tombstone_budget); } @@ -1647,7 +1668,7 @@ mod tests { }))) .mount(&server) .await; - let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await; + let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await; assert_eq!(tok.as_deref(), Some("nd-tok")); } @@ -1664,7 +1685,7 @@ mod tests { .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) .mount(&server) .await; - let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await; + let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await; assert!(tok.is_none()); } } diff --git a/src-tauri/crates/psysonic-library/src/sync/capability.rs b/src-tauri/crates/psysonic-library/src/sync/capability.rs index f7e47071..3406fd99 100644 --- a/src-tauri/crates/psysonic-library/src/sync/capability.rs +++ b/src-tauri/crates/psysonic-library/src/sync/capability.rs @@ -91,6 +91,7 @@ pub async fn probe_and_persist( store: &crate::store::LibraryStore, subsonic: &psysonic_integration::subsonic::SubsonicClient, navidrome: Option<&NavidromeProbeCredentials>, + http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, server_id: &str, library_scope: &str, ) -> Result { @@ -110,7 +111,7 @@ pub async fn probe_and_persist( .map_err(psysonic_integration::subsonic::SubsonicError::Transport)? .unwrap_or(0); - let mut result = CapabilityProbe::run(subsonic, navidrome).await?; + let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?; // R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it // must not drop a previously-learned NavidromeNativeBulk capability — the @@ -173,6 +174,8 @@ impl CapabilityProbe { pub async fn run( subsonic: &SubsonicClient, navidrome: Option<&NavidromeProbeCredentials>, + http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_id: Option<&str>, ) -> Result { let server_info = subsonic.server_info().await?; @@ -207,7 +210,14 @@ impl CapabilityProbe { } if let Some(creds) = navidrome { - match native_bulk_available(&creds.server_url, &creds.bearer_token).await { + match native_bulk_available( + http_registry, + server_id, + &creds.server_url, + &creds.bearer_token, + ) + .await + { Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK), Ok(false) => {} Err(_) => { @@ -345,7 +355,7 @@ mod tests { let server = MockServer::start().await; mount_subsonic_full_navidrome(&server).await; - let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None) .await .unwrap(); assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); @@ -372,7 +382,7 @@ mod tests { .mount(&server) .await; - let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None) .await .unwrap_err(); assert!(matches!(err, SubsonicError::Api { code: 40, .. })); @@ -415,7 +425,7 @@ mod tests { .mount(&server) .await; - let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None) .await .unwrap(); assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); @@ -440,7 +450,7 @@ mod tests { server_url: server.uri(), bearer_token: "nd-tok".into(), }; - let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav)) + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None) .await .unwrap(); assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); @@ -461,6 +471,7 @@ mod tests { &store, &test_subsonic_client(&server.uri()), None, + None, "s1", "", ) @@ -498,6 +509,7 @@ mod tests { &store, &test_subsonic_client(&server.uri()), None, + None, "s1", "", ) @@ -530,6 +542,7 @@ mod tests { &store, &test_subsonic_client(&server.uri()), None, + None, "s1", "", ) @@ -584,7 +597,7 @@ mod tests { let store = LibraryStore::open_in_memory(); let result = - super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "") + super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "") .await .unwrap(); assert_eq!(result.server_track_count, Some(170_000)); @@ -617,6 +630,7 @@ mod tests { &store, &test_subsonic_client(&server.uri()), None, + None, "s1", "", ) @@ -647,7 +661,7 @@ mod tests { mount_subsonic_full_navidrome(&server).await; // scanStatus has no count let result = - super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "") + super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "") .await .unwrap(); assert_eq!(result.server_track_count, None); @@ -672,7 +686,7 @@ mod tests { server_url: server.uri(), bearer_token: "nd-tok".into(), }; - let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav)) + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None) .await .unwrap(); assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); diff --git a/src-tauri/crates/psysonic-library/src/sync/delta.rs b/src-tauri/crates/psysonic-library/src/sync/delta.rs index 09df2b5b..d248674b 100644 --- a/src-tauri/crates/psysonic-library/src/sync/delta.rs +++ b/src-tauri/crates/psysonic-library/src/sync/delta.rs @@ -20,6 +20,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; +use psysonic_core::server_http::ServerHttpRegistry; use psysonic_integration::navidrome::queries::nd_list_songs_internal; use psysonic_integration::subsonic::SubsonicClient; use serde_json::Value; @@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> { store: &'a LibraryStore, subsonic: &'a SubsonicClient, navidrome: Option, + http_registry: Option>, server_id: String, library_scope: String, capability_flags: CapabilityFlags, @@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> { store, subsonic, navidrome: None, + http_registry: None, server_id: server_id.into(), library_scope: library_scope.into(), capability_flags, @@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> { self } + pub fn with_http_registry(mut self, registry: Option>) -> Self { + self.http_registry = registry; + self + } + pub fn with_cancellation(mut self, flag: Arc) -> Self { self.cancel = Some(flag); self @@ -396,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> { self, || { nd_list_songs_internal( + self.http_registry.as_deref(), + Some(&self.server_id), &creds.server_url, &creds.bearer_token, "updated_at", diff --git a/src-tauri/crates/psysonic-library/src/sync/initial.rs b/src-tauri/crates/psysonic-library/src/sync/initial.rs index 701be5ec..ea8035cb 100644 --- a/src-tauri/crates/psysonic-library/src/sync/initial.rs +++ b/src-tauri/crates/psysonic-library/src/sync/initial.rs @@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; +use psysonic_core::server_http::ServerHttpRegistry; use psysonic_integration::navidrome::queries::nd_list_songs_internal; use psysonic_integration::subsonic::SubsonicClient; use serde_json::Value; @@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> { store: &'a LibraryStore, subsonic: &'a SubsonicClient, navidrome: Option, + http_registry: Option>, server_id: String, library_scope: String, capability_flags: CapabilityFlags, @@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> { store, subsonic, navidrome: None, + http_registry: None, server_id: server_id.into(), library_scope: library_scope.into(), capability_flags, @@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> { self } + pub fn with_http_registry(mut self, registry: Option>) -> Self { + self.http_registry = registry; + self + } + pub fn with_cancellation(mut self, flag: Arc) -> Self { self.cancel = Some(flag); self @@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> { let cancel = self.cancel.clone(); let sleep_enabled = self.sleep_enabled; let creds = creds.clone(); + let http_registry = self.http_registry.clone(); + let server_id = self.server_id.clone(); let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset); loop { @@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> { queue.pump(|| self.check_cancellation(), |off| { let creds = creds.clone(); let cancel = cancel.clone(); + let http_registry = http_registry.clone(); + let server_id = server_id.clone(); tokio::spawn(async move { retry_fetch( sleep_enabled, @@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> { || async { let end = off.saturating_add(batch_size); let response = nd_list_songs_internal( + http_registry.as_deref(), + Some(&server_id), &creds.server_url, &creds.bearer_token, "id", @@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> { self, || { nd_list_songs_internal( + self.http_registry.as_deref(), + Some(&self.server_id), &creds.server_url, &creds.bearer_token, "id", diff --git a/src-tauri/crates/psysonic-library/src/sync/scheduler.rs b/src-tauri/crates/psysonic-library/src/sync/scheduler.rs index d7d64372..87b2219b 100644 --- a/src-tauri/crates/psysonic-library/src/sync/scheduler.rs +++ b/src-tauri/crates/psysonic-library/src/sync/scheduler.rs @@ -11,6 +11,7 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; +use psysonic_core::server_http::ServerHttpRegistry; use psysonic_integration::subsonic::SubsonicClient; use super::bandwidth::{ParallelismBudget, PlaybackHint}; @@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> { store: &'a LibraryStore, subsonic: &'a SubsonicClient, navidrome: Option, + http_registry: Option>, server_id: String, library_scope: String, capability_flags: CapabilityFlags, @@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> { store, subsonic, navidrome: None, + http_registry: None, server_id: server_id.into(), library_scope: library_scope.into(), capability_flags, @@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> { self } + pub fn with_http_registry(mut self, registry: Option>) -> Self { + self.http_registry = registry; + self + } + pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self { self.playback_hint = hint; self @@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> { &self.library_scope, self.capability_flags, ) - .with_progress(Arc::clone(&self.progress)); + .with_progress(Arc::clone(&self.progress)) + .with_http_registry(self.http_registry.clone()); if let Some(creds) = &self.navidrome { runner = runner.with_navidrome_credentials(creds.clone()); } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs index 73bddf8f..b7257c09 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs @@ -2,6 +2,8 @@ use tauri::{Emitter, Manager}; use psysonic_core::user_agent::subsonic_wire_user_agent; +use crate::file_transfer::apply_server_http_get; + pub fn resolve_hot_cache_root( custom_dir: Option, app: &tauri::AppHandle, @@ -340,7 +342,18 @@ pub async fn download_zip( .build() .map_err(|e| e.to_string())?; - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + let http_registry = app + .try_state::>() + .map(|s| std::sync::Arc::clone(&*s)); + let response = apply_server_http_get( + &client, + http_registry.as_deref(), + None, + &url, + ) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index ed3cd08c..7f0a8068 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; + use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority}; use psysonic_audio as audio; use psysonic_core::user_agent::subsonic_wire_user_agent; +use tauri::Manager; -use crate::file_transfer::stream_to_file; +use crate::file_transfer::{apply_server_http_get, stream_to_file}; use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult}; use super::offline::enqueue_analysis_seed_from_file; @@ -70,7 +73,14 @@ pub async fn download_track_hot_cache( .build() .map_err(|e| e.to_string())?; - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + + let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/local.rs b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs index ed4a3de5..d595350c 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/local.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs @@ -22,7 +22,7 @@ use psysonic_library::repos::TrackRow; use psysonic_library::{repos::TrackRepository, LibraryRuntime}; use tauri::{AppHandle, Manager, State}; -use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client}; use crate::{offline_cancel_flags, DownloadSemaphore}; use super::offline::enqueue_analysis_seed_from_file; @@ -359,7 +359,18 @@ pub async fn download_track_local( } let client = subsonic_http_client(std::time::Duration::from_secs(120))?; - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + let response = apply_server_http_get( + &client, + http_registry.as_deref(), + Some(&server_index_key), + &url, + ) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs index 8e30a62b..dbd67675 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs @@ -9,7 +9,7 @@ use psysonic_analysis::analysis_runtime::{ }; use crate::{offline_cancel_flags, DownloadSemaphore}; -use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client}; // ─── Offline Track Cache ────────────────────────────────────────────────────── @@ -31,12 +31,15 @@ pub async fn enqueue_analysis_seed_from_file( /// /// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")` /// (the `.part` file is cleaned up); `None` means the download is not cancellable. +#[allow(clippy::too_many_arguments)] pub(crate) async fn download_track_to_cache_dir( cache_dir: &std::path::Path, track_id: &str, suffix: &str, url: &str, client: &reqwest::Client, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: Option<&str>, cancel: Option<&AtomicBool>, ) -> Result { tokio::fs::create_dir_all(cache_dir) @@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir( return Ok(file_path); } - let response = client.get(url).send().await.map_err(|e| e.to_string())?; + let response = apply_server_http_get(client, registry, server_ref, url) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } @@ -129,12 +135,17 @@ pub async fn download_track_offline( } let client = subsonic_http_client(std::time::Duration::from_secs(120))?; + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); let final_path = download_track_to_cache_dir( &cache_dir, &track_id, &suffix, &url, &client, + http_registry.as_deref(), + Some(&server_id), cancel_flag.as_deref(), ) .await?; @@ -191,7 +202,7 @@ mod tests { let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/stream/track-1", server.uri()); - let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None) + let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None) .await .unwrap(); assert!(path.exists()); @@ -211,7 +222,7 @@ mod tests { let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/should-not-be-hit", server.uri()); - let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None) + let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None) .await .unwrap(); assert_eq!(path, pre_existing); @@ -232,7 +243,7 @@ mod tests { let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/stream/missing", server.uri()); - let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None) + let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None, None, None) .await .unwrap_err(); assert!(err.contains("HTTP 404"), "got {err}"); @@ -256,7 +267,7 @@ mod tests { let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/track", server.uri()); - download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None) + download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None, None, None) .await .unwrap(); assert!(cache_dir.join("t.mp3").exists()); @@ -278,7 +289,7 @@ mod tests { let cancel = AtomicBool::new(true); let err = - download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel)) + download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, Some(&cancel)) .await .unwrap_err(); assert_eq!(err, "CANCELLED"); diff --git a/src-tauri/crates/psysonic-syncfs/src/file_transfer.rs b/src-tauri/crates/psysonic-syncfs/src/file_transfer.rs index 73a7996c..cd499d5d 100644 --- a/src-tauri/crates/psysonic-syncfs/src/file_transfer.rs +++ b/src-tauri/crates/psysonic-syncfs/src/file_transfer.rs @@ -13,6 +13,20 @@ pub fn subsonic_http_client(timeout: Duration) -> Result, + server_ref: Option<&str>, + url: &str, +) -> reqwest::RequestBuilder { + psysonic_core::server_http::apply_optional_registry_headers( + registry, + server_ref, + url, + client.get(url), + ) +} + /// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full /// file in memory — keeps RAM flat regardless of file size. /// diff --git a/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs b/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs index dd8ed85b..48f5067a 100644 --- a/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs +++ b/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs @@ -1,11 +1,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use crate::sync_cancel_flags; -use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client}; use super::device::{ build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult, TrackSyncInfo, @@ -107,6 +107,7 @@ pub struct SyncDeltaResult { pub async fn fetch_subsonic_songs( client: &reqwest::Client, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, auth: &SubsonicAuthPayload, endpoint: &str, id: &str, @@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs( ("f", auth.f.as_str()), ("id", id), ]; - let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?; + let res = apply_server_http_get(client, registry, None, &url) + .query(&query) + .send() + .await + .map_err(|e| e.to_string())?; let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; parse_subsonic_songs(&json, endpoint) } @@ -239,8 +244,12 @@ pub async fn calculate_sync_payload( deletion_ids: Vec, auth: SubsonicAuthPayload, target_dir: String, + app: tauri::AppHandle, ) -> Result { let client = subsonic_http_client(std::time::Duration::from_secs(30))?; + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); let mut add_bytes = 0; let mut add_count = 0; @@ -264,17 +273,19 @@ pub async fn calculate_sync_payload( v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), }; let cli = client.clone(); + let reg_for_task = http_registry.clone(); let source_snapshot = source.clone(); let handle = tokio::spawn(async move { + let registry = reg_for_task.as_deref(); let mut res_tracks = Vec::new(); if source.source_type == "album" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } } else if source.source_type == "playlist" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } } else if source.source_type == "artist" { let url = format!("{}/getArtist.view", auth_clone.base_url); let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)]; - if let Ok(re) = cli.get(&url).query(&query).send().await { + if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await { if let Ok(js) = re.json::().await { if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) { let arr = root.as_array().cloned().unwrap_or_else(|| { @@ -282,7 +293,7 @@ pub async fn calculate_sync_payload( }); for al in arr { if let Some(aid) = al.get("id").and_then(|i| i.as_str()) { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await { + if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", aid).await { res_tracks.extend(ts); } } @@ -303,12 +314,14 @@ pub async fn calculate_sync_payload( v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), }; let cli = client.clone(); + let reg_for_task = http_registry.clone(); del_handles.push(tokio::spawn(async move { + let registry = reg_for_task.as_deref(); let mut res_tracks = Vec::new(); if source.source_type == "album" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } } else if source.source_type == "playlist" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } } res_tracks })); @@ -437,6 +450,9 @@ pub async fn sync_batch_to_device( // Shared reqwest client — reused across all downloads. let client = subsonic_http_client(Duration::from_secs(300))?; + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); // Concurrency limiter: max 2 parallel USB writes. let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2)); @@ -455,6 +471,7 @@ pub async fn sync_batch_to_device( for track in tracks { let sem = semaphore.clone(); let cli = client.clone(); + let reg_for_task = http_registry.clone(); let app2 = app.clone(); let job = job_id.clone(); let dest = dest_dir.clone(); @@ -466,6 +483,7 @@ pub async fn sync_batch_to_device( handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.expect("semaphore closed"); + let registry = reg_for_task.as_deref(); // Bail out if cancelled while waiting in the semaphore queue. if cancel.load(Ordering::Relaxed) { return; } @@ -492,7 +510,7 @@ pub async fn sync_batch_to_device( } } - let response = match cli.get(&track.url).send().await { + let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await { Ok(r) if r.status().is_success() => r, Ok(r) => { f.fetch_add(1, Ordering::Relaxed); @@ -819,7 +837,7 @@ mod tests { let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5)) .unwrap(); let auth = fake_auth(server.uri()); - let songs = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "album-42") + let songs = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "album-42") .await .unwrap(); assert_eq!(songs.len(), 2); @@ -838,7 +856,7 @@ mod tests { let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5)) .unwrap(); let auth = fake_auth(server.uri()); - let result = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "missing").await; + let result = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "missing").await; // 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we // just assert the function does not panic and propagates an error string. assert!(result.is_err()); @@ -989,7 +1007,7 @@ mod tests { let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5)) .unwrap(); let auth = fake_auth(server.uri()); - let songs = fetch_subsonic_songs(&client, &auth, "getPlaylist.view", "p1") + let songs = fetch_subsonic_songs(&client, None, &auth, "getPlaylist.view", "p1") .await .unwrap(); assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec"); diff --git a/src-tauri/crates/psysonic-syncfs/src/sync/device.rs b/src-tauri/crates/psysonic-syncfs/src/sync/device.rs index 83c3bb3a..9cd3837d 100644 --- a/src-tauri/crates/psysonic-syncfs/src/sync/device.rs +++ b/src-tauri/crates/psysonic-syncfs/src/sync/device.rs @@ -1,6 +1,6 @@ -use tauri::Emitter; +use tauri::{Emitter, Manager}; -use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client}; // ─── Device Sync ───────────────────────────────────────────────────────────── @@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track( suffix: &str, url: &str, client: &reqwest::Client, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: Option<&str>, ) -> Result { if dest_path.exists() { return Ok(false); @@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track( .await .map_err(|e| e.to_string())?; } - let response = client.get(url).send().await.map_err(|e| e.to_string())?; + let response = apply_server_http_get(client, registry, server_ref, url) + .send() + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } @@ -366,7 +371,19 @@ pub async fn sync_track_to_device( let path_str = dest_path.to_string_lossy().to_string(); let client = subsonic_http_client(std::time::Duration::from_secs(300))?; - match sync_download_one_track(&dest_path, &track.suffix, &track.url, &client).await { + let http_registry = app + .try_state::>() + .map(|s| std::sync::Arc::clone(&*s)); + match sync_download_one_track( + &dest_path, + &track.suffix, + &track.url, + &client, + http_registry.as_deref(), + None, + ) + .await + { Ok(false) => { let _ = app.emit("device:sync:progress", serde_json::json!({ "jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str, @@ -656,7 +673,7 @@ mod tests { let dest = dir.path().join("Album").join("01 - track.flac"); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/track", server.uri()); - let downloaded = sync_download_one_track(&dest, "flac", &url, &client) + let downloaded = sync_download_one_track(&dest, "flac", &url, &client, None, None) .await .unwrap(); assert!(downloaded, "fresh download must report Ok(true)"); @@ -672,7 +689,7 @@ mod tests { let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/should-not-be-hit", server.uri()); - let downloaded = sync_download_one_track(&dest, "mp3", &url, &client) + let downloaded = sync_download_one_track(&dest, "mp3", &url, &client, None, None) .await .unwrap(); assert!(!downloaded, "pre-existing file must be reported as skipped"); @@ -692,7 +709,7 @@ mod tests { let dest = dir.path().join("track.opus"); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/missing", server.uri()); - let err = sync_download_one_track(&dest, "opus", &url, &client) + let err = sync_download_one_track(&dest, "opus", &url, &client, None, None) .await .unwrap_err(); assert!(err.contains("HTTP 403")); @@ -714,7 +731,7 @@ mod tests { assert!(!dest.parent().unwrap().exists()); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let url = format!("{}/t", server.uri()); - sync_download_one_track(&dest, "mp3", &url, &client) + sync_download_one_track(&dest, "mp3", &url, &client, None, None) .await .unwrap(); assert!(dest.exists()); diff --git a/src-tauri/src/cover_cache/external.rs b/src-tauri/src/cover_cache/external.rs index 849d3d2a..223c1795 100644 --- a/src-tauri/src/cover_cache/external.rs +++ b/src-tauri/src/cover_cache/external.rs @@ -63,13 +63,15 @@ pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) -> /// `Ok(None)` when the artist carries no MBID tag. pub async fn fetch_artist_tag_mbid( client: &Client, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: Option<&str>, rest_base: &str, username: &str, password: &str, artist_id: &str, ) -> Result, String> { let url = build_artist_info2_url(rest_base, username, password, artist_id); - let body = http_get_text(client, &url).await?; + let body = http_get_text_scoped(client, registry, server_ref, &url).await?; let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; let mbid = v .get("subsonic-response") @@ -218,8 +220,21 @@ fn classify_mb_releases(v: &serde_json::Value) -> MbResolution { } /// Single GET → response text; any non-2xx is an error. -async fn http_get_text(client: &Client, url: &str) -> Result { - let resp = client.get(url).send().await.map_err(|e| e.to_string())?; +async fn http_get_text_scoped( + client: &Client, + registry: Option<&psysonic_core::server_http::ServerHttpRegistry>, + server_ref: Option<&str>, + url: &str, +) -> Result { + let resp = psysonic_core::server_http::apply_optional_registry_headers( + registry, + server_ref, + url, + client.get(url), + ) + .send() + .await + .map_err(|e| e.to_string())?; let status = resp.status(); if !status.is_success() { return Err(format!("HTTP {status}")); diff --git a/src-tauri/src/cover_cache/external_ensure.rs b/src-tauri/src/cover_cache/external_ensure.rs index 05501973..ad2264bc 100644 --- a/src-tauri/src/cover_cache/external_ensure.rs +++ b/src-tauri/src/cover_cache/external_ensure.rs @@ -242,12 +242,18 @@ pub(super) async fn try_external_fanart( let _permit = fanart_sem.clone().acquire_owned().await.ok()?; + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + // §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache // already carries one (skip the Navidrome round-trip). let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) { Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())), None => match external::fetch_artist_tag_mbid( client, + http_registry.as_deref(), + Some(server_id), &args.rest_base_url, &args.username, &args.password, @@ -344,7 +350,7 @@ pub(super) async fn try_external_fanart( } }; - let bytes = match fetch::fetch_cover_bytes(client, &img_url).await { + let bytes = match fetch::fetch_cover_bytes(client, &img_url, None, None).await { Ok(b) => b, Err(e) => { eprintln!("[fanart] download failed: {e}"); // transient — don't cache diff --git a/src-tauri/src/cover_cache/fetch.rs b/src-tauri/src/cover_cache/fetch.rs index 0bca246f..1b55bac0 100644 --- a/src-tauri/src/cover_cache/fetch.rs +++ b/src-tauri/src/cover_cache/fetch.rs @@ -1,4 +1,5 @@ use reqwest::Client; +use psysonic_core::server_http::ServerHttpRegistry; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use url::Url; @@ -83,8 +84,21 @@ enum FetchAttempt { Permanent(String), } -async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt { - let resp = match client.get(url).send().await { +async fn fetch_cover_once( + client: &Client, + url: &str, + registry: Option<&ServerHttpRegistry>, + server_ref: Option<&str>, +) -> FetchAttempt { + let mut req = client.get(url); + if let Some(reg) = registry { + if let Some(sid) = server_ref.filter(|s| !s.is_empty()) { + req = reg.apply_for_http_url(sid, url, req); + } else if let Some(ctx) = reg.get_for_server_url(url) { + req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url); + } + } + let resp = match req.send().await { Ok(r) => r, // Connection reset / timeout / DNS — transient under server load. Err(e) => return FetchAttempt::Transient(e.to_string()), @@ -104,10 +118,15 @@ async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt { } } -pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result, String> { +pub async fn fetch_cover_bytes( + client: &Client, + url: &str, + registry: Option<&ServerHttpRegistry>, + server_ref: Option<&str>, +) -> Result, String> { let mut last_err = String::from("cover fetch failed"); for attempt in 0..COVER_FETCH_ATTEMPTS { - match fetch_cover_once(client, url).await { + match fetch_cover_once(client, url, registry, server_ref).await { FetchAttempt::Ok(bytes) => return Ok(bytes), FetchAttempt::Permanent(e) => return Err(e), FetchAttempt::Transient(e) => { diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 9b081ddb..8dabb0ee 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -354,7 +354,10 @@ impl CoverCacheState { let source = if let Some(img) = load_image_from_disk(&dir) { CoverSource::Image(img) } else { - match download_cover_payload(&dir, &client, &http_sem, args).await { + let http_registry = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + match download_cover_payload(&dir, &client, &http_sem, args, http_registry).await { Ok(bytes) => CoverSource::Bytes(bytes), Err(err) => { log_cover_fetch_failure(app, args, &err); @@ -532,6 +535,7 @@ async fn download_cover_payload( client: &Client, http_sem: &Semaphore, args: &CoverCacheEnsureArgs, + registry: Option>, ) -> Result, String> { let _permit = http_sem .acquire() @@ -549,7 +553,13 @@ async fn download_cover_payload( &args.cover_art_id, fetch_size, ); - fetch::fetch_cover_bytes(client, &url).await + fetch::fetch_cover_bytes( + client, + &url, + registry.as_deref(), + Some(args.server_index_key.as_str()), + ) + .await } fn spawn_derive_remaining_tiers( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5226014f..b052af08 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,6 +106,7 @@ pub fn run() { let builder = tauri::Builder::default() .manage(audio_engine) + .manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new())) .manage(ShortcutMap::default()) .manage(discord::DiscordState::new()) .manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore) @@ -238,7 +239,10 @@ pub fn run() { let flags = psysonic_library::sync::capability::CapabilityFlags::new( flags_bits, ); - let subsonic = psysonic_integration::subsonic::SubsonicClient::new( + let registry = app_for_sched.state::>(); + let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry( + Some(registry.as_ref()), + &session.server_id, session.base_url.clone(), session.username.clone(), session.password.clone(), @@ -251,7 +255,8 @@ pub fn run() { scope.clone(), flags, ) - .with_playback_hint(hint); + .with_playback_hint(hint) + .with_http_registry(Some(Arc::clone(®istry))); if let Some(tok) = session.navidrome_token.clone() { sched = sched.with_navidrome_credentials( psysonic_library::sync::capability::NavidromeProbeCredentials { @@ -682,6 +687,9 @@ pub fn run() { migration_inspect, migration_run, resolve_host_addresses, + server_http_context_sync, + server_http_context_sync_all, + server_http_context_clear, psysonic_syncfs::sync::batch::calculate_sync_payload, exit_app, cli_publish_player_snapshot, diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs index 8193ef07..de2dbd3e 100644 --- a/src-tauri/src/lib_commands/app_api/mod.rs +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -36,7 +36,10 @@ pub(crate) use integration::{ unregister_global_shortcut, }; pub(crate) use migration::{migration_inspect, migration_run}; -pub(crate) use network::resolve_host_addresses; +pub(crate) use network::{ + resolve_host_addresses, server_http_context_clear, server_http_context_sync, + server_http_context_sync_all, +}; // Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown, // and analysis admin commands now live in their domain crates. invoke_handler! diff --git a/src-tauri/src/lib_commands/app_api/network.rs b/src-tauri/src/lib_commands/app_api/network.rs index c19a04e2..990b0435 100644 --- a/src-tauri/src/lib_commands/app_api/network.rs +++ b/src-tauri/src/lib_commands/app_api/network.rs @@ -2,6 +2,11 @@ //! dual-server-address add/edit form (UI hint only — not for connect). use std::collections::HashSet; + +use std::sync::Arc; + +use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry}; +use tauri::State; use tokio::net::lookup_host; /// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6). @@ -58,6 +63,34 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result>, + wire: ServerHttpContextSyncWire, +) -> Result<(), String> { + registry.sync(wire); + Ok(()) +} + +#[tauri::command] +pub(crate) fn server_http_context_sync_all( + registry: State<'_, Arc>, + entries: Vec, +) -> Result<(), String> { + registry.sync_all(entries); + Ok(()) +} + +#[tauri::command] +pub(crate) fn server_http_context_clear( + registry: State<'_, Arc>, + server_id: String, + app_server_id: String, +) -> Result<(), String> { + registry.remove(&server_id, &app_server_id); + Ok(()) +} + /// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves /// bracketed IPv6 with no port (`[::1]`) and bare hosts alone. fn strip_port(input: &str) -> String { diff --git a/src/api/subsonic.async.test.ts b/src/api/subsonic.async.test.ts index 7f617e66..87d5befc 100644 --- a/src/api/subsonic.async.test.ts +++ b/src/api/subsonic.async.test.ts @@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({ })); import axios from 'axios'; -import { pingWithCredentials, ping } from './subsonic'; +import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic'; import { getAlbumInfo2 } from './subsonicAlbumInfo'; import { getStarred } from './subsonicStarRating'; import { search } from './subsonicSearch'; @@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => { expect(r.openSubsonic).toBe(false); }); }); + +describe('pingWithCredentialsForProfile — custom gate headers', () => { + it('sends resolved custom headers on the ping request', async () => { + vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' })); + await pingWithCredentialsForProfile( + { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + username: 'u', + password: 'p', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }], + customHeadersApplyTo: 'public', + }, + 'https://music.example.com', + ); + const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record }; + expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret'); + }); + + it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => { + vi.mocked(axios.get).mockResolvedValue(okResponse({})); + await pingWithCredentialsForProfile( + { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + username: 'u', + password: 'p', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }], + customHeadersApplyTo: 'public', + }, + 'http://192.168.0.10:4533', + ); + const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record }; + expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined(); + }); +}); diff --git a/src/api/subsonic.scheduleProbe.test.ts b/src/api/subsonic.scheduleProbe.test.ts index 410ae5d5..c93a59dd 100644 --- a/src/api/subsonic.scheduleProbe.test.ts +++ b/src/api/subsonic.scheduleProbe.test.ts @@ -31,11 +31,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => { fetchMock.mockReset(); fetchMock.mockResolvedValue(['sonicSimilarity']); reset(); + useAuthStore.setState({ + servers: [{ + id: SID, + name: 'Probe', + url: 'https://music.example.com', + username: 'u', + password: 'p', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }], + customHeadersApplyTo: 'public', + }], + } as never); }); it('probes once, caches the result, then skips on the next poll', async () => { scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({ + url: 'https://music.example.com', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }], + }); await flush(); expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present'); diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index ab3e91f0..bbb4bcbf 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -1,6 +1,9 @@ import axios from 'axios'; import md5 from 'md5'; import { useAuthStore } from '../store/authStore'; +import type { ServerProfile } from '../store/authStoreTypes'; +import { headersForServerRequest } from '../utils/server/serverHttpHeaders'; +import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { type InstantMixProbeResult, type SubsonicServerIdentity, @@ -19,6 +22,7 @@ import { api, apiWithCredentials, secureRandomSalt, + type ServerHttpHeaderProfile, } from './subsonicClient'; import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes'; @@ -61,6 +65,47 @@ export async function pingWithCredentials( } } +/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */ +export async function pingWithCredentialsForProfile( + profile: Pick< + ServerProfile, + 'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo' + >, + endpointBaseUrl: string, +): Promise { + try { + const base = endpointBaseUrl.startsWith('http') + ? endpointBaseUrl.replace(/\/$/, '') + : `http://${endpointBaseUrl.replace(/\/$/, '')}`; + const salt = secureRandomSalt(); + const token = md5(profile.password + salt); + const resp = await axios.get(`${base}/rest/ping.view`, { + params: { + u: profile.username, + t: token, + s: salt, + v: '1.16.1', + c: SUBSONIC_CLIENT, + f: 'json', + }, + headers: headersForServerRequest(profile, endpointBaseUrl), + paramsSerializer: { indexes: null }, + timeout: 15000, + }); + const data = resp.data?.['subsonic-response']; + const ok = data?.status === 'ok'; + return { + ok, + type: typeof data?.type === 'string' ? data.type : undefined, + serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined, + openSubsonic: data?.openSubsonic === true, + }; + } catch (err) { + console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err); + return { ok: false }; + } +} + const INSTANT_MIX_PROBE_RANDOM_SIZE = 8; const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12; const INSTANT_MIX_PROBE_MAX_TRACKS = 4; @@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials( serverUrl: string, username: string, password: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise { try { const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>( @@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials( 'getRandomSongs.view', { size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() }, 12000, + headerProfile, ); const raw = data.randomSongs?.song; const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw]; @@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials( 'getSimilarSongs.view', { id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT }, 12000, + headerProfile, ); const sRaw = simData.similarSongs?.song; const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw]; @@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer( const ctx = buildCapabilityContext(identity); const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx); const store = useAuthStore.getState(); + const headerProfile = findServerByIdOrIndexKey(serverId); if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) { // One `getOpenSubsonicExtensions` fetch answers every extension-gated feature. @@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer( const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error'); if (force || listMissing || audiomuseStale) { if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing'); - void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => { + void fetchOpenSubsonicExtensionsWithCredentials( + serverUrl, + username, + password, + headerProfile, + ).then(extensions => { const st = useAuthStore.getState(); if (extensions === null) { if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error'); @@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer( if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) { const cached = store.instantMixProbeByServer[serverId]; if (force || cached === undefined || cached === 'error') { - void probeInstantMixWithCredentials(serverUrl, username, password).then(result => + void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result => useAuthStore.getState().setInstantMixProbe(serverId, result), ); } diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index fdfa0c65..d3dd6a1c 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -4,10 +4,17 @@ import { version } from '../../package.json'; import { useAuthStore } from '../store/authStore'; import type { ServerProfile } from '../store/authStoreTypes'; import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; +import { headersForServerRequest } from '../utils/server/serverHttpHeaders'; import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup'; export const SUBSONIC_CLIENT = `psysonic/${version}`; +/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */ +export type ServerHttpHeaderProfile = Pick< + ServerProfile, + 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo' +>; + export function secureRandomSalt(): string { const buf = new Uint8Array(8); crypto.getRandomValues(buf); @@ -32,10 +39,13 @@ export async function apiWithCredentials( endpoint: string, extra: Record = {}, timeout = 15000, + headerProfile?: ServerHttpHeaderProfile, ): Promise { const params = { ...getAuthParams(username, password), ...extra }; + const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {}; const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, { params, + headers, paramsSerializer: { indexes: null }, timeout, }); @@ -78,6 +88,7 @@ export async function apiForServer( endpoint, extra, timeout, + server, ); } @@ -88,8 +99,13 @@ export async function api( signal?: AbortSignal, ): Promise { const { baseUrl, params } = getClient(); + const server = useAuthStore.getState().getActiveServer(); + const connectBase = useAuthStore.getState().getBaseUrl(); + const headers = + server && connectBase ? headersForServerRequest(server, connectBase) : {}; const resp = await axios.get(`${baseUrl}/${endpoint}`, { params: { ...params, ...extra }, + headers, paramsSerializer: { indexes: null }, timeout, signal, diff --git a/src/api/subsonicEntityWithCredentials.ts b/src/api/subsonicEntityWithCredentials.ts index b0749038..dc96aaf5 100644 --- a/src/api/subsonicEntityWithCredentials.ts +++ b/src/api/subsonicEntityWithCredentials.ts @@ -1,4 +1,4 @@ -import { apiWithCredentials } from './subsonicClient'; +import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes'; export async function getSongWithCredentials( @@ -6,6 +6,7 @@ export async function getSongWithCredentials( username: string, password: string, id: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise { try { const data = await apiWithCredentials<{ song: SubsonicSong }>( @@ -14,6 +15,8 @@ export async function getSongWithCredentials( password, 'getSong.view', { id }, + 15000, + headerProfile, ); return data.song ?? null; } catch { @@ -26,6 +29,7 @@ export async function getAlbumWithCredentials( username: string, password: string, id: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>( serverUrl, @@ -33,6 +37,8 @@ export async function getAlbumWithCredentials( password, 'getAlbum.view', { id }, + 15000, + headerProfile, ); const { song, ...album } = data.album; return { album, songs: song ?? [] }; @@ -43,6 +49,7 @@ export async function getArtistWithCredentials( username: string, password: string, id: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>( serverUrl, @@ -50,6 +57,8 @@ export async function getArtistWithCredentials( password, 'getArtist.view', { id }, + 15000, + headerProfile, ); const { album, ...artist } = data.artist; return { artist, albums: album ?? [] }; diff --git a/src/api/subsonicOpenSubsonic.test.ts b/src/api/subsonicOpenSubsonic.test.ts index c2d75ac8..3c02f8a1 100644 --- a/src/api/subsonicOpenSubsonic.test.ts +++ b/src/api/subsonicOpenSubsonic.test.ts @@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => { fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'), ).resolves.toBeNull(); }); + + it('sends custom gate headers when a header profile is supplied', async () => { + vi.mocked(axios.get).mockResolvedValue(okExtensions([])); + await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', { + url: 'https://music.test', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }], + customHeadersApplyTo: 'public', + }); + const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record }; + expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret'); + }); }); diff --git a/src/api/subsonicOpenSubsonic.ts b/src/api/subsonicOpenSubsonic.ts index 1f6834fe..beb51385 100644 --- a/src/api/subsonicOpenSubsonic.ts +++ b/src/api/subsonicOpenSubsonic.ts @@ -1,4 +1,4 @@ -import { apiWithCredentials } from './subsonicClient'; +import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient'; export interface OpenSubsonicExtension { name: string; @@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials( serverUrl: string, username: string, password: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise { try { const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>( @@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials( 'getOpenSubsonicExtensions.view', {}, 12000, + headerProfile, ); return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name); } catch { diff --git a/src/components/PlayerBar.test.tsx b/src/components/PlayerBar.test.tsx index 4e57f3cf..6b3c020b 100644 --- a/src/components/PlayerBar.test.tsx +++ b/src/components/PlayerBar.test.tsx @@ -16,6 +16,12 @@ vi.mock('@/api/subsonic', () => ({ serverVersion: '0.55.0', openSubsonic: true, })), + pingWithCredentialsForProfile: vi.fn(async () => ({ + ok: true, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + })), scheduleInstantMixProbeForServer: vi.fn(), buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`), buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`), diff --git a/src/components/settings/AddServerForm.test.tsx b/src/components/settings/AddServerForm.test.tsx index 1f3476cf..d9044bfd 100644 --- a/src/components/settings/AddServerForm.test.tsx +++ b/src/components/settings/AddServerForm.test.tsx @@ -143,3 +143,33 @@ describe('AddServerForm — dual-address behaviour', () => { expect(arg).not.toHaveProperty('shareUsesLocalUrl'); }); }); + +describe('AddServerForm — custom HTTP headers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('includes configured custom headers on save', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[1]!, 'https://music.example.com'); + await user.type(inputs[3]!, 'tester'); + await user.type(screen.getByPlaceholderText('••••••••'), 'pw'); + + await user.click(screen.getByRole('button', { name: /custom http headers/i })); + const headerNameInputs = screen.getAllByPlaceholderText(/header name/i); + const headerValueInputs = screen.getAllByPlaceholderText(/header value/i); + await user.type(headerNameInputs[0]!, 'CF-Access-Client-Secret'); + await user.type(headerValueInputs[0]!, 'gate-secret'); + + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]); + expect(arg.customHeadersApplyTo).toBe('public'); + }); +}); diff --git a/src/components/settings/AddServerForm.tsx b/src/components/settings/AddServerForm.tsx index 893aed12..d07fb41f 100644 --- a/src/components/settings/AddServerForm.tsx +++ b/src/components/settings/AddServerForm.tsx @@ -1,7 +1,11 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ServerProfile } from '../../store/authStoreTypes'; +import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../../store/authStoreTypes'; import { showToast } from '../../utils/ui/toast'; +import { + DEFAULT_CUSTOM_HEADERS_APPLY_TO, + validateCustomHeaders, +} from '../../utils/server/serverHttpHeaders'; import { decodeServerMagicString, encodeServerMagicString, @@ -19,6 +23,9 @@ type FormState = { shareUsesLocalUrl: boolean; username: string; password: string; + customHeaders: CustomHeaderEntry[]; + customHeadersApplyTo: CustomHeadersApplyTo; + customHeadersOpen: boolean; }; /** Hostname for the DNS-based form hint, or null when the input is a literal IP. */ @@ -62,6 +69,12 @@ export function AddServerForm({ shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false, username: editingServer.username, password: editingServer.password, + customHeaders: editingServer.customHeaders?.length + ? editingServer.customHeaders.map(h => ({ ...h })) + : [{ name: '', value: '' }], + customHeadersApplyTo: + editingServer.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO, + customHeadersOpen: Boolean(editingServer.customHeaders?.length), } : { name: '', @@ -70,6 +83,9 @@ export function AddServerForm({ shareUsesLocalUrl: false, username: '', password: '', + customHeaders: [{ name: '', value: '' }], + customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO, + customHeadersOpen: false, }, ); const [magicString, setMagicString] = useState(''); @@ -175,6 +191,20 @@ export function AddServerForm({ return true; }; + const customHeadersPayload = (): Pick< + ServerProfile, + 'customHeaders' | 'customHeadersApplyTo' + > => { + const rows = form.customHeaders + .map(h => ({ name: h.name.trim(), value: h.value })) + .filter(h => h.name || h.value); + if (!rows.length) return {}; + return { + customHeaders: rows, + customHeadersApplyTo: form.customHeadersApplyTo, + }; + }; + const submit = async () => { const ms = magicString.trim(); if (ms) { @@ -193,6 +223,7 @@ export function AddServerForm({ url: decoded.url, username: decoded.username, password: decoded.password, + ...customHeadersPayload(), ...(altDecoded ? { alternateUrl: altDecoded, @@ -205,6 +236,15 @@ export function AddServerForm({ if (!form.url.trim()) return; if (!validateAddresses()) return; + const headerValidation = validateCustomHeaders( + form.customHeaders.filter(h => h.name.trim() || h.value), + ); + if (!headerValidation.ok) { + const first = headerValidation.fieldErrors[0]; + showToast(t(first.messageKey, { defaultValue: first.messageKey }), 5000, 'error'); + return; + } + const altTrimmed = form.alternateUrl.trim(); // If the user clears the second address, strip the share-flag as well so // we don't leave a dangling preference (spec §5.3 last row). @@ -213,6 +253,7 @@ export function AddServerForm({ url: form.url.trim(), username: form.username.trim(), password: form.password, + ...customHeadersPayload(), ...(altTrimmed ? { alternateUrl: altTrimmed, @@ -338,6 +379,108 @@ export function AddServerForm({ )} +
+ + {form.customHeadersOpen && ( +
+

+ {t('settings.customHeadersHelp')} +

+ {form.customHeaders.map((row, index) => ( +
+ { + const name = e.target.value; + setForm(f => { + const customHeaders = f.customHeaders.map((h, i) => + i === index ? { ...h, name } : h, + ); + return { ...f, customHeaders }; + }); + }} + placeholder={t('settings.customHeadersNamePlaceholder')} + autoComplete="off" + /> + { + const value = e.target.value; + setForm(f => ({ + ...f, + customHeaders: f.customHeaders.map((h, i) => + i === index ? { ...h, value } : h, + ), + })); + }} + placeholder={t('settings.customHeadersValuePlaceholder')} + autoComplete="off" + /> + +
+ ))} + +
h.name.trim() || h.value)} + style={{ border: 'none', padding: 0, margin: 0 }} + > + {t('settings.customHeadersApplyTo')} + {(['public', 'local', 'both'] as const).map(kind => ( + + ))} +
+

+ {t('settings.customHeadersNotInShare')} +

+
+ )} +
{!isEdit && (
diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index e271e3d8..b6f8ca2d 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -12,7 +12,11 @@ import { bootstrapIndexedServer } from '../../utils/library/librarySession'; import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync'; import ServerLibraryIndexControls from './ServerLibraryIndexControls'; import type { ServerProfile } from '../../store/authStoreTypes'; -import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; +import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '../../api/subsonic'; +import { + clearServerHttpContext, + syncServerHttpContextForProfile, +} from '../../utils/server/syncServerHttpContext'; import { useDragDrop } from '../../contexts/DragDropContext'; import { type ServerMagicPayload } from '../../utils/server/serverMagicString'; import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint'; @@ -191,6 +195,7 @@ export function ServersTab({ auth.removeServer(server.id); try { + await clearServerHttpContext(server); await librarySyncClearSession(server.id); if (purgeLibrary) { await libraryDeleteServerData(server.id); @@ -238,7 +243,12 @@ export function ServersTab({ // straight to the legacy ping (which is also the connect-test). if (data.alternateUrl) { const verify = await verifySameServerEndpoints( - { url: data.url, alternateUrl: data.alternateUrl }, + { + url: data.url, + alternateUrl: data.alternateUrl, + customHeaders: data.customHeaders, + customHeadersApplyTo: data.customHeadersApplyTo, + }, data.username, data.password, ); @@ -247,7 +257,7 @@ export function ServersTab({ return; } } - const ping = await pingWithCredentials(data.url, data.username, data.password); + const ping = await pingWithCredentialsForProfile(data, data.url); if (ping.ok) { const id = auth.addServer(data); const identity = { @@ -259,7 +269,10 @@ export function ServersTab({ scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true); setConnStatus(s => ({ ...s, [id]: 'ok' })); const added = useAuthStore.getState().servers.find(s => s.id === id); - if (added) void bootstrapIndexedServer(added); + if (added) { + void syncServerHttpContextForProfile(added); + void bootstrapIndexedServer(added); + } } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } @@ -323,7 +336,12 @@ export function ServersTab({ if (dualAddressChanged) { setConnStatus(s => ({ ...s, [id]: 'testing' })); const verify = await verifySameServerEndpoints( - { url: data.url, alternateUrl: data.alternateUrl }, + { + url: data.url, + alternateUrl: data.alternateUrl, + customHeaders: data.customHeaders, + customHeadersApplyTo: data.customHeadersApplyTo, + }, data.username, data.password, ); @@ -335,12 +353,13 @@ export function ServersTab({ setEditingServerId(null); auth.updateServer(id, data); + const updated = useAuthStore.getState().servers.find(s => s.id === id); + if (updated) void syncServerHttpContextForProfile(updated); // Profile edited → any cached sticky connect URL for this id may now be - // stale (credentials may have changed, alternate may have been added). invalidateReachableEndpointCache(id); setConnStatus(s => ({ ...s, [id]: 'testing' })); try { - const ping = await pingWithCredentials(data.url, data.username, data.password); + const ping = await pingWithCredentialsForProfile(data, data.url); if (ping.ok) { const identity = { type: ping.type, diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index b31d43f0..7d09e382 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -172,6 +172,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Niri compositor tiling WM detection (PR #1127)', 'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)', 'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)', + 'Custom HTTP headers for reverse-proxy gates — per-server header editor, TS/Rust registry sync, full playback/sync/cover path coverage, log redaction (PR #1156)', ], }, { diff --git a/src/hooks/useConnectionStatus.test.ts b/src/hooks/useConnectionStatus.test.ts index dc59fa1b..9b72ce6d 100644 --- a/src/hooks/useConnectionStatus.test.ts +++ b/src/hooks/useConnectionStatus.test.ts @@ -9,6 +9,7 @@ import { vi.mock('@/api/subsonic', () => ({ pingWithCredentials: vi.fn(), + pingWithCredentialsForProfile: vi.fn(), scheduleInstantMixProbeForServer: vi.fn(), })); @@ -16,7 +17,7 @@ vi.mock('@/utils/perf/perfFlags', () => ({ usePerfProbeFlags: () => ({ disableBackgroundPolling: false }), })); -import { pingWithCredentials } from '@/api/subsonic'; +import { pingWithCredentialsForProfile } from '@/api/subsonic'; import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore'; import { useConnectionStatus } from './useConnectionStatus'; @@ -24,7 +25,7 @@ beforeEach(() => { resetAuthStore(); invalidateReachableEndpointCache(); useDevOfflineBrowseStore.getState().setForceOffline(false); - vi.mocked(pingWithCredentials).mockReset(); + vi.mocked(pingWithCredentialsForProfile).mockReset(); }); afterEach(() => { @@ -49,7 +50,7 @@ describe('useConnectionStatus.isLan', () => { // LAN endpoint answers — alternateUrl is the LAN side here, so a // primary-url-only check would say "public". We assert it says "local" // (active endpoint kind). - vi.mocked(pingWithCredentials).mockImplementation(async url => + vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) => url === 'http://192.168.0.10' ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } : { ok: false }, @@ -63,7 +64,7 @@ describe('useConnectionStatus.isLan', () => { it('falls back to public when only the public address answers', async () => { vi.useFakeTimers(); seedDualAddressServer(); - vi.mocked(pingWithCredentials).mockImplementation(async url => + vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) => url === 'https://music.example.com' ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } : { ok: false }, @@ -88,7 +89,7 @@ describe('useConnectionStatus.isLan', () => { seedDualAddressServer(); // Don't resolve the ping — the hook is still in the `checking` state. let _resolve: ((v: PickReachableResult) => void) | null = null; - vi.mocked(pingWithCredentials).mockReturnValue( + vi.mocked(pingWithCredentialsForProfile).mockReturnValue( new Promise(r => { _resolve = ((res: PickReachableResult) => { if (res.ok) { @@ -116,7 +117,7 @@ describe('useConnectionStatus online event', () => { it('flushes the reachable-endpoint cache when the browser fires online', async () => { seedDualAddressServer(); // Initial probe: LAN answers. - vi.mocked(pingWithCredentials).mockImplementation(async url => + vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) => url === 'http://192.168.0.10' ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } : { ok: false }, @@ -129,8 +130,8 @@ describe('useConnectionStatus online event', () => { // fire in this test; we trigger the online event instead. The handler // invalidates the sticky cache so the next probe goes LAN-first and // flips over to public when LAN refuses. - vi.mocked(pingWithCredentials).mockClear(); - vi.mocked(pingWithCredentials).mockImplementation(async url => + vi.mocked(pingWithCredentialsForProfile).mockClear(); + vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) => url === 'https://music.example.com' ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } : { ok: false }, @@ -147,14 +148,14 @@ describe('useConnectionStatus online event', () => { await waitFor(() => expect(result.current.isLan).toBe(false)); // Both endpoints were probed (LAN refused, public answered). - expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2); + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(2); }); }); describe('useConnectionStatus DEV offline toggle', () => { it('does not probe again on mount beyond the polling effect', async () => { seedDualAddressServer(); - vi.mocked(pingWithCredentials).mockResolvedValue({ + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({ ok: true, type: 'navidrome', serverVersion: '0.55.0', @@ -162,15 +163,15 @@ describe('useConnectionStatus DEV offline toggle', () => { }); renderHook(() => useConnectionStatus()); - await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1)); - const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length; + await waitFor(() => expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(1)); + const callsAfterMount = vi.mocked(pingWithCredentialsForProfile).mock.calls.length; await new Promise(r => setTimeout(r, 20)); - expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount); + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsAfterMount); }); it('disconnects on force-offline toggle without an extra probe', async () => { seedDualAddressServer(); - vi.mocked(pingWithCredentials).mockResolvedValue({ + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({ ok: true, type: 'navidrome', serverVersion: '0.55.0', @@ -179,10 +180,10 @@ describe('useConnectionStatus DEV offline toggle', () => { const { result } = renderHook(() => useConnectionStatus()); await waitFor(() => expect(result.current.status).toBe('connected')); - const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length; + const callsBeforeToggle = vi.mocked(pingWithCredentialsForProfile).mock.calls.length; act(() => useDevOfflineBrowseStore.getState().setForceOffline(true)); await waitFor(() => expect(result.current.status).toBe('disconnected')); - expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle); + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle); }); }); diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index bca686d3..c1547f47 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.', serverUsername: 'Benutzername', serverPassword: 'Passwort', + customHeadersTitle: 'Benutzerdefinierte HTTP-Header', + customHeadersHelp: 'Für Reverse-Proxy-Gates (Pangolin, Cloudflare Access). Werden nur an deinen Server gesendet — nicht an Drittanbieter.', + customHeadersNamePlaceholder: 'Header-Name', + customHeadersValuePlaceholder: 'Header-Wert', + customHeadersAddRow: 'Header hinzufügen', + customHeadersRemoveRow: 'Header entfernen', + customHeadersApplyTo: 'Header anwenden auf', + customHeadersApplyTo_public: 'Öffentliche Adresse', + customHeadersApplyTo_local: 'Lokale Adresse', + customHeadersApplyTo_both: 'Beide Adressen', + customHeadersNotInShare: 'Benutzerdefinierte Header sind nicht in Freigabe-Einladungen enthalten — auf jedem Gerät erneut konfigurieren.', + 'customHeadersValidation.tooMany': 'Zu viele benutzerdefinierte Header (max. 16).', + 'customHeadersValidation.nameRequired': 'Header-Name ist erforderlich.', + 'customHeadersValidation.nameTooLong': 'Header-Name ist zu lang.', + 'customHeadersValidation.valueTooLong': 'Header-Wert ist zu lang.', + 'customHeadersValidation.crlf': 'Header-Namen und -Werte dürfen keine Zeilenumbrüche enthalten.', + 'customHeadersValidation.blocked': 'Dieser Header-Name ist nicht erlaubt.', + 'customHeadersValidation.duplicate': 'Doppelter Header-Name.', addServer: 'Server hinzufügen', addServerTitle: 'Neuen Server hinzufügen', editServer: 'Bearbeiten', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 37ba6712..1f8a68cb 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.', serverUsername: 'Username', serverPassword: 'Password', + customHeadersTitle: 'Custom HTTP headers', + customHeadersHelp: 'For reverse-proxy gates (Pangolin, Cloudflare Access). Sent only to your server — not to third-party services.', + customHeadersNamePlaceholder: 'Header name', + customHeadersValuePlaceholder: 'Header value', + customHeadersAddRow: 'Add header', + customHeadersRemoveRow: 'Remove header', + customHeadersApplyTo: 'Apply headers to', + customHeadersApplyTo_public: 'Public address', + customHeadersApplyTo_local: 'Local address', + customHeadersApplyTo_both: 'Both addresses', + customHeadersNotInShare: 'Custom headers are not included in share invites — configure them again on each device.', + 'customHeadersValidation.tooMany': 'Too many custom headers (max 16).', + 'customHeadersValidation.nameRequired': 'Header name is required.', + 'customHeadersValidation.nameTooLong': 'Header name is too long.', + 'customHeadersValidation.valueTooLong': 'Header value is too long.', + 'customHeadersValidation.crlf': 'Header names and values cannot contain line breaks.', + 'customHeadersValidation.blocked': 'This header name is not allowed.', + 'customHeadersValidation.duplicate': 'Duplicate header name.', addServer: 'Add Server', addServerTitle: 'Add New Server', editServer: 'Edit', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index d9974f8a..0ecdea8c 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.', serverUsername: 'Usuario', serverPassword: 'Contraseña', + customHeadersTitle: 'Encabezados HTTP personalizados', + customHeadersHelp: 'Para puertas de reverse proxy (Pangolin, Cloudflare Access). Se envían solo a tu servidor — no a servicios de terceros.', + customHeadersNamePlaceholder: 'Nombre del encabezado', + customHeadersValuePlaceholder: 'Valor del encabezado', + customHeadersAddRow: 'Añadir encabezado', + customHeadersRemoveRow: 'Eliminar encabezado', + customHeadersApplyTo: 'Aplicar encabezados a', + customHeadersApplyTo_public: 'Dirección pública', + customHeadersApplyTo_local: 'Dirección local', + customHeadersApplyTo_both: 'Ambas direcciones', + customHeadersNotInShare: 'Los encabezados personalizados no se incluyen en las invitaciones — configúralos de nuevo en cada dispositivo.', + 'customHeadersValidation.tooMany': 'Demasiados encabezados personalizados (máx. 16).', + 'customHeadersValidation.nameRequired': 'El nombre del encabezado es obligatorio.', + 'customHeadersValidation.nameTooLong': 'El nombre del encabezado es demasiado largo.', + 'customHeadersValidation.valueTooLong': 'El valor del encabezado es demasiado largo.', + 'customHeadersValidation.crlf': 'Los nombres y valores no pueden contener saltos de línea.', + 'customHeadersValidation.blocked': 'Este nombre de encabezado no está permitido.', + 'customHeadersValidation.duplicate': 'Nombre de encabezado duplicado.', addServer: 'Agregar Servidor', addServerTitle: 'Agregar Nuevo Servidor', editServer: 'Editar', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 61e2bc5a..920340cc 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.', serverUsername: 'Nom d\'utilisateur', serverPassword: 'Mot de passe', + customHeadersTitle: 'En-têtes HTTP personnalisés', + customHeadersHelp: 'Pour les passerelles reverse proxy (Pangolin, Cloudflare Access). Envoyés uniquement à votre serveur — pas aux services tiers.', + customHeadersNamePlaceholder: 'Nom de l’en-tête', + customHeadersValuePlaceholder: 'Valeur de l’en-tête', + customHeadersAddRow: 'Ajouter un en-tête', + customHeadersRemoveRow: 'Supprimer l’en-tête', + customHeadersApplyTo: 'Appliquer les en-têtes à', + customHeadersApplyTo_public: 'Adresse publique', + customHeadersApplyTo_local: 'Adresse locale', + customHeadersApplyTo_both: 'Les deux adresses', + customHeadersNotInShare: 'Les en-têtes personnalisés ne sont pas inclus dans les invitations — configurez-les à nouveau sur chaque appareil.', + 'customHeadersValidation.tooMany': 'Trop d’en-têtes personnalisés (max. 16).', + 'customHeadersValidation.nameRequired': 'Le nom de l’en-tête est requis.', + 'customHeadersValidation.nameTooLong': 'Le nom de l’en-tête est trop long.', + 'customHeadersValidation.valueTooLong': 'La valeur de l’en-tête est trop longue.', + 'customHeadersValidation.crlf': 'Les noms et valeurs ne peuvent pas contenir de saut de ligne.', + 'customHeadersValidation.blocked': 'Ce nom d’en-tête n’est pas autorisé.', + 'customHeadersValidation.duplicate': 'Nom d’en-tête en double.', addServer: 'Ajouter un serveur', addServerTitle: 'Ajouter un nouveau serveur', editServer: 'Modifier', diff --git a/src/locales/hu/settings.ts b/src/locales/hu/settings.ts index 53638943..2aa772ef 100644 --- a/src/locales/hu/settings.ts +++ b/src/locales/hu/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Érinti az Orbit-meghívókat és a könyvtármegosztásokat. Alapértelmezett: nyilvános cím.', serverUsername: 'Felhasználónév', serverPassword: 'Jelszó', + customHeadersTitle: 'Egyéni HTTP fejlécek', + customHeadersHelp: 'Reverse-proxy kapukhoz (Pangolin, Cloudflare Access). Csak a szerveredhez kerülnek — harmadik fél szolgáltatásaihoz nem.', + customHeadersNamePlaceholder: 'Fejléc neve', + customHeadersValuePlaceholder: 'Fejléc értéke', + customHeadersAddRow: 'Fejléc hozzáadása', + customHeadersRemoveRow: 'Fejléc eltávolítása', + customHeadersApplyTo: 'Fejlécek alkalmazása', + customHeadersApplyTo_public: 'Nyilvános cím', + customHeadersApplyTo_local: 'Helyi cím', + customHeadersApplyTo_both: 'Mindkét cím', + customHeadersNotInShare: 'Az egyéni fejlécek nem szerepelnek a meghívókban — minden eszközön külön kell beállítani.', + 'customHeadersValidation.tooMany': 'Túl sok egyéni fejléc (max. 16).', + 'customHeadersValidation.nameRequired': 'A fejléc neve kötelező.', + 'customHeadersValidation.nameTooLong': 'A fejléc neve túl hosszú.', + 'customHeadersValidation.valueTooLong': 'A fejléc értéke túl hosszú.', + 'customHeadersValidation.crlf': 'A név és az érték nem tartalmazhat sortörést.', + 'customHeadersValidation.blocked': 'Ez a fejlécnév nem engedélyezett.', + 'customHeadersValidation.duplicate': 'Ismétlődő fejlécnév.', addServer: 'Szerver hozzáadása', addServerTitle: 'Új szerver hozzáadása', editServer: 'Szerkesztés', diff --git a/src/locales/ja/settings.ts b/src/locales/ja/settings.ts index 850ed7d9..28e7178a 100644 --- a/src/locales/ja/settings.ts +++ b/src/locales/ja/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。', serverUsername: 'ユーザー名', serverPassword: 'パスワード', + customHeadersTitle: 'カスタム HTTP ヘッダー', + customHeadersHelp: 'リバースプロキシゲート用(Pangolin、Cloudflare Access)。サーバーへの送信のみ — 第三者サービスには送信しません。', + customHeadersNamePlaceholder: 'ヘッダー名', + customHeadersValuePlaceholder: 'ヘッダー値', + customHeadersAddRow: 'ヘッダーを追加', + customHeadersRemoveRow: 'ヘッダーを削除', + customHeadersApplyTo: 'ヘッダーの適用先', + customHeadersApplyTo_public: '公開アドレス', + customHeadersApplyTo_local: 'ローカルアドレス', + customHeadersApplyTo_both: '両方のアドレス', + customHeadersNotInShare: 'カスタムヘッダーは招待リンクに含まれません — 各デバイスで再度設定してください。', + 'customHeadersValidation.tooMany': 'カスタムヘッダーが多すぎます(最大 16)。', + 'customHeadersValidation.nameRequired': 'ヘッダー名は必須です。', + 'customHeadersValidation.nameTooLong': 'ヘッダー名が長すぎます。', + 'customHeadersValidation.valueTooLong': 'ヘッダー値が長すぎます。', + 'customHeadersValidation.crlf': 'ヘッダー名と値に改行を含めることはできません。', + 'customHeadersValidation.blocked': 'このヘッダー名は使用できません。', + 'customHeadersValidation.duplicate': 'ヘッダー名が重複しています。', addServer: 'サーバーを追加', addServerTitle: '新しいサーバーを追加', editServer: '編集', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 81c99669..33cb3484 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.', serverUsername: 'Brukernavn', serverPassword: 'Passord', + customHeadersTitle: 'Egendefinerte HTTP-headere', + customHeadersHelp: 'For reverse-proxy-gates (Pangolin, Cloudflare Access). Sendes bare til serveren din — ikke til tredjepartstjenester.', + customHeadersNamePlaceholder: 'Headernavn', + customHeadersValuePlaceholder: 'Headerverdi', + customHeadersAddRow: 'Legg til header', + customHeadersRemoveRow: 'Fjern header', + customHeadersApplyTo: 'Bruk headere på', + customHeadersApplyTo_public: 'Offentlig adresse', + customHeadersApplyTo_local: 'Lokal adresse', + customHeadersApplyTo_both: 'Begge adresser', + customHeadersNotInShare: 'Egendefinerte headere er ikke inkludert i invitasjoner — konfigurer dem på nytt på hver enhet.', + 'customHeadersValidation.tooMany': 'For mange egendefinerte headere (maks. 16).', + 'customHeadersValidation.nameRequired': 'Headernavn er påkrevd.', + 'customHeadersValidation.nameTooLong': 'Headernavnet er for langt.', + 'customHeadersValidation.valueTooLong': 'Headerverdien er for lang.', + 'customHeadersValidation.crlf': 'Headernavn og -verdier kan ikke inneholde linjeskift.', + 'customHeadersValidation.blocked': 'Dette headernavnet er ikke tillatt.', + 'customHeadersValidation.duplicate': 'Duplikat headernavn.', addServer: 'Legg til tjener', addServerTitle: 'Legg til ny tjener', editServer: 'Rediger', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 4150892e..0d817079 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.', serverUsername: 'Gebruikersnaam', serverPassword: 'Wachtwoord', + customHeadersTitle: 'Aangepaste HTTP-headers', + customHeadersHelp: 'Voor reverse-proxy-gates (Pangolin, Cloudflare Access). Alleen naar je server gestuurd — niet naar diensten van derden.', + customHeadersNamePlaceholder: 'Headernaam', + customHeadersValuePlaceholder: 'Headervalue', + customHeadersAddRow: 'Header toevoegen', + customHeadersRemoveRow: 'Header verwijderen', + customHeadersApplyTo: 'Headers toepassen op', + customHeadersApplyTo_public: 'Openbaar adres', + customHeadersApplyTo_local: 'Lokaal adres', + customHeadersApplyTo_both: 'Beide adressen', + customHeadersNotInShare: 'Aangepaste headers worden niet opgenomen in uitnodigingen — configureer ze opnieuw op elk apparaat.', + 'customHeadersValidation.tooMany': 'Te veel aangepaste headers (max. 16).', + 'customHeadersValidation.nameRequired': 'Headernaam is verplicht.', + 'customHeadersValidation.nameTooLong': 'Headernaam is te lang.', + 'customHeadersValidation.valueTooLong': 'Headervalue is te lang.', + 'customHeadersValidation.crlf': 'Headernamen en -waarden mogen geen regeleinden bevatten.', + 'customHeadersValidation.blocked': 'Deze headernaam is niet toegestaan.', + 'customHeadersValidation.duplicate': 'Dubbele headernaam.', addServer: 'Server toevoegen', addServerTitle: 'Nieuwe server toevoegen', editServer: 'Bewerken', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 6071f2a0..f47a277f 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.', serverUsername: 'Nume utilizator', serverPassword: 'Parolă', + customHeadersTitle: 'Antete HTTP personalizate', + customHeadersHelp: 'Pentru porți reverse proxy (Pangolin, Cloudflare Access). Trimise doar către serverul tău — nu către servicii terțe.', + customHeadersNamePlaceholder: 'Nume antet', + customHeadersValuePlaceholder: 'Valoare antet', + customHeadersAddRow: 'Adaugă antet', + customHeadersRemoveRow: 'Elimină antet', + customHeadersApplyTo: 'Aplică antetele la', + customHeadersApplyTo_public: 'Adresă publică', + customHeadersApplyTo_local: 'Adresă locală', + customHeadersApplyTo_both: 'Ambele adrese', + customHeadersNotInShare: 'Antetele personalizate nu sunt incluse în invitații — configurează-le din nou pe fiecare dispozitiv.', + 'customHeadersValidation.tooMany': 'Prea multe antete personalizate (max. 16).', + 'customHeadersValidation.nameRequired': 'Numele antetului este obligatoriu.', + 'customHeadersValidation.nameTooLong': 'Numele antetului este prea lung.', + 'customHeadersValidation.valueTooLong': 'Valoarea antetului este prea lungă.', + 'customHeadersValidation.crlf': 'Numele și valorile nu pot conține linii noi.', + 'customHeadersValidation.blocked': 'Acest nume de antet nu este permis.', + 'customHeadersValidation.duplicate': 'Nume de antet duplicat.', addServer: 'Adaugă Server', addServerTitle: 'Adaugă Server nou', editServer: 'Editează', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 1b56350a..0ee50d1f 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.', serverUsername: 'Логин', serverPassword: 'Пароль', + customHeadersTitle: 'Пользовательские HTTP-заголовки', + customHeadersHelp: 'Для reverse-proxy (Pangolin, Cloudflare Access). Отправляются только на ваш сервер — не сторонним сервисам.', + customHeadersNamePlaceholder: 'Имя заголовка', + customHeadersValuePlaceholder: 'Значение заголовка', + customHeadersAddRow: 'Добавить заголовок', + customHeadersRemoveRow: 'Удалить заголовок', + customHeadersApplyTo: 'Применять заголовки к', + customHeadersApplyTo_public: 'Публичному адресу', + customHeadersApplyTo_local: 'Локальному адресу', + customHeadersApplyTo_both: 'Оба адреса', + customHeadersNotInShare: 'Заголовки не входят в приглашения — настройте их на каждом устройстве отдельно.', + 'customHeadersValidation.tooMany': 'Слишком много заголовков (макс. 16).', + 'customHeadersValidation.nameRequired': 'Укажите имя заголовка.', + 'customHeadersValidation.nameTooLong': 'Имя заголовка слишком длинное.', + 'customHeadersValidation.valueTooLong': 'Значение заголовка слишком длинное.', + 'customHeadersValidation.crlf': 'Имя и значение не могут содержать переводы строк.', + 'customHeadersValidation.blocked': 'Это имя заголовка запрещено.', + 'customHeadersValidation.duplicate': 'Дублирующееся имя заголовка.', addServer: 'Добавить сервер', addServerTitle: 'Новый сервер', editServer: 'Изменить', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index efaa974b..b6648783 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -41,6 +41,24 @@ export const settings = { shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。', serverUsername: '用户名', serverPassword: '密码', + customHeadersTitle: '自定义 HTTP 标头', + customHeadersHelp: '用于反向代理网关(Pangolin、Cloudflare Access)。仅发送到你的服务器 — 不会发送给第三方服务。', + customHeadersNamePlaceholder: '标头名称', + customHeadersValuePlaceholder: '标头值', + customHeadersAddRow: '添加标头', + customHeadersRemoveRow: '删除标头', + customHeadersApplyTo: '将标头应用于', + customHeadersApplyTo_public: '公共地址', + customHeadersApplyTo_local: '本地地址', + customHeadersApplyTo_both: '两个地址', + customHeadersNotInShare: '自定义标头不会包含在分享邀请中 — 请在每台设备上重新配置。', + 'customHeadersValidation.tooMany': '自定义标头过多(最多 16 个)。', + 'customHeadersValidation.nameRequired': '标头名称不能为空。', + 'customHeadersValidation.nameTooLong': '标头名称过长。', + 'customHeadersValidation.valueTooLong': '标头值过长。', + 'customHeadersValidation.crlf': '标头名称和值不能包含换行符。', + 'customHeadersValidation.blocked': '不允许使用此标头名称。', + 'customHeadersValidation.duplicate': '标头名称重复。', addServer: '添加服务器', addServerTitle: '添加新服务器', editServer: '编辑', diff --git a/src/store/authStore.ts b/src/store/authStore.ts index daf80805..1c7a3e24 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -20,6 +20,7 @@ import { DEFAULT_LIBRARY_GRID_MAX_COLUMNS, } from './authStoreDefaults'; import { computeAuthStoreRehydration } from './authStoreRehydrate'; +import { syncAllServerHttpContexts } from '../utils/server/syncServerHttpContext'; import type { AuthState } from './authStoreTypes'; import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint'; import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl'; @@ -172,6 +173,7 @@ export const useAuthStore = create()( onRehydrateStorage: () => (state, error) => { if (error || !state) return; useAuthStore.setState(computeAuthStoreRehydration(state)); + void syncAllServerHttpContexts(useAuthStore.getState().servers); }, } ) diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 54245c5c..115976f0 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -6,6 +6,23 @@ import type { } from '../utils/server/subsonicServerIdentity'; import type { PersistedAccount } from '../music-network'; +export type CustomHeaderEntry = { + name: string; + value: string; +}; + +export type CustomHeadersApplyTo = 'local' | 'public' | 'both'; + +export type CustomHeadersFieldError = { + index: number; + field: 'name' | 'value'; + messageKey: string; +}; + +export type CustomHeadersValidationResult = + | { ok: true } + | { ok: false; fieldErrors: CustomHeadersFieldError[]; formMessage?: string }; + export interface ServerProfile { id: string; name: string; @@ -30,6 +47,10 @@ export interface ServerProfile { shareUsesLocalUrl?: boolean; username: string; password: string; + /** Optional HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access). */ + customHeaders?: CustomHeaderEntry[]; + /** Which profile endpoint(s) receive `customHeaders`. Default when absent: `'public'`. */ + customHeadersApplyTo?: CustomHeadersApplyTo; } export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index c4f3bd25..c057e300 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -23,6 +23,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Listing every export the store uses keeps the override stable. vi.mock('@/api/subsonic', () => ({ pingWithCredentials: vi.fn(async () => ({ ok: true })), + pingWithCredentialsForProfile: vi.fn(async () => ({ ok: true })), + scheduleInstantMixProbeForServer: vi.fn(), })); vi.mock('@/api/subsonicPlayQueue', () => ({ savePlayQueue: vi.fn(async () => undefined), diff --git a/src/utils/library/librarySession.ts b/src/utils/library/librarySession.ts index e0560f6e..eb55ff75 100644 --- a/src/utils/library/librarySession.ts +++ b/src/utils/library/librarySession.ts @@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { ensureConnectUrlResolved } from '../server/serverEndpoint'; import { serverIndexKeyForProfile } from '../server/serverIndexKey'; +import { syncServerHttpContextForProfile } from '../server/syncServerHttpContext'; import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog'; export type BindServerResult = 'bound' | 'offline' | 'error'; @@ -49,6 +50,7 @@ export async function bindIndexedServer(server: ServerProfile): Promise { expect(out).toContain('password=REDACTED'); expect(out).not.toContain('sekrit'); }); + + it('redacts reverse-proxy gate header values', () => { + const line = 'req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key'; + const out = sanitizeLogLine(line); + expect(out).toContain('CF-Access-Client-Secret: REDACTED'); + expect(out).not.toContain('gate-secret'); + expect(out).not.toContain('tok123'); + expect(out).toContain('x-pangolin-auth: REDACTED'); + expect(out).not.toContain('pangolin-key'); + expect(out).not.toMatch(/Authorization:\s*Bearer\s+\S/i); + }); }); diff --git a/src/utils/perf/sanitizeLogLine.ts b/src/utils/perf/sanitizeLogLine.ts index ff2837ae..d0d309ab 100644 --- a/src/utils/perf/sanitizeLogLine.ts +++ b/src/utils/perf/sanitizeLogLine.ts @@ -11,8 +11,12 @@ const SENSITIVE_QUERY_KEYS = new Set([ const SENSITIVE_KV_KEYS = [ 'password', 'passwd', 'token', 'secret', 'api_key', 'apikey', 'access_token', 'refresh_token', 'authorization', 'auth', + 'cookie', 'x-api-key', 'cf-access-client-secret', 'cf-access-client-id', 'x-auth-token', ]; +/** Gate / reverse-proxy header names — redact any `x-pangolin-*` prefix. */ +const PANGOLIN_HEADER_RE = /(\bx-pangolin-[a-z0-9-]+\s*[:=]\s*)(\S+)/gi; + function isIpv4LanLiteral(ip: string): boolean { const parts = ip.split('.'); if (parts.length !== 4) return false; @@ -175,6 +179,10 @@ function redactBearerTokens(line: string): string { return s; } +function redactPangolinHeaders(line: string): string { + return line.replace(PANGOLIN_HEADER_RE, '$1REDACTED'); +} + function redactSensitiveKeyValues(line: string): string { let out = line; for (const key of SENSITIVE_KV_KEYS) { @@ -231,5 +239,5 @@ function redactUrlsInText(line: string): string { } export function sanitizeLogLine(line: string): string { - return redactUrlsInText(redactSensitiveKeyValues(redactBearerTokens(line))); + return redactUrlsInText(redactSensitiveKeyValues(redactPangolinHeaders(redactBearerTokens(line)))); } diff --git a/src/utils/server/serverEndpoint.test.ts b/src/utils/server/serverEndpoint.test.ts index d5b0d491..146f9027 100644 --- a/src/utils/server/serverEndpoint.test.ts +++ b/src/utils/server/serverEndpoint.test.ts @@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../api/subsonic', () => ({ pingWithCredentials: vi.fn(), + pingWithCredentialsForProfile: vi.fn(), })); -import { pingWithCredentials } from '../../api/subsonic'; +import { pingWithCredentialsForProfile } from '../../api/subsonic'; import { allNormalizedAddresses, ensureConnectUrlResolved, @@ -47,7 +48,7 @@ function pingFail() { const CONNECT_PROBE_ATTEMPTS = 3; function mockDualAddressLanFailPublicOk() { - vi.mocked(pingWithCredentials).mockImplementation(async (url: string) => { + vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url: string) => { if (url === 'http://192.168.0.10') return pingFail(); return pingOk(); }); @@ -212,7 +213,7 @@ describe('serverAddressEndpoints', () => { describe('pickReachableBaseUrl', () => { beforeEach(() => { invalidateReachableEndpointCache(); - vi.mocked(pingWithCredentials).mockReset(); + vi.mocked(pingWithCredentialsForProfile).mockReset(); }); afterEach(() => { @@ -220,7 +221,7 @@ describe('pickReachableBaseUrl', () => { }); it('returns the single endpoint when it pings ok and caches it', async () => { - vi.mocked(pingWithCredentials).mockResolvedValue(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk()); const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); expect(result.ok).toBe(true); if (result.ok) { @@ -230,11 +231,11 @@ describe('pickReachableBaseUrl', () => { expect(result.ping.type).toBe('navidrome'); } expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); - expect(pingWithCredentials).toHaveBeenCalledTimes(1); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1); }); it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => { - vi.mocked(pingWithCredentials).mockResolvedValue(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk()); const result = await pickReachableBaseUrl( makeProfile({ url: 'https://music.example.com', @@ -243,8 +244,8 @@ describe('pickReachableBaseUrl', () => { ); expect(result.ok).toBe(true); if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10'); - expect(pingWithCredentials).toHaveBeenCalledTimes(1); - expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10'); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1); + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10'); }); it('falls through to the public endpoint when LAN ping fails', async () => { @@ -260,13 +261,13 @@ describe('pickReachableBaseUrl', () => { const result = await promise; expect(result.ok).toBe(true); if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); - expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1); expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); }); it('retries a flaky endpoint before declaring it unreachable', async () => { vi.useFakeTimers(); - vi.mocked(pingWithCredentials) + vi.mocked(pingWithCredentialsForProfile) .mockResolvedValueOnce(pingFail()) .mockResolvedValueOnce(pingOk()); const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); @@ -274,32 +275,32 @@ describe('pickReachableBaseUrl', () => { const result = await promise; expect(result.ok).toBe(true); if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); - expect(pingWithCredentials).toHaveBeenCalledTimes(2); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); it('returns unreachable and clears cache when every endpoint fails', async () => { - vi.mocked(pingWithCredentials).mockResolvedValue(pingFail()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail()); // Seed a stale cache entry first. - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); - vi.mocked(pingWithCredentials).mockReset(); - vi.mocked(pingWithCredentials).mockResolvedValue(pingFail()); + vi.mocked(pingWithCredentialsForProfile).mockReset(); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail()); vi.useFakeTimers(); const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); await vi.runAllTimersAsync(); const result = await unreachablePromise; expect(result).toEqual({ ok: false, reason: 'unreachable' }); expect(getCachedConnectBaseUrl('profile-1')).toBeNull(); - expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS); }); it('returns unreachable when the profile has no usable url', async () => { const result = await pickReachableBaseUrl(makeProfile({ url: '' })); expect(result).toEqual({ ok: false, reason: 'unreachable' }); - expect(pingWithCredentials).not.toHaveBeenCalled(); + expect(pingWithCredentialsForProfile).not.toHaveBeenCalled(); }); it('tries the cached endpoint first on subsequent calls (sticky)', async () => { @@ -308,18 +309,18 @@ describe('pickReachableBaseUrl', () => { alternateUrl: 'http://192.168.0.10', }); // First call: LAN responds ok, becomes cached. - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(profile); expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); // Second call: cached URL is tried first; sole ping happens against it. - vi.mocked(pingWithCredentials).mockClear(); - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockClear(); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); const result = await pickReachableBaseUrl(profile); expect(result.ok).toBe(true); if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10'); - expect(pingWithCredentials).toHaveBeenCalledTimes(1); - expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10'); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1); + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10'); }); it('dedupes concurrent calls for the same profile (single shared probe)', async () => { @@ -328,7 +329,7 @@ describe('pickReachableBaseUrl', () => { // cache write, with last-write-wins potentially clobbering the correct // LAN sticky a millisecond after it was set. let resolvePing: ((v: ReturnType) => void) | null = null; - vi.mocked(pingWithCredentials).mockReturnValueOnce( + vi.mocked(pingWithCredentialsForProfile).mockReturnValueOnce( new Promise(r => { resolvePing = r; }), @@ -338,7 +339,7 @@ describe('pickReachableBaseUrl', () => { const p2 = pickReachableBaseUrl(profile); // Both calls saw a pending probe — only one ping should have been fired. - expect(pingWithCredentials).toHaveBeenCalledTimes(1); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1); resolvePing!(pingOk()); const [r1, r2] = await Promise.all([p1, p2]); @@ -354,13 +355,13 @@ describe('pickReachableBaseUrl', () => { it('starts a fresh probe after the in-flight one settles', async () => { // Once the previous probe resolves, the in-flight slot is freed and // the next call hits the network again (subject to the sticky cache). - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); - vi.mocked(pingWithCredentials).mockClear(); - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockClear(); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); - expect(pingWithCredentials).toHaveBeenCalledTimes(1); + expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1); }); it('falls back to the natural order if the cached endpoint stops answering', async () => { @@ -368,12 +369,12 @@ describe('pickReachableBaseUrl', () => { url: 'https://music.example.com', alternateUrl: 'http://192.168.0.10', }); - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(profile); expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); // LAN now fails; public answers. - vi.mocked(pingWithCredentials).mockClear(); + vi.mocked(pingWithCredentialsForProfile).mockClear(); vi.useFakeTimers(); mockDualAddressLanFailPublicOk(); const fallbackPromise = pickReachableBaseUrl(profile); @@ -388,13 +389,13 @@ describe('pickReachableBaseUrl', () => { describe('invalidateReachableEndpointCache', () => { beforeEach(() => { invalidateReachableEndpointCache(); - vi.mocked(pingWithCredentials).mockReset(); + vi.mocked(pingWithCredentialsForProfile).mockReset(); }); it('clears a specific profile', async () => { - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await ensureConnectUrlResolved(makeProfile({ id: 'a' })); - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await ensureConnectUrlResolved(makeProfile({ id: 'b' })); expect(getCachedConnectBaseUrl('a')).not.toBeNull(); expect(getCachedConnectBaseUrl('b')).not.toBeNull(); @@ -405,7 +406,7 @@ describe('invalidateReachableEndpointCache', () => { }); it('clears everything when called with no argument', async () => { - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await ensureConnectUrlResolved(makeProfile({ id: 'a' })); invalidateReachableEndpointCache(); expect(getCachedConnectBaseUrl('a')).toBeNull(); @@ -415,7 +416,7 @@ describe('invalidateReachableEndpointCache', () => { describe('subscribeConnectCache — connect-URL flip notifications', () => { beforeEach(() => { invalidateReachableEndpointCache(); - vi.mocked(pingWithCredentials).mockReset(); + vi.mocked(pingWithCredentialsForProfile).mockReset(); }); it('notifies when a probe resolves a new endpoint and on a later flip', async () => { @@ -427,7 +428,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => { }); // First probe: LAN answers → cache set → one notification. - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(profile); expect(listener).toHaveBeenCalledTimes(1); @@ -444,13 +445,13 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => { it('does not notify when the sticky endpoint is unchanged', async () => { const profile = makeProfile({ url: 'http://192.168.0.10' }); - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(profile); const listener = vi.fn(); const unsubscribe = subscribeConnectCache(listener); // Re-probe, same endpoint answers → cache value identical → no notification. - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(profile); expect(listener).not.toHaveBeenCalled(); @@ -458,7 +459,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => { }); it('notifies on explicit cache invalidation when an entry existed', async () => { - vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk()); await pickReachableBaseUrl(makeProfile({ id: 'a' })); const listener = vi.fn(); diff --git a/src/utils/server/serverEndpoint.ts b/src/utils/server/serverEndpoint.ts index d039bf8a..5ac5012f 100644 --- a/src/utils/server/serverEndpoint.ts +++ b/src/utils/server/serverEndpoint.ts @@ -1,4 +1,4 @@ -import { pingWithCredentials } from '../../api/subsonic'; +import { pingWithCredentialsForProfile } from '../../api/subsonic'; import type { PingWithCredentialsResult } from '../../api/subsonicTypes'; import type { ServerProfile } from '../../store/authStoreTypes'; import { serverProfileBaseUrl } from './serverBaseUrl'; @@ -261,15 +261,14 @@ function sleepMs(ms: number): Promise { * proxy TLS flakes) before the connection indicator marks the server down. */ async function pingWithConnectRetries( - baseUrl: string, - username: string, - password: string, + profile: ServerProfile, + endpointUrl: string, ): Promise { - let ping = await pingWithCredentials(baseUrl, username, password); + let ping = await pingWithCredentialsForProfile(profile, endpointUrl); if (ping.ok) return ping; for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) { await sleepMs(CONNECT_PING_RETRY_DELAY_MS); - ping = await pingWithCredentials(baseUrl, username, password); + ping = await pingWithCredentialsForProfile(profile, endpointUrl); if (ping.ok) return ping; } return ping; @@ -309,7 +308,7 @@ export async function pickReachableBaseUrl( : ordered; for (const endpoint of endpoints) { - const ping = await pingWithConnectRetries(endpoint.url, profile.username, profile.password); + const ping = await pingWithConnectRetries(profile, endpoint.url); if (ping.ok) { const prev = connectCache.get(profile.id); connectCache.set(profile.id, endpoint.url); diff --git a/src/utils/server/serverFingerprint.ts b/src/utils/server/serverFingerprint.ts index 40866886..b4b3c5d5 100644 --- a/src/utils/server/serverFingerprint.ts +++ b/src/utils/server/serverFingerprint.ts @@ -15,9 +15,16 @@ */ import md5 from 'md5'; -import { apiWithCredentials, restBaseFromUrl, secureRandomSalt, SUBSONIC_CLIENT } from '../../api/subsonicClient'; +import { + apiWithCredentials, + restBaseFromUrl, + secureRandomSalt, + SUBSONIC_CLIENT, + type ServerHttpHeaderProfile, +} from '../../api/subsonicClient'; import type { ServerProfile } from '../../store/authStoreTypes'; import { allNormalizedAddresses } from './serverEndpoint'; +import { headersForServerRequest } from './serverHttpHeaders'; export type ServerFingerprint = { ping: { @@ -55,6 +62,7 @@ async function fetchPingFingerprint( baseUrl: string, username: string, password: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise { // Mirrors pingWithCredentials but also extracts envelope `version`. // Using fetch (the bundled axios pulls in extra noise here; one call is fine). @@ -69,9 +77,18 @@ async function fetchPingFingerprint( f: 'json', }); const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`; + const profileForHeaders: ServerHttpHeaderProfile = headerProfile ?? { + url: baseUrl, + alternateUrl: undefined, + customHeaders: undefined, + customHeadersApplyTo: undefined, + }; let body: Record | null = null; try { - const resp = await fetch(url, { method: 'GET' }); + const resp = await fetch(url, { + method: 'GET', + headers: headersForServerRequest(profileForHeaders, baseUrl), + }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const json = (await resp.json()) as Record; body = (json?.['subsonic-response'] as Record) ?? null; @@ -171,12 +188,13 @@ export async function fetchServerFingerprint( baseUrl: string, username: string, password: string, + headerProfile?: ServerHttpHeaderProfile, ): Promise { // Ping is required — but we still build a (mostly-null) fingerprint when // it fails so callers can tell the difference between "server unreachable" // (verify reports `unreachable`) and "server reachable but disagrees" // (verify reports `mismatch` / `insufficient`). - const ping = await fetchPingFingerprint(baseUrl, username, password); + const ping = await fetchPingFingerprint(baseUrl, username, password, headerProfile); // The optional calls only make sense once ping succeeded — without that, // any subsequent call against the same URL is just wasted bandwidth. @@ -196,10 +214,42 @@ export async function fetchServerFingerprint( } const settled = await Promise.allSettled([ - apiWithCredentials>(baseUrl, username, password, 'getMusicFolders.view'), - apiWithCredentials>(baseUrl, username, password, 'getUser.view', { username }), - apiWithCredentials>(baseUrl, username, password, 'getLicense.view'), - apiWithCredentials>(baseUrl, username, password, 'getIndexes.view'), + apiWithCredentials>( + baseUrl, + username, + password, + 'getMusicFolders.view', + {}, + 15000, + headerProfile, + ), + apiWithCredentials>( + baseUrl, + username, + password, + 'getUser.view', + { username }, + 15000, + headerProfile, + ), + apiWithCredentials>( + baseUrl, + username, + password, + 'getLicense.view', + {}, + 15000, + headerProfile, + ), + apiWithCredentials>( + baseUrl, + username, + password, + 'getIndexes.view', + {}, + 15000, + headerProfile, + ), ]); const [foldersResult, userResult, licenseResult, indexesResult] = settled; @@ -299,7 +349,10 @@ function musicFoldersEqual( * `ok: true` — nothing to verify. */ export async function verifySameServerEndpoints( - profile: Pick, + profile: Pick< + ServerProfile, + 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo' + >, username: string, password: string, ): Promise { @@ -307,7 +360,7 @@ export async function verifySameServerEndpoints( if (endpoints.length <= 1) return { ok: true }; const fingerprints = await Promise.all( - endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password)), + endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password, profile)), ); // If any ping failed → unreachable (with the offending host for the UI). diff --git a/src/utils/server/serverHttpHeaders.test.ts b/src/utils/server/serverHttpHeaders.test.ts new file mode 100644 index 00000000..cb1207f2 --- /dev/null +++ b/src/utils/server/serverHttpHeaders.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + headersForServerRequest, + requestBaseUrlFromHttpUrl, + validateCustomHeaders, +} from './serverHttpHeaders'; + +describe('requestBaseUrlFromHttpUrl', () => { + it('strips /rest/ path and query from stream URLs', () => { + expect( + requestBaseUrlFromHttpUrl( + 'https://music.example.com/rest/stream.view?id=1&u=x&t=y&s=z', + ), + ).toBe('https://music.example.com'); + }); + + it('strips /api/ Navidrome paths', () => { + expect(requestBaseUrlFromHttpUrl('https://nd.local/api/album')).toBe('https://nd.local'); + }); +}); + +describe('headersForServerRequest', () => { + const profile = { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.1.10:4533', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }], + customHeadersApplyTo: 'public' as const, + }; + + it('applies headers on public endpoint only when applyTo is public', () => { + expect(headersForServerRequest(profile, 'https://music.example.com')).toEqual({ + 'CF-Access-Client-Secret': 'secret', + }); + expect(headersForServerRequest(profile, 'http://192.168.1.10:4533')).toEqual({}); + }); + + it('returns empty for foreign base URL', () => { + expect(headersForServerRequest(profile, 'https://other.example.com')).toEqual({}); + }); +}); + +describe('validateCustomHeaders', () => { + it('rejects blocked header names', () => { + const result = validateCustomHeaders([{ name: 'Host', value: 'x' }]); + expect(result.ok).toBe(false); + }); + + it('accepts valid rows', () => { + expect( + validateCustomHeaders([{ name: 'X-Custom', value: 'ok' }]), + ).toEqual({ ok: true }); + }); +}); diff --git a/src/utils/server/serverHttpHeaders.ts b/src/utils/server/serverHttpHeaders.ts new file mode 100644 index 00000000..fcdc6054 --- /dev/null +++ b/src/utils/server/serverHttpHeaders.ts @@ -0,0 +1,152 @@ +import type { + CustomHeaderEntry, + CustomHeadersApplyTo, + CustomHeadersFieldError, + CustomHeadersValidationResult, + ServerProfile, +} from '../../store/authStoreTypes'; +import { serverIndexKeyForProfile } from './serverIndexKey'; +import { normalizeServerBaseUrl, serverAddressEndpoints, type ServerEndpointKind } from './serverEndpoint'; + +export const DEFAULT_CUSTOM_HEADERS_APPLY_TO: CustomHeadersApplyTo = 'public'; + +export const CUSTOM_HEADER_NAME_BLOCKLIST = new Set([ + 'host', + 'content-length', + 'transfer-encoding', + 'connection', + 'cookie', +]); + +const MAX_CUSTOM_HEADERS = 16; +const MAX_HEADER_NAME_LEN = 256; +const MAX_HEADER_VALUE_LEN = 8192; + +export function normalizeHeaderName(name: string): string { + return name.trim(); +} + +export function requestBaseUrlFromHttpUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (!trimmed) return ''; + try { + const parsed = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`); + parsed.search = ''; + parsed.hash = ''; + let path = parsed.pathname; + const restIdx = path.indexOf('/rest/'); + if (restIdx >= 0 || path.endsWith('/rest')) { + path = path.slice(0, restIdx >= 0 ? restIdx : path.length - '/rest'.length); + } else { + for (const seg of ['/api/', '/auth/'] as const) { + const idx = path.indexOf(seg); + if (idx >= 0) { + path = path.slice(0, idx); + break; + } + } + } + if (path.endsWith('/') && path.length > 1) path = path.replace(/\/+$/, ''); + parsed.pathname = path || '/'; + const origin = `${parsed.protocol}//${parsed.host}${path === '/' ? '' : path}`; + return normalizeServerBaseUrl(origin); + } catch { + return normalizeServerBaseUrl(trimmed); + } +} + +export function validateCustomHeaders( + headers: CustomHeaderEntry[] | undefined, +): CustomHeadersValidationResult { + if (!headers?.length) return { ok: true }; + if (headers.length > MAX_CUSTOM_HEADERS) { + return { + ok: false, + fieldErrors: [{ index: 0, field: 'name', messageKey: 'settings.customHeadersValidation.tooMany' }], + }; + } + const fieldErrors: CustomHeadersFieldError[] = []; + const seen = new Set(); + headers.forEach((row, index) => { + const name = normalizeHeaderName(row.name); + const value = row.value; + if (!name) { + fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameRequired' }); + return; + } + if (name.length > MAX_HEADER_NAME_LEN) { + fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameTooLong' }); + } + if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) { + fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.crlf' }); + } + if (CUSTOM_HEADER_NAME_BLOCKLIST.has(name.toLowerCase())) { + fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.blocked' }); + } + const key = name.toLowerCase(); + if (seen.has(key)) { + fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.duplicate' }); + } + seen.add(key); + if (value.length > MAX_HEADER_VALUE_LEN) { + fieldErrors.push({ index, field: 'value', messageKey: 'settings.customHeadersValidation.valueTooLong' }); + } + }); + if (fieldErrors.length) return { ok: false, fieldErrors }; + return { ok: true }; +} + +function headersRecord(entries: CustomHeaderEntry[]): Record { + const out: Record = {}; + for (const row of entries) { + const name = normalizeHeaderName(row.name); + if (!name) continue; + out[name] = row.value; + } + return out; +} + +export function headersForServerRequest( + profile: Pick< + ServerProfile, + 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo' + >, + requestBaseUrl: string, +): Record { + if (!profile.customHeaders?.length) return {}; + const normalized = normalizeServerBaseUrl(requestBaseUrl); + const endpoint = serverAddressEndpoints(profile).find(e => e.url === normalized); + if (!endpoint) return {}; + const apply = profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO; + if (apply === 'both' || apply === endpoint.kind) { + return headersRecord(profile.customHeaders); + } + return {}; +} + +export type ServerHttpEndpointWire = { + url: string; + kind: ServerEndpointKind; +}; + +/** Payload for Rust registry sync — endpoint kinds from TS dual-address layer. */ +export function serverHttpContextWireForProfile( + server: Pick< + ServerProfile, + 'id' | 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo' + >, +): { + serverId: string; + appServerId: string; + endpoints: ServerHttpEndpointWire[]; + customHeaders: CustomHeaderEntry[]; + customHeadersApplyTo: CustomHeadersApplyTo; +} { + return { + serverId: serverIndexKeyForProfile(server), + appServerId: server.id, + endpoints: serverAddressEndpoints(server).map(e => ({ url: e.url, kind: e.kind })), + customHeaders: server.customHeaders ?? [], + customHeadersApplyTo: server.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO, + }; +} diff --git a/src/utils/server/switchActiveServer.ts b/src/utils/server/switchActiveServer.ts index 1a01a689..712fbaf9 100644 --- a/src/utils/server/switchActiveServer.ts +++ b/src/utils/server/switchActiveServer.ts @@ -10,6 +10,7 @@ import { flushPlayQueueForServer } from '../../store/queueSync'; import { markQueueHandoffPending } from '../../store/queueSyncUiState'; import { endOrbitSession, leaveOrbitSession } from '../orbit'; import { ensureConnectUrlResolved } from './serverEndpoint'; +import { syncServerHttpContextForProfile } from './syncServerHttpContext'; export async function switchActiveServer(server: ServerProfile): Promise { coverTrafficBeginServerSwitch(); @@ -56,6 +57,7 @@ export async function switchActiveServer(server: ServerProfile): Promise vi.fn(async () => undefined)); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })); + +import { syncServerHttpContextForProfile } from './syncServerHttpContext'; + +const server = { + id: 'app-uuid-1', + name: 'Gated', + url: 'https://music.example.com', + username: 'u', + password: 'p', + customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }], + customHeadersApplyTo: 'public' as const, +}; + +describe('syncServerHttpContextForProfile', () => { + beforeEach(() => { + invokeMock.mockClear(); + }); + + it('passes the wire payload under the wire key for Tauri struct args', async () => { + await syncServerHttpContextForProfile(server); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('server_http_context_sync', { + wire: expect.objectContaining({ + serverId: expect.any(String), + appServerId: 'app-uuid-1', + customHeaders: server.customHeaders, + customHeadersApplyTo: 'public', + }), + }); + }); +}); diff --git a/src/utils/server/syncServerHttpContext.ts b/src/utils/server/syncServerHttpContext.ts new file mode 100644 index 00000000..0d0cc7f4 --- /dev/null +++ b/src/utils/server/syncServerHttpContext.ts @@ -0,0 +1,21 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { serverHttpContextWireForProfile } from './serverHttpHeaders'; +import { serverIndexKeyForProfile } from './serverIndexKey'; + +export async function syncServerHttpContextForProfile(server: ServerProfile): Promise { + const wire = serverHttpContextWireForProfile(server); + await invoke('server_http_context_sync', { wire }); +} + +export async function syncAllServerHttpContexts(servers: ServerProfile[]): Promise { + if (servers.length === 0) return; + await invoke('server_http_context_sync_all', { + entries: servers.map(s => serverHttpContextWireForProfile(s)), + }); +} + +export async function clearServerHttpContext(server: Pick): Promise { + const indexKey = serverIndexKeyForProfile(server); + await invoke('server_http_context_clear', { serverId: indexKey, appServerId: server.id }); +} diff --git a/src/utils/share/enqueueShareSearchPayload.test.ts b/src/utils/share/enqueueShareSearchPayload.test.ts index 57ce313a..5e37ab1f 100644 --- a/src/utils/share/enqueueShareSearchPayload.test.ts +++ b/src/utils/share/enqueueShareSearchPayload.test.ts @@ -133,6 +133,7 @@ describe('share search payload resolution', () => { sharedServer.username, sharedServer.password, 'song-1', + sharedServer, ); expect(mocks.getSong).not.toHaveBeenCalled(); expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); @@ -147,12 +148,14 @@ describe('share search payload resolution', () => { sharedServer.username, sharedServer.password, 'album-1', + sharedServer, ); expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith( sharedServer.url, sharedServer.username, sharedServer.password, 'artist-1', + sharedServer, ); expect(mocks.getAlbum).not.toHaveBeenCalled(); expect(mocks.getArtist).not.toHaveBeenCalled(); @@ -172,6 +175,7 @@ describe('share search payload resolution', () => { sharedServer.username, sharedServer.password, 'composer-1', + sharedServer, ); expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); }); diff --git a/src/utils/share/enqueueShareSearchPayload.ts b/src/utils/share/enqueueShareSearchPayload.ts index 61361c52..075795af 100644 --- a/src/utils/share/enqueueShareSearchPayload.ts +++ b/src/utils/share/enqueueShareSearchPayload.ts @@ -110,6 +110,7 @@ async function resolveSharedSong( lookup.server.username, lookup.server.password, id, + lookup.server, ); } @@ -187,6 +188,7 @@ export async function resolveShareSearchAlbum( lookup.server.username, lookup.server.password, payload.id, + lookup.server, ); return { type: 'ok', album }; } catch { @@ -214,6 +216,7 @@ export async function resolveShareSearchArtist( lookup.server.username, lookup.server.password, payload.id, + lookup.server, ); return { type: 'ok', artist }; } catch {