feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156)

This commit is contained in:
cucadmuh
2026-06-22 16:25:28 +03:00
committed by GitHub
parent 2c9b2eeb46
commit 15cecb5d7d
83 changed files with 2452 additions and 327 deletions
+18 -3
View File
@@ -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<Option<String>, 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<String, String> {
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<String, String> {
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}"));
+7 -1
View File
@@ -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::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.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
+23 -4
View File
@@ -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<Vec<u8>, String> {
pub async fn fetch_cover_bytes(
client: &Client,
url: &str,
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<Vec<u8>, 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) => {
+12 -2
View File
@@ -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::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.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<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
) -> Result<Vec<u8>, 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(
+10 -2
View File
@@ -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::<Arc<psysonic_core::server_http::ServerHttpRegistry>>();
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(&registry)));
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,
+4 -1
View File
@@ -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!
@@ -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<Vec<Strin
Ok(result)
}
#[tauri::command]
pub(crate) fn server_http_context_sync(
registry: State<'_, Arc<ServerHttpRegistry>>,
wire: ServerHttpContextSyncWire,
) -> Result<(), String> {
registry.sync(wire);
Ok(())
}
#[tauri::command]
pub(crate) fn server_http_context_sync_all(
registry: State<'_, Arc<ServerHttpRegistry>>,
entries: Vec<ServerHttpContextSyncWire>,
) -> Result<(), String> {
registry.sync_all(entries);
Ok(())
}
#[tauri::command]
pub(crate) fn server_http_context_clear(
registry: State<'_, Arc<ServerHttpRegistry>>,
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 {