diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f6f8ae..dc4e3a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Servers — second optional address per profile (LAN + public) + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#880](https://github.com/Psychotoxical/psysonic/pull/880)** + +* **Settings → Servers:** a profile can carry a second address — typically a LAN counterpart of a public URL or vice versa. The app probes LAN-first and uses whichever endpoint actually answers, so the same profile is fast at home and reachable away from home without manual switching. Single-address profiles behave exactly as before. +* **Same-server verify on save** when both addresses are filled — mismatched or unreachable pairs are blocked with a clear message; two-LAN combinations are caught client-side before any network call. +* **Share links** (Orbit invites, library / queue shares, magic invite v2) embed the **public** address by default so off-LAN guests can reach the host; a *Use local address in share links* checkbox flips that for LAN-only groups. Pasted invites match either address. +* **Editing the primary URL** to a new host triggers a confirm-modal-gated data move: library + analysis databases, cover-cache files on disk, and player queue all re-tag to the new identifier in one go. Changing only the second address or `http`↔`https` skips the migration entirely. + + + ## Changed ### Linux — session GDK, WebKitGTK mitigations, and Wayland text diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 1c3074cc..931f26c9 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -705,6 +705,128 @@ pub async fn cover_cache_clear_server( Ok(()) } +/// Rename a server's cover-cache bucket on disk after the user edits the +/// primary URL (and the derived index key changes). Used by the URL-change +/// remigration pipeline (dual-server-address spec §8.3) so cached covers +/// stay reachable under the new key. +/// +/// Sanitization: rejects path-separator characters and `..` components — keys +/// flow from `serverIndexKeyFromUrl(url)` which strips schemes and trailing +/// slashes, but defense in depth at the FS boundary is cheap. +/// +/// Behaviour: +/// - `old_key == new_key` → no-op success. +/// - Old bucket missing → no-op success (nothing to migrate). +/// - New bucket missing → simple `rename` (fastest path). +/// - Both exist → recursive merge, **prefer existing** in destination (the +/// newer bucket wins on collision; the surviving file count goes up, never +/// loses data). +/// +/// Always emits `cover:bucket-renamed` with `{oldKey, newKey}` on success so +/// the frontend in-memory disk-src cache can invalidate stale entries. +#[tauri::command] +pub async fn cover_cache_rename_server_bucket( + app: AppHandle, + old_key: String, + new_key: String, +) -> Result<(), String> { + let st = state(&app)?; + let guard = st.lock().await; + rename_bucket_inner(&guard.root, &old_key, &new_key)?; + drop(guard); + let _ = app.emit( + "cover:bucket-renamed", + serde_json::json!({ "oldKey": old_key, "newKey": new_key }), + ); + Ok(()) +} + +/// FS-only worker for `cover_cache_rename_server_bucket`, lifted out so the +/// command-level behaviour (sanitization + every short-circuit + the merge +/// branch) is testable against a real `tempdir` without spinning up Tauri +/// State. The command wrapper above adds nothing the tests need to cover +/// except the event emit. +fn rename_bucket_inner(root: &std::path::Path, old_key: &str, new_key: &str) -> Result<(), String> { + if old_key.is_empty() || new_key.is_empty() { + return Err("cover_cache_rename_server_bucket: empty key".into()); + } + if !is_safe_index_key(old_key) || !is_safe_index_key(new_key) { + return Err("cover_cache_rename_server_bucket: key contains path separator".into()); + } + if old_key == new_key { + return Ok(()); + } + + let old_dir = root.join(old_key); + let new_dir = root.join(new_key); + + if !old_dir.is_dir() { + return Ok(()); + } + + if !new_dir.exists() { + std::fs::rename(&old_dir, &new_dir).map_err(|e| e.to_string())?; + } else { + merge_cover_bucket(&old_dir, &new_dir)?; + let _ = std::fs::remove_dir_all(&old_dir); + } + Ok(()) +} + +fn is_safe_index_key(key: &str) -> bool { + // Real index keys are `host[:port][/sub/path]` shape — forward slashes + // are legitimate path components (Navidrome behind a reverse-proxy + // subpath, etc.). Everything below is defense-in-depth at the FS + // boundary; real keys come out of `serverIndexKeyFromUrl` and never + // start with a separator or carry the patterns we reject here. + if key.is_empty() { + return false; + } + // Absolute-path leaders — `root.join("/etc/...")` and `root.join("\\foo\\")` + // on Unix / Windows respectively REPLACE the base path with the absolute + // argument. Reject before that ever happens. + if key.starts_with('/') || key.starts_with('\\') { + return false; + } + // Windows drive-letter root (`C:`, `c:`). `Path::join("C:")` is also + // treated as absolute on Windows. + let bytes = key.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() { + return false; + } + // Backslash anywhere — separators are forward-slash only. + if key.contains('\\') { + return false; + } + // No `..` segments anywhere — would escape the cover-cache root. + for segment in key.split('/') { + if segment == ".." { + return false; + } + } + true +} + +fn merge_cover_bucket(old_dir: &std::path::Path, new_dir: &std::path::Path) -> Result<(), String> { + let entries = std::fs::read_dir(old_dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let from = entry.path(); + let to = new_dir.join(entry.file_name()); + if to.exists() { + // Prefer existing in destination — newer bucket wins. + continue; + } + if from.is_dir() { + std::fs::create_dir_all(&to).map_err(|e| e.to_string())?; + merge_cover_bucket(&from, &to)?; + } else { + std::fs::rename(&from, &to).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + #[tauri::command] pub async fn cover_cache_configure( app: AppHandle, @@ -856,6 +978,9 @@ mod tests { use super::decode_image_bytes; use super::disk::{cover_dir, tier_path}; + use super::{is_safe_index_key, merge_cover_bucket, rename_bucket_inner}; + use std::fs; + use std::path::PathBuf; #[test] fn disk_layout_paths() { @@ -874,4 +999,162 @@ mod tests { assert_eq!(decoded.width(), 2); assert_eq!(decoded.height(), 2); } + + #[test] + fn safe_index_key_accepts_real_keys() { + assert!(is_safe_index_key("music.example.com")); + assert!(is_safe_index_key("192.168.0.10:4533")); + assert!(is_safe_index_key("music.example.com/navidrome")); + assert!(is_safe_index_key("[fe80::1]:4533")); + } + + #[test] + fn safe_index_key_rejects_path_traversal_and_backslashes() { + assert!(!is_safe_index_key("../etc")); + assert!(!is_safe_index_key("a/../b")); + assert!(!is_safe_index_key("a\\b")); + assert!(!is_safe_index_key("..\\evil")); + } + + #[test] + fn safe_index_key_rejects_absolute_paths_and_drive_letters() { + // Path::join with an absolute argument replaces the base — must + // never accept keys that lead with a separator. + assert!(!is_safe_index_key("/etc/passwd")); + assert!(!is_safe_index_key("/")); + assert!(!is_safe_index_key("\\windows")); + // Windows drive-letter roots are also treated as absolute. + assert!(!is_safe_index_key("C:")); + assert!(!is_safe_index_key("C:/Windows")); + assert!(!is_safe_index_key("c:foo")); + // Empty key is meaningless and would join to the root itself. + assert!(!is_safe_index_key("")); + } + + /// Build a unique tmpdir for the merge tests so parallel runs don't trip + /// on each other. + fn fresh_tmpdir(label: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!("psysonic-cover-merge-{}-{}", label, nanos)); + fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn merge_bucket_moves_unique_files() { + let root = fresh_tmpdir("unique"); + let old = root.join("old"); + let new_ = root.join("new"); + fs::create_dir_all(old.join("al-1")).unwrap(); + fs::write(old.join("al-1").join("128.webp"), b"old-bytes").unwrap(); + fs::create_dir_all(&new_).unwrap(); + + merge_cover_bucket(&old, &new_).unwrap(); + + assert!(new_.join("al-1").join("128.webp").exists()); + assert_eq!(fs::read(new_.join("al-1").join("128.webp")).unwrap(), b"old-bytes"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn merge_bucket_prefers_existing_on_collision() { + let root = fresh_tmpdir("collision"); + let old = root.join("old"); + let new_ = root.join("new"); + fs::create_dir_all(old.join("al-1")).unwrap(); + fs::create_dir_all(new_.join("al-1")).unwrap(); + fs::write(old.join("al-1").join("128.webp"), b"OLD").unwrap(); + fs::write(new_.join("al-1").join("128.webp"), b"NEW").unwrap(); + + merge_cover_bucket(&old, &new_).unwrap(); + + // Existing destination wins; nothing was overwritten. + assert_eq!(fs::read(new_.join("al-1").join("128.webp")).unwrap(), b"NEW"); + let _ = fs::remove_dir_all(&root); + } + + // ── rename_bucket_inner — command-level behaviour ───────────────────────── + + #[test] + fn rename_bucket_inner_rejects_empty_keys() { + let root = fresh_tmpdir("rename-empty"); + assert!(rename_bucket_inner(&root, "", "new").is_err()); + assert!(rename_bucket_inner(&root, "old", "").is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_rejects_unsafe_keys() { + let root = fresh_tmpdir("rename-unsafe"); + assert!(rename_bucket_inner(&root, "../escape", "new").is_err()); + assert!(rename_bucket_inner(&root, "old", "/abs/path").is_err()); + assert!(rename_bucket_inner(&root, "old", "C:/Windows").is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_noop_when_old_missing() { + let root = fresh_tmpdir("rename-missing"); + // No old dir exists at all — must succeed without creating new. + rename_bucket_inner(&root, "old", "new").unwrap(); + assert!(!root.join("new").exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_noop_when_keys_equal() { + let root = fresh_tmpdir("rename-equal"); + fs::create_dir_all(root.join("same").join("al-1")).unwrap(); + fs::write(root.join("same").join("al-1").join("128.webp"), b"x").unwrap(); + rename_bucket_inner(&root, "same", "same").unwrap(); + // Still exactly where it was; nothing renamed. + assert!(root.join("same").join("al-1").join("128.webp").exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_simple_rename_when_new_missing() { + let root = fresh_tmpdir("rename-simple"); + fs::create_dir_all(root.join("old").join("al-1")).unwrap(); + fs::write(root.join("old").join("al-1").join("128.webp"), b"payload").unwrap(); + rename_bucket_inner(&root, "old", "new").unwrap(); + assert!(!root.join("old").exists()); + assert_eq!( + fs::read(root.join("new").join("al-1").join("128.webp")).unwrap(), + b"payload", + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_merges_when_new_exists() { + let root = fresh_tmpdir("rename-merge"); + fs::create_dir_all(root.join("old").join("al-1")).unwrap(); + fs::create_dir_all(root.join("new").join("al-2")).unwrap(); + fs::write(root.join("old").join("al-1").join("128.webp"), b"from-old").unwrap(); + fs::write(root.join("new").join("al-2").join("128.webp"), b"from-new").unwrap(); + // Collision on al-2 — destination wins. + fs::create_dir_all(root.join("old").join("al-2")).unwrap(); + fs::write(root.join("old").join("al-2").join("128.webp"), b"overwrite-attempt").unwrap(); + + rename_bucket_inner(&root, "old", "new").unwrap(); + + // Old bucket gone. + assert!(!root.join("old").exists()); + // al-1 moved in. + assert_eq!( + fs::read(root.join("new").join("al-1").join("128.webp")).unwrap(), + b"from-old", + ); + // al-2 destination preserved (prefer-existing). + assert_eq!( + fs::read(root.join("new").join("al-2").join("128.webp")).unwrap(), + b"from-new", + ); + let _ = fs::remove_dir_all(&root); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae1c0637..07f7cd19 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -599,6 +599,7 @@ pub fn run() { backup_import_full, migration_inspect, migration_run, + resolve_host_addresses, psysonic_syncfs::sync::batch::calculate_sync_payload, exit_app, cli_publish_player_snapshot, @@ -748,6 +749,7 @@ pub fn run() { cover_cache::cover_cache_configure, cover_cache::cover_cache_clear, cover_cache::cover_cache_clear_server, + cover_cache::cover_cache_rename_server_bucket, cover_cache::cover_cache_stats_server, cover_cache::library_cover_backfill_batch, cover_cache::library_cover_progress, diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs index 79017237..59246d46 100644 --- a/src-tauri/src/lib_commands/app_api/mod.rs +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -3,6 +3,7 @@ mod cli_bridge; mod core; mod integration; mod migration; +mod network; mod perf; pub(crate) mod platform; @@ -33,6 +34,7 @@ pub(crate) use integration::{ unregister_global_shortcut, }; pub(crate) use migration::{migration_inspect, migration_run}; +pub(crate) use network::resolve_host_addresses; // 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 new file mode 100644 index 00000000..c19a04e2 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/network.rs @@ -0,0 +1,124 @@ +//! Network helpers exposed as Tauri commands. Currently just DNS lookup for the +//! dual-server-address add/edit form (UI hint only — not for connect). + +use std::collections::HashSet; +use tokio::net::lookup_host; + +/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6). +/// +/// Strips a `host:port` suffix before lookup — the form only knows the host. +/// Used by the add/edit-server form to hint whether the entered address +/// classifies as LAN or public (a hostname that resolves to a private range +/// IP suggests the user might want to add a public second address, and +/// vice versa). **Never used for connect** — connect always goes through the +/// existing `pingWithCredentials` path, which carries credentials. +/// +/// Returns an empty vec on lookup failure (the UI then shows no hint, by +/// design: a transient DNS hiccup shouldn't block save). +#[tauri::command] +pub(crate) async fn resolve_host_addresses(hostname: String) -> Result, String> { + let trimmed = hostname.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + // Strip port if present. IPv6 literals use [host]:port; bare IPv4/host + // use host:port. We only resolve the host portion. + let host_only = strip_port(trimmed); + if host_only.is_empty() { + return Ok(Vec::new()); + } + + // tokio's lookup_host requires a port. Append :0 — we discard the port + // from each returned SocketAddr. + let lookup_target = if host_only.contains(':') { + // IPv6 literal — wrap in brackets if not already. + if host_only.starts_with('[') { + format!("{}:0", host_only) + } else { + format!("[{}]:0", host_only) + } + } else { + format!("{}:0", host_only) + }; + + let addrs = match lookup_host(&lookup_target).await { + Ok(iter) => iter, + Err(_) => return Ok(Vec::new()), + }; + + let mut seen: HashSet = HashSet::new(); + let mut result = Vec::new(); + for sock in addrs { + let ip = sock.ip().to_string(); + if seen.insert(ip.clone()) { + result.push(ip); + } + } + Ok(result) +} + +/// 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 { + let s = input.trim(); + // Bracketed IPv6 — `[host]:port` → `host`; `[host]` (no port) → `host`. + if let Some(rest) = s.strip_prefix('[') { + if let Some(close) = rest.find(']') { + return rest[..close].to_string(); + } + // Malformed bracket — fall through. + } + // Hostnames and IPv4 only contain one `:`. IPv6 without brackets cannot + // be unambiguously split from a port, so leave as-is (lookup_host wraps + // it for us). + let colon_count = s.bytes().filter(|&b| b == b':').count(); + if colon_count == 1 { + if let Some((host, _port)) = s.rsplit_once(':') { + return host.to_string(); + } + } + s.to_string() +} + +#[cfg(test)] +mod tests { + use super::strip_port; + + #[test] + fn strips_host_port_pair() { + assert_eq!(strip_port("music.example.com:4533"), "music.example.com"); + } + + #[test] + fn strips_ipv4_port_pair() { + assert_eq!(strip_port("192.168.0.10:4533"), "192.168.0.10"); + } + + #[test] + fn leaves_bare_host_alone() { + assert_eq!(strip_port("music.example.com"), "music.example.com"); + } + + #[test] + fn unwraps_bracketed_ipv6_with_port() { + assert_eq!(strip_port("[::1]:4533"), "::1"); + } + + #[test] + fn unwraps_bracketed_ipv6_without_port() { + assert_eq!(strip_port("[fe80::1]"), "fe80::1"); + } + + #[test] + fn leaves_unbracketed_ipv6_alone() { + // Multiple colons + no brackets — can't tell host from port; safe to + // hand the raw string to lookup_host, which handles it. + assert_eq!(strip_port("fe80::1"), "fe80::1"); + } + + #[test] + fn handles_empty_input() { + assert_eq!(strip_port(""), ""); + } +} diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index 92090fcd..b86f22b5 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { restBaseFromUrl } from './subsonicClient'; @@ -42,13 +43,17 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const scope = ref.serverScope; if (scope.kind === 'server') { + // scope.url is the index-stable primary; the Rust cover fetcher needs + // the runtime connect URL (LAN or public, whichever currently answers). return { serverIndexKey: coverIndexKeyFromRef(ref), cacheKind: ref.cacheKind, cacheEntityId: ref.cacheEntityId, coverArtId: ref.fetchCoverArtId, tier, - restBaseUrl: coverCacheRestHost(scope.url), + restBaseUrl: coverCacheRestHost( + connectBaseUrlForServer({ id: scope.serverId, url: scope.url }), + ), username: scope.username, password: scope.password, }; @@ -66,7 +71,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { return getActiveServer(); })() : getActiveServer(); - const baseUrl = server?.url || getBaseUrl(); + const baseUrl = server ? connectBaseUrlForServer(server) : getBaseUrl(); return { serverIndexKey: coverIndexKeyFromRef(ref), cacheKind: ref.cacheKind, diff --git a/src/api/network.ts b/src/api/network.ts new file mode 100644 index 00000000..deeb971e --- /dev/null +++ b/src/api/network.ts @@ -0,0 +1,24 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * Resolve a hostname (or `host:port`) to a deduped list of IP-address strings + * via the Rust `resolve_host_addresses` command. IPv4 + IPv6 returned in one + * list; order is whatever the OS resolver hands back. + * + * **Form-hint only.** Used by the add/edit-server form to suggest whether + * the entered address is LAN-only (→ hint to add a public second address) + * or public-only (→ hint to add a local one). The actual connect path runs + * `pingWithCredentials` against the URL — not against the resolved IPs. + * + * Lookup failure / DNS hiccup → empty array (UI shows no hint, by design; + * a transient DNS error must not block save). + */ +export async function resolveHostAddresses(hostname: string): Promise { + const trimmed = hostname.trim(); + if (!trimmed) return []; + try { + return await invoke('resolve_host_addresses', { hostname: trimmed }); + } catch { + return []; + } +} diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index e69ff744..fdfa0c65 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -3,6 +3,7 @@ import md5 from 'md5'; import { version } from '../../package.json'; import { useAuthStore } from '../store/authStore'; import type { ServerProfile } from '../store/authStoreTypes'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup'; export const SUBSONIC_CLIENT = `psysonic/${version}`; @@ -66,7 +67,18 @@ export async function apiForServer( ): Promise { const server = getServerById(serverId); if (!server) throw new Error(`Unknown server: ${serverId}`); - return apiWithCredentials(server.url, server.username, server.password, endpoint, extra, timeout); + // Dual-address: route through the cached connect URL when one has been + // probed for this profile; otherwise the normalized primary url is the + // same string the legacy code path used, so single-address profiles are + // byte-identical to before. + return apiWithCredentials( + connectBaseUrlForServer(server), + server.username, + server.password, + endpoint, + extra, + timeout, + ); } export async function api( diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index 1b1833bd..63125876 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -3,6 +3,7 @@ import { coverStorageKey, coverStorageKeyFromRef } from '../cover/storageKeys'; import { coverEntryToRef, resolveAlbumCoverEntry } from '../cover/resolveEntry'; import type { CoverArtTier } from '../cover/types'; import { useAuthStore } from '../store/authStore'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; @@ -45,7 +46,8 @@ function streamUrlFromProfile( export function buildStreamUrlForServer(serverId: string, id: string): string { const server = findServerByIdOrIndexKey(serverId); if (!server) return buildStreamUrl(id); - return streamUrlFromProfile(server.url, server.username, server.password, id); + // Dual-address: route the stream through the cached connect endpoint. + return streamUrlFromProfile(connectBaseUrlForServer(server), server.username, server.password, id); } export function buildStreamUrl(id: string): string { @@ -53,7 +55,10 @@ export function buildStreamUrl(id: string): string { const server = getActiveServer(); const baseUrl = getBaseUrl(); if (!server || !baseUrl) return streamUrlFromProfile('', '', '', id); - return streamUrlFromProfile(server.url, server.username, server.password, id); + // `getBaseUrl()` already returns the cached connect URL; use it directly + // instead of re-normalizing `server.url`, which would bypass the dual- + // address connect cache. + return streamUrlFromProfile(baseUrl, server.username, server.password, id); } /** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */ diff --git a/src/components/OrbitSharePopover.tsx b/src/components/OrbitSharePopover.tsx index e0b88c9f..18604039 100644 --- a/src/components/OrbitSharePopover.tsx +++ b/src/components/OrbitSharePopover.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { useAuthStore } from '../store/authStore'; import { buildOrbitShareLink } from '../utils/orbit'; +import { serverShareBaseUrl } from '../utils/server/serverEndpoint'; interface Props { anchorRef: React.RefObject; @@ -23,7 +24,10 @@ export default function OrbitSharePopover({ anchorRef, onClose }: Props) { const [copied, setCopied] = useState(false); const shareLink = sessionId - ? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId) + ? (() => { + const active = useAuthStore.getState().getActiveServer(); + return buildOrbitShareLink(active ? serverShareBaseUrl(active) : '', sessionId); + })() : null; useEffect(() => { diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx index 95c13b9a..5a865537 100644 --- a/src/components/OrbitStartModal.tsx +++ b/src/components/OrbitStartModal.tsx @@ -13,7 +13,7 @@ import { import { randomOrbitSessionName } from '../utils/orbitNames'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; -import { isLanUrl } from '../hooks/useConnectionStatus'; +import { isLanUrl, serverShareBaseUrl } from '../utils/server/serverEndpoint'; import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit'; interface Props { onClose: () => void; } @@ -38,7 +38,10 @@ export default function OrbitStartModal({ onClose }: Props) { const [clearQueue, setClearQueue] = useState(false); const server = useAuthStore.getState().getActiveServer(); - const serverBase = server?.url ?? ''; + // Orbit links go to remote guests — use the share URL (public by default + // when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning + // then correctly reads the address the guest will actually see. + const serverBase = server ? serverShareBaseUrl(server) : ''; const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer'); const onLan = isLanUrl(serverBase); diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 9d8caf68..ddc8e4ff 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useAuthStore } from '../store/authStore'; import { encodeSharePayload } from '../utils/share/shareLink'; +import { serverShareBaseUrl } from '../utils/server/serverEndpoint'; import { copyTextToClipboard } from '../utils/server/serverMagicString'; import { showToast } from '../utils/ui/toast'; import { useThemeStore } from '../store/themeStore'; @@ -196,7 +197,12 @@ function QueuePanelHostOrSolo() { showToast(t('queue.shareQueueEmpty'), 3000, 'info'); return; } - const srv = useAuthStore.getState().getBaseUrl(); + // Queue share goes to remote recipients — use the share URL, not the + // connect URL the active app is currently bound to (would leak the LAN + // host on a dual-address profile). + const active = useAuthStore.getState().getActiveServer(); + if (!active) return; + const srv = serverShareBaseUrl(active); if (!srv) return; const ids = queueItems.map(r => r.trackId); const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids })); diff --git a/src/components/settings/AddServerForm.test.tsx b/src/components/settings/AddServerForm.test.tsx new file mode 100644 index 00000000..1f3476cf --- /dev/null +++ b/src/components/settings/AddServerForm.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { AddServerForm } from './AddServerForm'; +import { encodeServerMagicString } from '@/utils/server/serverMagicString'; + +// resolve_host_addresses Tauri command — hint-only, must not block save. +vi.mock('@/api/network', () => ({ + resolveHostAddresses: vi.fn(async () => [] as string[]), +})); + +// showToast mocked so we can assert two-LAN validation surfaced the error. +vi.mock('@/utils/ui/toast', () => ({ + showToast: vi.fn(), +})); + +import { showToast } from '@/utils/ui/toast'; + +describe('AddServerForm — dual-address behaviour', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('saves a single-address profile without alternateUrl / shareUsesLocalUrl', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + // [0] name, [1] primary url, [2] alternate url, [3] username, [4] magic string + 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: /add/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg.username).toBe('tester'); + expect(arg.password).toBe('pw'); + expect(arg).not.toHaveProperty('alternateUrl'); + expect(arg).not.toHaveProperty('shareUsesLocalUrl'); + }); + + it('saves both addresses when the user fills the second field', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + // [0] name, [1] primary url, [2] alternate url, [3] username + await user.type(inputs[1]!, 'https://music.example.com'); + await user.type(inputs[2]!, 'http://192.168.0.10:4533'); + await user.type(inputs[3]!, 'tester'); + await user.type(screen.getByPlaceholderText('••••••••'), 'pw'); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg.alternateUrl).toBe('http://192.168.0.10:4533'); + expect(arg.shareUsesLocalUrl).toBe(false); + }); + + it('blocks save with a toast when both addresses classify as LAN', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[1]!, 'http://10.0.0.5'); + await user.type(inputs[2]!, 'http://192.168.0.10'); + await user.type(inputs[3]!, 'tester'); + await user.type(screen.getByPlaceholderText('••••••••'), 'pw'); + await user.click(screen.getByRole('button', { name: /add/i })); + + // Save is blocked, error toast surfaced with the two-LAN string. + expect(onSave).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith( + expect.stringMatching(/both addresses are local/i), + expect.any(Number), + 'error', + ); + }); + + it('decodes a v2 magic string and forwards alternateUrl + shareUsesLocalUrl on save', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const magicString = encodeServerMagicString({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + shareUsesLocalUrl: true, + username: 'tester', + password: 'pw', + }); + + // The magic-string input is the last textbox shown for new-profile mode. + const inputs = screen.getAllByRole('textbox'); + const magicInput = inputs[inputs.length - 1]!; + await user.type(magicInput, magicString); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg.alternateUrl).toBe('http://192.168.0.10:4533'); + expect(arg.shareUsesLocalUrl).toBe(true); + expect(arg.username).toBe('tester'); + expect(arg.password).toBe('pw'); + }); + + it('strips alternateUrl + share flag when the user empties the second field', async () => { + const onSave = vi.fn(); + renderWithProviders( + , + ); + const user = userEvent.setup(); + + // Locate the alternate-url field (second URL-shaped input, prefilled). + const altInput = screen.getByDisplayValue('http://192.168.0.10'); + await user.clear(altInput); + await user.click(screen.getByRole('button', { name: /save/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg).not.toHaveProperty('alternateUrl'); + expect(arg).not.toHaveProperty('shareUsesLocalUrl'); + }); +}); diff --git a/src/components/settings/AddServerForm.tsx b/src/components/settings/AddServerForm.tsx index 9e66f071..41ee6b7b 100644 --- a/src/components/settings/AddServerForm.tsx +++ b/src/components/settings/AddServerForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ServerProfile } from '../../store/authStoreTypes'; import { showToast } from '../../utils/ui/toast'; @@ -9,6 +9,34 @@ import { type ServerMagicPayload, } from '../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../utils/server/serverDisplayName'; +import { isLanUrl } from '../../utils/server/serverEndpoint'; +import { resolveHostAddresses } from '../../api/network'; + +type FormState = { + name: string; + url: string; + alternateUrl: string; + shareUsesLocalUrl: boolean; + username: string; + password: string; +}; + +/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */ +function hostnameForDnsHint(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + try { + const u = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`); + const host = u.hostname.replace(/^\[|\]$/g, ''); + // Literal IPv4 / IPv6 → no DNS lookup; isLanUrl alone classifies it. + if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return null; + if (host.includes(':')) return null; + if (host === 'localhost' || host.endsWith('.local')) return null; + return host; + } catch { + return null; + } +} export function AddServerForm({ onSave, @@ -16,35 +44,64 @@ export function AddServerForm({ initialInvite = null, editingServer = null, }: { - onSave: (data: Omit) => void; + onSave: (data: Omit) => void | Promise; onCancel: () => void; initialInvite?: ServerMagicPayload | null; editingServer?: ServerProfile | null; }) { const { t } = useTranslation(); const isEdit = editingServer != null; - const [form, setForm] = useState( + const [form, setForm] = useState( editingServer - ? { name: editingServer.name, url: editingServer.url, username: editingServer.username, password: editingServer.password } - : { name: '', url: '', username: '', password: '' }, + ? { + name: editingServer.name, + url: editingServer.url, + alternateUrl: editingServer.alternateUrl ?? '', + shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false, + username: editingServer.username, + password: editingServer.password, + } + : { + name: '', + url: '', + alternateUrl: '', + shareUsesLocalUrl: false, + username: '', + password: '', + }, ); const [magicString, setMagicString] = useState(''); const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); + // DNS-classified hint: 'lan' / 'public' / null (no hint, no lookup yet, or + // literal IP — isLanUrl already classifies those without DNS). + const [primaryDnsClass, setPrimaryDnsClass] = useState<'lan' | 'public' | null>(null); useEffect(() => { if (!initialInvite) return; setBlockPasswordReveal(true); - setForm({ + setForm(f => ({ + ...f, name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url), url: initialInvite.url, + // v2 invites carry the host's dual-address fields. Pre-populate so the + // receiver sees both addresses + the share preference rather than + // re-typing them. + alternateUrl: initialInvite.alternateUrl ?? '', + shareUsesLocalUrl: initialInvite.shareUsesLocalUrl ?? false, username: initialInvite.username, password: initialInvite.password, - }); + })); setMagicString(encodeServerMagicString(initialInvite)); }, [initialInvite]); - const update = (k: keyof typeof form) => (e: React.ChangeEvent) => - setForm(f => ({ ...f, [k]: e.target.value })); + const update = (k: K) => + (e: React.ChangeEvent) => { + const value = + e.target.type === 'checkbox' + ? (e.target as HTMLInputElement).checked + : e.target.value; + setForm(f => ({ ...f, [k]: value }) as FormState); + }; const handleMagicStringChange = (e: React.ChangeEvent) => { const v = e.target.value; @@ -53,16 +110,70 @@ export function AddServerForm({ const decoded = decodeServerMagicString(trimmed); if (decoded) { setBlockPasswordReveal(true); - setForm({ + setForm(f => ({ + ...f, name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), url: decoded.url, + alternateUrl: decoded.alternateUrl ?? '', + shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false, username: decoded.username, password: decoded.password, - }); + })); } }; - const submit = () => { + // Literal-IP classification — instant, no DNS needed. + const primaryUrlIsLanLiteral = useMemo(() => { + const trimmed = form.url.trim(); + if (!trimmed) return null as boolean | null; + if (hostnameForDnsHint(trimmed) !== null) return null; // hostname — defer to DNS + return isLanUrl(trimmed); + }, [form.url]); + + // Effective LAN classification: literal IP shortcut OR DNS result. + const primaryUrlIsLan = useMemo(() => { + if (primaryUrlIsLanLiteral !== null) return primaryUrlIsLanLiteral; + if (primaryDnsClass === null) return null; + return primaryDnsClass === 'lan'; + }, [primaryUrlIsLanLiteral, primaryDnsClass]); + + const runDnsHint = async () => { + const hostname = hostnameForDnsHint(form.url); + if (!hostname) { + setPrimaryDnsClass(null); + return; + } + const addresses = await resolveHostAddresses(hostname); + if (addresses.length === 0) { + // DNS failed (no network, NXDOMAIN, …) → no hint, don't block. + setPrimaryDnsClass(null); + return; + } + const anyPublic = addresses.some(ip => !isLanUrl(`http://${ip}`)); + setPrimaryDnsClass(anyPublic ? 'public' : 'lan'); + }; + + // Two-LAN client-side check before submit. Returns true on validation pass. + const validateAddresses = (): boolean => { + const url = form.url.trim(); + const alt = form.alternateUrl.trim(); + if (!url) return false; + if (!alt) return true; // single-address — always fine. + // For the LAN-LAN check we accept both the synchronous isLanUrl + // classification (literal IPs + .local + localhost) and the DNS-resolved + // class for the primary. The alternate goes through isLanUrl directly — + // we don't run a second DNS lookup for it here; the verify step on save + // will catch any deeper inconsistency. + const primaryLan = primaryUrlIsLan ?? isLanUrl(url); + const altLan = isLanUrl(alt); + if (primaryLan && altLan) { + showToast(t('settings.serverBothLanError'), 4500, 'error'); + return false; + } + return true; + }; + + const submit = async () => { const ms = magicString.trim(); if (ms) { const decoded = decodeServerMagicString(ms); @@ -70,28 +181,66 @@ export function AddServerForm({ showToast(t('login.magicStringInvalid'), 4000, 'error'); return; } - onSave({ + // v2 invites carry alternateUrl + shareUsesLocalUrl — must survive the + // magic-string submit path (handleMagicStringChange already prefills + // them into form state, but the magic-string branch forwards the + // decoded payload directly so we have to pick them off here too). + const altDecoded = decoded.alternateUrl?.trim() ?? ''; + await onSave({ name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), url: decoded.url, username: decoded.username, password: decoded.password, + ...(altDecoded + ? { + alternateUrl: altDecoded, + shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false, + } + : {}), }); return; } if (!form.url.trim()) return; - onSave({ + if (!validateAddresses()) 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). + const data: Omit = { name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password, - }); + ...(altTrimmed + ? { + alternateUrl: altTrimmed, + shareUsesLocalUrl: form.shareUsesLocalUrl, + } + : {}), + }; + await onSave(data); }; + // Hint to show under the second-address field: only when the primary is + // classified one way or the other and the second is still empty. + const alternateUrlHint = + !form.alternateUrl.trim() && primaryUrlIsLan !== null + ? primaryUrlIsLan + ? t('settings.serverAlternateUrlHintAddPublic') + : t('settings.serverAlternateUrlHintAddLocal') + : null; + + // Share checkbox visibility (spec §5.3): + // - hidden when there is no second address + // - shown the moment a user fills the second field (or both already exist + // on an edit). Default off; persists across save/load. + const showShareCheckbox = form.alternateUrl.trim().length > 0; + return (
{ e.preventDefault(); submit(); }} + onSubmit={e => { e.preventDefault(); void submit(); }} >

{isEdit ? t('settings.editServerTitle') : t('settings.addServerTitle')} @@ -102,8 +251,54 @@ export function AddServerForm({
- + { void runDnsHint(); }} + placeholder={t('settings.serverUrlPlaceholder')} + autoComplete="off" + />
+
+ + + {alternateUrlHint && ( +
+ {alternateUrlHint} +
+ )} +
+ {showShareCheckbox && ( +
+ +
+ )}
diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index 4598c03e..042f0943 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -13,6 +13,17 @@ import type { ServerProfile } from '../../store/authStoreTypes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { useDragDrop } from '../../contexts/DragDropContext'; import { type ServerMagicPayload } from '../../utils/server/serverMagicString'; +import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint'; +import { + verifySameServerEndpoints, + type VerifySameServerResult, +} from '../../utils/server/serverFingerprint'; +import { + indexKeyRemapForUrlChange, + runIndexKeyRemigration, +} from '../../utils/server/serverUrlRemigration'; +import { useConfirmModalStore } from '../../store/confirmModalStore'; +import { showToast } from '../../utils/ui/toast'; import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; @@ -112,17 +123,21 @@ export function ServersTab({ const testConnection = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (ping.ok) { + // Dual-address: probe through the connect layer so the test reflects + // whichever endpoint the app would actually use right now (LAN at home, + // public elsewhere). probe.baseUrl also feeds the AudioMuse probe so + // that one hits the same endpoint. + const probe = await ensureConnectUrlResolved(server); + if (probe.ok) { const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, + type: probe.ping.type, + serverVersion: probe.ping.serverVersion, + openSubsonic: probe.ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); - scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); + scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity); } - setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' })); + setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } @@ -166,12 +181,48 @@ export function ServersTab({ setPastedServerInvite(null); }; + /** + * Surface a dual-address verify failure as a toast (mismatch / + * insufficient / unreachable). Returns true when the result is `ok` and + * the caller should proceed; false when the user must fix something + * before save. + */ + const announceVerifyResult = (result: VerifySameServerResult): boolean => { + if (result.ok) return true; + if (result.reason === 'unreachable') { + showToast( + t('settings.dualAddressUnreachable', { host: result.unreachableHost ?? '' }), + 6000, + 'error', + ); + } else if (result.reason === 'mismatch') { + showToast(t('settings.dualAddressMismatch'), 6000, 'error'); + } else { + showToast(t('settings.dualAddressInsufficient'), 6000, 'error'); + } + return false; + }; + const handleAddServer = async (data: Omit) => { setShowAddForm(false); setPastedServerInvite(null); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { + // Dual-address: confirm both addresses point at the same server + // before persisting anything. Single-address adds skip verify and go + // straight to the legacy ping (which is also the connect-test). + if (data.alternateUrl) { + const verify = await verifySameServerEndpoints( + { url: data.url, alternateUrl: data.alternateUrl }, + data.username, + data.password, + ); + if (!announceVerifyResult(verify)) { + setConnStatus(s => ({ ...s, [tempId]: 'error' })); + return; + } + } const ping = await pingWithCredentials(data.url, data.username, data.password); if (ping.ok) { const id = auth.addServer(data); @@ -193,13 +244,76 @@ export function ServersTab({ } }; - // Edit saves unconditionally — ping result becomes a post-save status - // indicator (analog zum existing Test-Button) rather than blocking the - // save. Lets users update a profile even when the server is currently + // Edit normally saves unconditionally — ping result becomes a post-save + // status indicator (analog zum existing Test-Button) rather than blocking + // the save. Lets users update a profile even when the server is currently // unreachable. + // + // **Dual-address exception:** when the edit introduces or changes the + // second address (or changes the primary url while a second address is + // already saved), verify both addresses are the same server *before* + // persisting. A mismatch here would silently bind library / cover / queue + // data to two unrelated boxes — the spec blocks save in v1. const handleEditServer = async (id: string, data: Omit) => { + const previous = auth.servers.find(s => s.id === id); + + // URL-change remigration — runs BEFORE everything else when the edit + // changes the derived index key. User confirms first; on failure the + // edit is aborted with a stage-specific toast. Spec §8. + const remap = previous ? indexKeyRemapForUrlChange(previous, data) : null; + if (remap) { + const confirmed = await useConfirmModalStore.getState().request({ + title: t('settings.urlRemigrationTitle'), + message: t('settings.urlRemigrationMessage', { + oldKey: remap.oldKey, + newKey: remap.newKey, + }), + confirmLabel: t('settings.urlRemigrationConfirm'), + cancelLabel: t('common.cancel'), + danger: true, + }); + if (!confirmed) return; + setConnStatus(s => ({ ...s, [id]: 'testing' })); + const result = await runIndexKeyRemigration(remap); + if (!result.ok) { + const failureKey = + result.failure.stage === 'inspect' + ? 'settings.urlRemigrationFailureInspect' + : result.failure.stage === 'run' + ? 'settings.urlRemigrationFailureRun' + : 'settings.urlRemigrationFailureCoverRename'; + showToast(t(failureKey), 8000, 'error'); + setConnStatus(s => ({ ...s, [id]: 'error' })); + return; + } + } + + const dualAddressChanged = + data.alternateUrl != null && + data.alternateUrl !== '' && + (data.alternateUrl !== previous?.alternateUrl || + data.url !== previous?.url || + data.username !== previous?.username || + data.password !== previous?.password); + + if (dualAddressChanged) { + setConnStatus(s => ({ ...s, [id]: 'testing' })); + const verify = await verifySameServerEndpoints( + { url: data.url, alternateUrl: data.alternateUrl }, + data.username, + data.password, + ); + if (!announceVerifyResult(verify)) { + setConnStatus(s => ({ ...s, [id]: 'error' })); + return; + } + } + setEditingServerId(null); auth.updateServer(id, data); + // 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); diff --git a/src/components/settings/UserForm.tsx b/src/components/settings/UserForm.tsx index 8dfb4463..6253bbab 100644 --- a/src/components/settings/UserForm.tsx +++ b/src/components/settings/UserForm.tsx @@ -6,8 +6,10 @@ import { showToast } from '../../utils/ui/toast'; import { copyTextToClipboard, encodeServerMagicString, + magicPayloadAddressFields, } from '../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../utils/server/serverDisplayName'; +import { useAuthStore } from '../../store/authStore'; export interface UserFormState { userName: string; @@ -104,8 +106,12 @@ export function UserForm({ } finally { setMagicGenBusy(false); } + const addressFields = magicPayloadAddressFields( + shareServerUrl.trim(), + useAuthStore.getState().servers, + ); const str = encodeServerMagicString({ - url: shareServerUrl.trim(), + ...addressFields, username: form.userName.trim(), password: form.password, name: shortHostFromServerUrl(shareServerUrl), diff --git a/src/components/settings/userMgmt/MagicStringModal.tsx b/src/components/settings/userMgmt/MagicStringModal.tsx index 3b7cbfb3..1ed6af6a 100644 --- a/src/components/settings/userMgmt/MagicStringModal.tsx +++ b/src/components/settings/userMgmt/MagicStringModal.tsx @@ -7,8 +7,10 @@ import { showToast } from '../../../utils/ui/toast'; import { copyTextToClipboard, encodeServerMagicString, + magicPayloadAddressFields, } from '../../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../../utils/server/serverDisplayName'; +import { useAuthStore } from '../../../store/authStore'; interface Props { user: NdUser; @@ -63,8 +65,12 @@ export function MagicStringModal({ return; } setSubmitting(false); + const addressFields = magicPayloadAddressFields( + serverUrl, + useAuthStore.getState().servers, + ); const str = encodeServerMagicString({ - url: serverUrl, + ...addressFields, username: user.userName, password: password.trim(), name: shortHostFromServerUrl(serverUrl), diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 2fbd7253..b8fd66ee 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -134,6 +134,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)', 'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)', 'Artist detail: sort albums by year (newest/oldest) in the Albums section (PR #877)', + 'Servers: optional second address per profile (LAN + public), LAN-first connect with sticky fallback, same-server verify on save, share-link picker, URL-change library remigration, magic invite v2 (PR #880)', 'Cover art: Windows thumbnails, tier fallback, PNG decode, Subsonic coverArt id resolution (PR #878)', 'Analytics: native advanced library backfill coordinator — UI stays responsive on large libraries (PR #881)', 'Analytics: library backfill scan phase/cursor persistence so advanced indexing can finish large libraries (PR #882)', diff --git a/src/cover/diskSrcCache.test.ts b/src/cover/diskSrcCache.test.ts index 2d122c39..6303138d 100644 --- a/src/cover/diskSrcCache.test.ts +++ b/src/cover/diskSrcCache.test.ts @@ -6,7 +6,14 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { convertFileSrc } from '@tauri-apps/api/core'; -import { clearAllDiskSrcCache, coverDiskUrl, getDiskSrc, rememberDiskSrc } from './diskSrcCache'; +import { + clearAllDiskSrcCache, + coverDiskUrl, + forgetDiskSrcForServer, + forgetDiskSrcPrefix, + getDiskSrc, + rememberDiskSrc, +} from './diskSrcCache'; describe('coverDiskUrl', () => { beforeEach(() => { @@ -56,3 +63,69 @@ describe('rememberDiskSrc', () => { expect(getDiskSrc('srv:cover:al-1:128')).toBe(''); }); }); + +const serverScopeA = { + kind: 'server' as const, + serverId: 'profile-a', + url: 'http://srv-a', + username: 'u', + password: 'p', +}; + +describe('forgetDiskSrcForServer', () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockImplementation((p: string) => + `asset://localhost/${encodeURIComponent(p)}`, + ); + clearAllDiskSrcCache(); + }); + + it('drops every cached entry under the given server index key', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + rememberDiskSrc('srv-a:cover:album:al-1:512', '/disk/a/al-1/512.webp'); + rememberDiskSrc('srv-a:cover:album:al-2:128', '/disk/a/al-2/128.webp'); + rememberDiskSrc('srv-b:cover:album:al-1:128', '/disk/b/al-1/128.webp'); + + forgetDiskSrcForServer('srv-a'); + + expect(getDiskSrc('srv-a:cover:album:al-1:128')).toBe(''); + expect(getDiskSrc('srv-a:cover:album:al-1:512')).toBe(''); + expect(getDiskSrc('srv-a:cover:album:al-2:128')).toBe(''); + // Other servers untouched — this is the URL-change remigration path, + // not a global purge. + expect(getDiskSrc('srv-b:cover:album:al-1:128')).not.toBe(''); + }); + + it('is a no-op on an empty key (defensive)', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + forgetDiskSrcForServer(''); + expect(getDiskSrc('srv-a:cover:album:al-1:128')).not.toBe(''); + }); + + it('is a no-op when nothing matches the prefix', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + forgetDiskSrcForServer('srv-missing'); + expect(getDiskSrc('srv-a:cover:album:al-1:128')).not.toBe(''); + }); +}); + +describe('forgetDiskSrcPrefix (regression — must not be confused with forgetDiskSrcForServer)', () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockImplementation((p: string) => + `asset://localhost/${encodeURIComponent(p)}`, + ); + clearAllDiskSrcCache(); + }); + + it('only clears the (server, cache entity) tuple', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + rememberDiskSrc('srv-a:cover:album:al-2:128', '/disk/a/al-2/128.webp'); + forgetDiskSrcPrefix({ + serverScope: serverScopeA, + cacheKind: 'album', + cacheEntityId: 'al-1', + }); + expect(getDiskSrc('srv-a:cover:album:al-1:128')).toBe(''); + expect(getDiskSrc('srv-a:cover:album:al-2:128')).not.toBe(''); + }); +}); diff --git a/src/cover/diskSrcCache.ts b/src/cover/diskSrcCache.ts index 1795f96a..87706a50 100644 --- a/src/cover/diskSrcCache.ts +++ b/src/cover/diskSrcCache.ts @@ -123,6 +123,25 @@ export function forgetDiskSrcPrefix(ref: { if (changed) bumpDiskSrcCache(); } +/** + * Drop every cached disk-src under a server index key (all cover ids, all + * tiers). Used by the URL-change remigration `cover:bucket-renamed` listener + * so entries pointing at the now-renamed `{root}/{oldKey}/…` path stop + * serving stale URLs. + */ +export function forgetDiskSrcForServer(serverIndexKey: string): void { + if (!serverIndexKey) return; + const prefix = `${serverIndexKey}:cover:`; + let changed = false; + for (const key of diskSrcByStorageKey.keys()) { + if (key.startsWith(prefix)) { + diskSrcByStorageKey.delete(key); + changed = true; + } + } + if (changed) bumpDiskSrcCache(); +} + export function clearAllDiskSrcCache(): void { if (diskSrcByStorageKey.size === 0) return; diskSrcByStorageKey.clear(); diff --git a/src/cover/fetchUrl.ts b/src/cover/fetchUrl.ts index 550a8ae9..5ab587dd 100644 --- a/src/cover/fetchUrl.ts +++ b/src/cover/fetchUrl.ts @@ -4,14 +4,19 @@ import { } from '../api/subsonicStreamUrl'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { useAuthStore } from '../store/authStore'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import type { CoverArtRef, CoverArtTier } from './types'; /** Builds ephemeral getCoverArt URL — NOT a cache key */ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string { const { fetchCoverArtId, serverScope } = ref; if (serverScope.kind === 'server') { + // Scope.url is the index-stable primary URL (so storage keys keep working + // across LAN ↔ public). For the actual cover fetch we want the connect + // endpoint — use connectBaseUrlForServer to pick the cached LAN/public + // URL, falling back to the primary url when no probe has run yet. return buildCoverArtUrlForServer( - serverScope.url, + connectBaseUrlForServer({ id: serverScope.serverId, url: serverScope.url }), serverScope.username, serverScope.password, fetchCoverArtId, @@ -25,7 +30,7 @@ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): str const server = useAuthStore.getState().servers.find(s => s.id === playbackSid); if (server) { return buildCoverArtUrlForServer( - server.url, + connectBaseUrlForServer(server), server.username, server.password, fetchCoverArtId, diff --git a/src/hooks/tauriBridge/useCoverArtBridge.ts b/src/hooks/tauriBridge/useCoverArtBridge.ts index 8bd47980..116c83a4 100644 --- a/src/hooks/tauriBridge/useCoverArtBridge.ts +++ b/src/hooks/tauriBridge/useCoverArtBridge.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { listen } from '@tauri-apps/api/event'; import { clearAllDiskSrcCache, + forgetDiskSrcForServer, forgetDiskSrcPrefix, } from '../../cover/diskSrcCache'; import { rememberDiskSrcLadder } from '../../cover/diskSrcLookup'; @@ -24,6 +25,11 @@ type CoverEvictedPayload = { cacheEntityId: string; }; +type CoverBucketRenamedPayload = { + oldKey: string; + newKey: string; +}; + /** Rust → UI: disk `.webp` ready — do not invalidate IDB (that caused webview refetch storms). */ export function useCoverArtBridge(): void { useEffect(() => { @@ -57,6 +63,17 @@ export function useCoverArtBridge(): void { } }), ); + unsubs.push( + await listen('cover:bucket-renamed', ev => { + // URL-change remigration moved the disk bucket from oldKey to newKey + // (cover_cache_rename_server_bucket). Every in-memory disk-src cache + // entry tagged under oldKey now points at a path that no longer + // exists — drop them so the next read re-resolves under newKey via + // the normal getDiskSrcForGrid path. + if (!ev.payload?.oldKey) return; + forgetDiskSrcForServer(ev.payload.oldKey); + }), + ); })(); return () => { for (const u of unsubs) u(); diff --git a/src/hooks/useConnectionStatus.test.ts b/src/hooks/useConnectionStatus.test.ts new file mode 100644 index 00000000..abebee3c --- /dev/null +++ b/src/hooks/useConnectionStatus.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { resetAuthStore } from '@/test/helpers/storeReset'; +import { useAuthStore } from '@/store/authStore'; +import { + invalidateReachableEndpointCache, + type PickReachableResult, +} from '@/utils/server/serverEndpoint'; + +vi.mock('@/api/subsonic', () => ({ + pingWithCredentials: vi.fn(), + scheduleInstantMixProbeForServer: vi.fn(), +})); + +vi.mock('@/utils/perf/perfFlags', () => ({ + usePerfProbeFlags: () => ({ disableBackgroundPolling: false }), +})); + +import { pingWithCredentials } from '@/api/subsonic'; +import { useConnectionStatus } from './useConnectionStatus'; + +beforeEach(() => { + resetAuthStore(); + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); +}); + +function seedDualAddressServer(): string { + const id = useAuthStore.getState().addServer({ + name: 'Home', + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + username: 'tester', + password: 'pw', + }); + useAuthStore.getState().setActiveServer(id); + return id; +} + +describe('useConnectionStatus.isLan', () => { + it('reports the active endpoint kind after a probe, not the primary URL kind', async () => { + seedDualAddressServer(); + // 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 => + url === 'http://192.168.0.10' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + expect(result.current.isLan).toBe(true); + }); + + it('falls back to public when only the public address answers', async () => { + seedDualAddressServer(); + vi.mocked(pingWithCredentials).mockImplementation(async url => + url === 'https://music.example.com' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + // primary url is `https://music.example.com` — public. isLanUrl alone + // would have said `false` for the wrong reason (because the primary + // happens to be public); the test is meaningful because the LAN side + // was probed first and refused, so `activeEndpointKind` actively + // reflects "public". + expect(result.current.isLan).toBe(false); + }); + + it('falls back to primary URL classification before the first probe completes', () => { + 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( + new Promise(r => { + _resolve = ((res: PickReachableResult) => { + if (res.ok) { + r({ + ok: true, + type: res.ping.type, + serverVersion: res.ping.serverVersion, + openSubsonic: res.ping.openSubsonic, + }); + } else { + r({ ok: false }); + } + }) as never; + }), + ); + + const { result } = renderHook(() => useConnectionStatus()); + // Before the probe completes, isLan reflects the primary URL — public + // here, so false. + expect(result.current.isLan).toBe(false); + }); +}); + +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 => + url === 'http://192.168.0.10' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + + // Now flip: LAN goes dark, only public answers. The 120 s tick won't + // 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 => + url === 'https://music.example.com' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + await act(async () => { + window.dispatchEvent(new Event('online')); + }); + + 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); + }); +}); diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index 4c9f99ff..5e1b07fd 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -1,31 +1,29 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useAuthStore } from '../store/authStore'; -import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; +import { scheduleInstantMixProbeForServer } from '../api/subsonic'; import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; +import { + ensureConnectUrlResolved, + invalidateReachableEndpointCache, + isLanUrl, + type ServerEndpointKind, +} from '../utils/server/serverEndpoint'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; -export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; +// Backward-compatible re-export for call sites that still import from the hook. +export { isLanUrl }; -export function isLanUrl(url: string): boolean { - try { - const hostname = new URL(url.startsWith('http') ? url : `http://${url}`).hostname; - return ( - hostname === 'localhost' || - hostname.endsWith('.local') || - /^127\./.test(hostname) || - /^10\./.test(hostname) || - /^192\.168\./.test(hostname) || - /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) - ); - } catch { - return false; - } -} +export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; export function useConnectionStatus() { const perfFlags = usePerfProbeFlags(); const [status, setStatus] = useState('checking'); const [isRetrying, setIsRetrying] = useState(false); + // Tracks the kind of endpoint the last successful probe answered on so the + // badge reflects the *active* connection, not just whatever the user typed + // as the primary URL. A LAN-tagged primary that has fallen over to its + // public alternate must read as 'public', not 'local'. + const [activeEndpointKind, setActiveEndpointKind] = useState(null); const intervalRef = useRef | null>(null); const check = useCallback(async () => { @@ -40,24 +38,35 @@ export function useConnectionStatus() { return; } - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (ping.ok) { + // Dual-address: probe LAN-first via the shared cache. On every poll the + // sticky entry is tried first; on failure the full sequence runs and the + // cache flips to whichever endpoint actually answers — so a laptop moving + // off WiFi smoothly transitions from LAN to public without a manual retry. + const probe = await ensureConnectUrlResolved(server); + if (probe.ok) { const sid = useAuthStore.getState().activeServerId; if (sid) { const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, + type: probe.ping.type, + serverVersion: probe.ping.serverVersion, + openSubsonic: probe.ping.openSubsonic, }; useAuthStore.getState().setSubsonicServerIdentity(sid, identity); - scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity); + scheduleInstantMixProbeForServer(sid, probe.baseUrl, server.username, server.password, identity); } + setActiveEndpointKind(probe.endpoint.kind); + } else { + setActiveEndpointKind(null); } - setStatus(ping.ok ? 'connected' : 'disconnected'); + setStatus(probe.ok ? 'connected' : 'disconnected'); }, []); const retry = useCallback(async () => { setIsRetrying(true); + // Manual retry: drop the sticky cache so the next probe starts in the + // natural LAN-first order instead of revalidating whatever last worked. + const sid = useAuthStore.getState().activeServerId; + if (sid) invalidateReachableEndpointCache(sid); await check(); setIsRetrying(false); }, [check]); @@ -74,7 +83,13 @@ export function useConnectionStatus() { check(); intervalRef.current = setInterval(check, 120_000); - const handleOnline = () => check(); + const handleOnline = () => { + // Network just came back — the sticky entry is from a different network + // moment and may be wrong. Flush, then re-probe LAN-first. + const sid = useAuthStore.getState().activeServerId; + if (sid) invalidateReachableEndpointCache(sid); + check(); + }; const handleOffline = () => setStatus('disconnected'); window.addEventListener('online', handleOnline); @@ -98,7 +113,16 @@ export function useConnectionStatus() { status, isRetrying, retry, - isLan: server ? isLanUrl(server.url) : false, + // Active endpoint kind preferred; until the first probe completes we + // fall back to the primary url's classification so the badge has + // *something* to render at mount time. Once a probe has resolved, + // `activeEndpointKind` is the source of truth. + isLan: + activeEndpointKind !== null + ? activeEndpointKind === 'local' + : server + ? isLanUrl(server.url) + : false, serverName, }; } diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 444c33f0..b67db267 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Server-Name', serverUrl: 'Server-URL', serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com', + serverAlternateUrl: 'Zweite Adresse (optional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Du kannst eine öffentliche Adresse hinzufügen, um außerhalb deines Heimnetzwerks zuzugreifen.', + serverAlternateUrlHintAddLocal: 'Du kannst eine lokale Adresse hinzufügen, um zu Hause schneller zu verbinden.', + serverBothLanError: 'Beide Adressen sind lokal — eine entfernen oder eine öffentliche hinzufügen.', + dualAddressVerifying: 'Prüfe, ob beide Adressen denselben Server meinen…', + dualAddressMismatch: 'Die beiden Adressen zeigen auf unterschiedliche Server. Schreibweise prüfen und erneut versuchen.', + dualAddressInsufficient: 'Konnte nicht bestätigen, dass beide Adressen derselbe Server sind. Entferne eine Adresse, um fortzufahren.', + dualAddressUnreachable: 'Konnte {{host}} nicht erreichen. Adresse und Netzwerk prüfen, dann erneut versuchen.', + urlRemigrationTitle: 'Bibliotheksdaten verschieben?', + urlRemigrationMessage: 'Beim Ändern der Adresse werden alle Bibliotheks-, Analyse- und Cover-Cache-Daten von {{oldKey}} nach {{newKey}} verschoben. Das kann nicht rückgängig gemacht werden.', + urlRemigrationConfirm: 'Daten verschieben', + urlRemigrationProgress: 'Bibliotheksdaten werden verschoben…', + urlRemigrationFailureInspect: 'Konnte nicht prüfen, was verschoben würde. Erneut versuchen oder das Debug-Log öffnen.', + urlRemigrationFailureRun: 'Verschieben der Bibliotheksdaten ist fehlgeschlagen. Deine gespeicherten Daten sind unverändert — erneut versuchen.', + urlRemigrationFailureCoverRename: 'Bibliotheksdaten wurden verschoben, aber Cover-Dateien konnten nicht umbenannt werden. Cover werden beim nächsten Zugriff neu aufgebaut.', + shareUsesLocalUrl: 'Lokale Adresse in Freigabe-Links verwenden', + shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.', serverUsername: 'Benutzername', serverPassword: 'Passwort', addServer: 'Server hinzufügen', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index eb4e42eb..440efee8 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Server Name', serverUrl: 'Server URL', serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com', + serverAlternateUrl: 'Second address (optional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'You can add a public address for access outside your home network.', + serverAlternateUrlHintAddLocal: 'You can add a local address for faster connection at home.', + serverBothLanError: 'Both addresses are local — keep one or add a public address.', + dualAddressVerifying: 'Checking that both addresses point at the same server…', + dualAddressMismatch: 'The two addresses point at different servers. Check spelling and try again.', + dualAddressInsufficient: 'Could not confirm both addresses are the same server. Remove one address to continue.', + dualAddressUnreachable: 'Could not reach {{host}}. Check the address and your network, then try again.', + urlRemigrationTitle: 'Move your library data?', + urlRemigrationMessage: 'Changing the address will move all library, analysis, and cover-cache data from {{oldKey}} to {{newKey}}. This cannot be undone.', + urlRemigrationConfirm: 'Move data', + urlRemigrationProgress: 'Moving library data…', + urlRemigrationFailureInspect: 'Could not check what would be moved. Try again, or open the debug log.', + urlRemigrationFailureRun: 'Moving the library data failed. Your saved data is unchanged — try again.', + urlRemigrationFailureCoverRename: 'Library data was moved, but cover files could not be renamed. Covers will rebuild on next access.', + shareUsesLocalUrl: 'Use local address in share links', + shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.', serverUsername: 'Username', serverPassword: 'Password', addServer: 'Add Server', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 4e1d3e25..87371d2a 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Nombre del Servidor', serverUrl: 'URL del Servidor', serverUrlPlaceholder: '192.168.1.100:4533 o https://music.example.com', + serverAlternateUrl: 'Segunda dirección (opcional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Puedes añadir una dirección pública para acceder fuera de tu red doméstica.', + serverAlternateUrlHintAddLocal: 'Puedes añadir una dirección local para una conexión más rápida en casa.', + serverBothLanError: 'Ambas direcciones son locales: conserva una o añade una pública.', + dualAddressVerifying: 'Comprobando que ambas direcciones apuntan al mismo servidor…', + dualAddressMismatch: 'Las dos direcciones apuntan a servidores distintos. Comprueba la ortografía y vuelve a intentarlo.', + dualAddressInsufficient: 'No se pudo confirmar que ambas direcciones sean el mismo servidor. Elimina una dirección para continuar.', + dualAddressUnreachable: 'No se pudo contactar con {{host}}. Comprueba la dirección y tu red, y vuelve a intentarlo.', + urlRemigrationTitle: '¿Mover los datos de la biblioteca?', + urlRemigrationMessage: 'Cambiar la dirección moverá todos los datos de biblioteca, análisis y caché de carátulas de {{oldKey}} a {{newKey}}. Esto no se puede deshacer.', + urlRemigrationConfirm: 'Mover datos', + urlRemigrationProgress: 'Moviendo los datos de la biblioteca…', + urlRemigrationFailureInspect: 'No se pudo comprobar qué se movería. Vuelve a intentarlo o abre el registro de depuración.', + urlRemigrationFailureRun: 'Fallo al mover los datos de la biblioteca. Tus datos guardados están intactos — vuelve a intentarlo.', + urlRemigrationFailureCoverRename: 'Los datos de la biblioteca se movieron, pero los archivos de carátulas no se pudieron renombrar. Las carátulas se reconstruirán en el próximo acceso.', + shareUsesLocalUrl: 'Usar la dirección local en los enlaces compartidos', + 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', addServer: 'Agregar Servidor', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index d7679552..eafaf63b 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Nom du serveur', serverUrl: 'URL du serveur', serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com', + serverAlternateUrl: 'Seconde adresse (optionnelle)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Vous pouvez ajouter une adresse publique pour y accéder en dehors de votre réseau domestique.', + serverAlternateUrlHintAddLocal: 'Vous pouvez ajouter une adresse locale pour une connexion plus rapide à domicile.', + serverBothLanError: 'Les deux adresses sont locales — conservez-en une ou ajoutez une adresse publique.', + dualAddressVerifying: 'Vérification que les deux adresses pointent vers le même serveur…', + dualAddressMismatch: 'Les deux adresses pointent vers des serveurs différents. Vérifiez l’orthographe et réessayez.', + dualAddressInsufficient: 'Impossible de confirmer que les deux adresses correspondent au même serveur. Supprimez une adresse pour continuer.', + dualAddressUnreachable: 'Impossible de joindre {{host}}. Vérifiez l’adresse et votre réseau, puis réessayez.', + urlRemigrationTitle: 'Déplacer les données de la bibliothèque ?', + urlRemigrationMessage: 'Changer l’adresse déplacera toutes les données de la bibliothèque, de l’analyse et du cache de couvertures de {{oldKey}} vers {{newKey}}. Cette action est irréversible.', + urlRemigrationConfirm: 'Déplacer les données', + urlRemigrationProgress: 'Déplacement des données de la bibliothèque…', + urlRemigrationFailureInspect: 'Impossible de vérifier ce qui serait déplacé. Réessayez ou ouvrez le journal de débogage.', + urlRemigrationFailureRun: 'Le déplacement des données de la bibliothèque a échoué. Vos données enregistrées sont intactes — réessayez.', + urlRemigrationFailureCoverRename: 'Les données de la bibliothèque ont été déplacées, mais les fichiers de couverture n’ont pas pu être renommés. Les couvertures seront reconstruites au prochain accès.', + shareUsesLocalUrl: 'Utiliser l’adresse locale dans les liens de partage', + shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.', serverUsername: 'Nom d\'utilisateur', serverPassword: 'Mot de passe', addServer: 'Ajouter un serveur', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 28ebdb5a..aa6ffa82 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Tjenernavn', serverUrl: 'Tjener-URL', serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com', + serverAlternateUrl: 'Andre adresse (valgfritt)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Du kan legge til en offentlig adresse for tilgang utenfor hjemmenettverket.', + serverAlternateUrlHintAddLocal: 'Du kan legge til en lokal adresse for raskere tilkobling hjemme.', + serverBothLanError: 'Begge adressene er lokale — behold én eller legg til en offentlig adresse.', + dualAddressVerifying: 'Sjekker at begge adressene peker til samme tjener…', + dualAddressMismatch: 'De to adressene peker til ulike tjenere. Sjekk skrivemåten og prøv igjen.', + dualAddressInsufficient: 'Kunne ikke bekrefte at begge adressene er samme tjener. Fjern én adresse for å fortsette.', + dualAddressUnreachable: 'Kunne ikke nå {{host}}. Sjekk adressen og nettverket, og prøv igjen.', + urlRemigrationTitle: 'Flytte biblioteksdataene?', + urlRemigrationMessage: 'Å endre adressen vil flytte alle biblioteks-, analyse- og omslags-buffer-data fra {{oldKey}} til {{newKey}}. Dette kan ikke angres.', + urlRemigrationConfirm: 'Flytt data', + urlRemigrationProgress: 'Flytter biblioteksdata…', + urlRemigrationFailureInspect: 'Kunne ikke sjekke hva som ville bli flyttet. Prøv igjen eller åpne feilsøkingsloggen.', + urlRemigrationFailureRun: 'Flyttingen av biblioteksdataene mislyktes. Lagrede data er uendret — prøv igjen.', + urlRemigrationFailureCoverRename: 'Biblioteksdataene ble flyttet, men omslagsfilene kunne ikke endre navn. Omslag bygges opp igjen ved neste tilgang.', + shareUsesLocalUrl: 'Bruk lokal adresse i delingslenker', + shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.', serverUsername: 'Brukernavn', serverPassword: 'Passord', addServer: 'Legg til tjener', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 485cc25e..1de9b202 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Servernaam', serverUrl: 'Server-URL', serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl', + serverAlternateUrl: 'Tweede adres (optioneel)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Voeg een openbaar adres toe om buiten je thuisnetwerk te verbinden.', + serverAlternateUrlHintAddLocal: 'Voeg een lokaal adres toe voor een snellere verbinding thuis.', + serverBothLanError: 'Beide adressen zijn lokaal — houd er één of voeg een openbaar adres toe.', + dualAddressVerifying: 'Controleren of beide adressen naar dezelfde server wijzen…', + dualAddressMismatch: 'De twee adressen wijzen naar verschillende servers. Controleer de spelling en probeer opnieuw.', + dualAddressInsufficient: 'Kon niet bevestigen dat beide adressen dezelfde server zijn. Verwijder één adres om door te gaan.', + dualAddressUnreachable: 'Kon {{host}} niet bereiken. Controleer het adres en je netwerk en probeer opnieuw.', + urlRemigrationTitle: 'Bibliotheekgegevens verplaatsen?', + urlRemigrationMessage: 'Bij het wijzigen van het adres worden alle bibliotheek-, analyse- en cover-cachegegevens van {{oldKey}} naar {{newKey}} verplaatst. Dit kan niet ongedaan gemaakt worden.', + urlRemigrationConfirm: 'Gegevens verplaatsen', + urlRemigrationProgress: 'Bibliotheekgegevens worden verplaatst…', + urlRemigrationFailureInspect: 'Kon niet controleren wat verplaatst zou worden. Probeer opnieuw of open het foutopsporingslogboek.', + urlRemigrationFailureRun: 'Verplaatsen van bibliotheekgegevens mislukt. Je opgeslagen gegevens zijn ongewijzigd — probeer opnieuw.', + urlRemigrationFailureCoverRename: 'Bibliotheekgegevens zijn verplaatst, maar coverbestanden konden niet worden hernoemd. Covers worden bij het volgende gebruik opnieuw opgebouwd.', + shareUsesLocalUrl: 'Lokaal adres gebruiken in deellinks', + shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.', serverUsername: 'Gebruikersnaam', serverPassword: 'Wachtwoord', addServer: 'Server toevoegen', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 154b8be5..7706d887 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Nume Server', serverUrl: 'URL Server', serverUrlPlaceholder: '192.168.1.100:4533 sau https://music.example.com', + serverAlternateUrl: 'A doua adresă (opțional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Poți adăuga o adresă publică pentru a te conecta din afara rețelei tale de acasă.', + serverAlternateUrlHintAddLocal: 'Poți adăuga o adresă locală pentru o conexiune mai rapidă acasă.', + serverBothLanError: 'Ambele adrese sunt locale — păstrează una sau adaugă o adresă publică.', + dualAddressVerifying: 'Verificăm dacă ambele adrese duc la același server…', + dualAddressMismatch: 'Cele două adrese duc la servere diferite. Verifică ortografia și încearcă din nou.', + dualAddressInsufficient: 'Nu am putut confirma că ambele adrese sunt același server. Elimină o adresă pentru a continua.', + dualAddressUnreachable: 'Nu am putut contacta {{host}}. Verifică adresa și rețeaua și încearcă din nou.', + urlRemigrationTitle: 'Muți datele bibliotecii?', + urlRemigrationMessage: 'Schimbarea adresei va muta toate datele bibliotecii, analizei și ale cache-ului de coperți de la {{oldKey}} la {{newKey}}. Această acțiune nu poate fi anulată.', + urlRemigrationConfirm: 'Mută datele', + urlRemigrationProgress: 'Mutăm datele bibliotecii…', + urlRemigrationFailureInspect: 'Nu am putut verifica ce ar fi mutat. Încearcă din nou sau deschide jurnalul de depanare.', + urlRemigrationFailureRun: 'Mutarea datelor bibliotecii a eșuat. Datele salvate sunt neschimbate — încearcă din nou.', + urlRemigrationFailureCoverRename: 'Datele bibliotecii au fost mutate, dar fișierele de copertă nu au putut fi redenumite. Coperțile vor fi reconstruite la următorul acces.', + shareUsesLocalUrl: 'Folosește adresa locală în linkurile de partajare', + shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.', serverUsername: 'Nume utilizator', serverPassword: 'Parolă', addServer: 'Adaugă Server', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 689e19be..91494eb9 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Название', serverUrl: 'Адрес', serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com', + serverAlternateUrl: 'Второй адрес (необязательно)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Добавьте публичный адрес, чтобы подключаться вне домашней сети.', + serverAlternateUrlHintAddLocal: 'Добавьте локальный адрес для более быстрой связи дома.', + serverBothLanError: 'Оба адреса локальные — оставьте один или добавьте публичный.', + dualAddressVerifying: 'Проверяем, что оба адреса указывают на один и тот же сервер…', + dualAddressMismatch: 'Адреса ведут к разным серверам. Проверьте написание и попробуйте снова.', + dualAddressInsufficient: 'Не удалось убедиться, что это один сервер. Удалите один адрес, чтобы продолжить.', + dualAddressUnreachable: 'Не удалось достучаться до {{host}}. Проверьте адрес и сеть, затем попробуйте снова.', + urlRemigrationTitle: 'Переместить данные библиотеки?', + urlRemigrationMessage: 'Смена адреса перенесёт все данные библиотеки, анализа и кэша обложек с {{oldKey}} на {{newKey}}. Это нельзя отменить.', + urlRemigrationConfirm: 'Переместить данные', + urlRemigrationProgress: 'Перемещаем данные библиотеки…', + urlRemigrationFailureInspect: 'Не удалось проверить, что нужно перенести. Попробуйте снова или откройте журнал отладки.', + urlRemigrationFailureRun: 'Не удалось перенести данные библиотеки. Сохранённые данные не изменены — попробуйте снова.', + urlRemigrationFailureCoverRename: 'Данные библиотеки перенесены, но файлы обложек не удалось переименовать. Обложки восстановятся при следующем обращении.', + shareUsesLocalUrl: 'Использовать локальный адрес в ссылках общего доступа', + shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.', serverUsername: 'Логин', serverPassword: 'Пароль', addServer: 'Добавить сервер', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index bc1f6261..704b73ee 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: '服务器名称', serverUrl: '服务器地址', serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com', + serverAlternateUrl: '备用地址(可选)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: '可以添加一个公共地址,便于在家庭网络之外访问。', + serverAlternateUrlHintAddLocal: '可以添加一个本地地址,在家中连接更快。', + serverBothLanError: '两个地址都是本地地址——保留一个或添加一个公共地址。', + dualAddressVerifying: '正在确认两个地址指向同一台服务器…', + dualAddressMismatch: '两个地址指向不同的服务器。请检查拼写后重试。', + dualAddressInsufficient: '无法确认两个地址是同一台服务器。请删除一个地址以继续。', + dualAddressUnreachable: '无法连接到 {{host}}。请检查地址和网络后重试。', + urlRemigrationTitle: '移动你的资料库数据?', + urlRemigrationMessage: '更改地址将把所有资料库、分析和封面缓存数据从 {{oldKey}} 移动到 {{newKey}}。此操作无法撤销。', + urlRemigrationConfirm: '移动数据', + urlRemigrationProgress: '正在移动资料库数据…', + urlRemigrationFailureInspect: '无法检查将要移动的内容。请重试或打开调试日志。', + urlRemigrationFailureRun: '资料库数据移动失败。已保存的数据保持不变——请重试。', + urlRemigrationFailureCoverRename: '资料库数据已移动,但封面文件无法重命名。封面将在下次访问时重新生成。', + shareUsesLocalUrl: '在分享链接中使用本地地址', + shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。', serverUsername: '用户名', serverPassword: '密码', addServer: '添加服务器', diff --git a/src/pages/Login.test.tsx b/src/pages/Login.test.tsx new file mode 100644 index 00000000..109e5f85 --- /dev/null +++ b/src/pages/Login.test.tsx @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { resetAuthStore } from '@/test/helpers/storeReset'; +import { useAuthStore } from '@/store/authStore'; +import { encodeServerMagicString } from '@/utils/server/serverMagicString'; + +vi.mock('@/api/subsonic', () => ({ + pingWithCredentials: vi.fn(async () => ({ + ok: true, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + })), + scheduleInstantMixProbeForServer: vi.fn(), +})); + +import { pingWithCredentials } from '@/api/subsonic'; + +beforeEach(() => { + resetAuthStore(); + vi.mocked(pingWithCredentials).mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Login — v2 magic string paste persistence', () => { + it('persists alternateUrl + shareUsesLocalUrl from a pasted v2 invite', async () => { + const Login = (await import('./Login')).default; + renderWithProviders(); + const user = userEvent.setup(); + + const magicString = encodeServerMagicString({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + shareUsesLocalUrl: true, + username: 'tester', + password: 'pw', + }); + + // Find the magic-string input by its known label/placeholder. The login + // form has multiple inputs; we use the displayed-value-after-paste path + // to keep the test stable across UI label tweaks. + const allTextboxes = screen.getAllByRole('textbox'); + // The magic-string input is the one whose change handler decodes and + // prefills the rest — paste into it and the form auto-populates. + const magicInput = allTextboxes[allTextboxes.length - 1]!; + await user.type(magicInput, magicString); + + // Submit the form via the Connect button (label "Connect" in en locale). + const submit = screen.getByRole('button', { name: /connect/i }); + await user.click(submit); + + await waitFor(() => { + expect(useAuthStore.getState().servers.length).toBeGreaterThan(0); + }); + + const saved = useAuthStore.getState().servers[0]!; + expect(saved.url).toBe('https://music.example.com'); + expect(saved.alternateUrl).toBe('http://192.168.0.10:4533'); + expect(saved.shareUsesLocalUrl).toBe(true); + expect(saved.username).toBe('tester'); + }); + + it('persists a v1 invite as a single-address profile (no alternateUrl)', async () => { + const Login = (await import('./Login')).default; + renderWithProviders(); + const user = userEvent.setup(); + + const v1MagicString = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'tester', + password: 'pw', + }); + + const allTextboxes = screen.getAllByRole('textbox'); + const magicInput = allTextboxes[allTextboxes.length - 1]!; + await user.type(magicInput, v1MagicString); + + const submit = screen.getByRole('button', { name: /connect/i }); + await user.click(submit); + + await waitFor(() => { + expect(useAuthStore.getState().servers.length).toBeGreaterThan(0); + }); + + const saved = useAuthStore.getState().servers[0]!; + expect(saved.url).toBe('https://music.example.com'); + // v1 invite — neither dual-address field should appear on the saved + // profile so localStorage doesn't carry dangling defaults. + expect(saved.alternateUrl).toBeUndefined(); + expect(saved.shareUsesLocalUrl).toBeUndefined(); + }); +}); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 173a4c42..dcdce10c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -24,7 +24,18 @@ export default function Login() { const { t } = useTranslation(); const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore(); - const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' }); + // alternateUrl / shareUsesLocalUrl are not user-editable on this page (Login + // stays single-address by design); they're populated only when a v2 magic + // string is decoded so the dual-address shape persists straight from the + // invite onto the saved profile. + const [form, setForm] = useState({ + serverName: '', + url: '', + username: '', + password: '', + alternateUrl: '' as string, + shareUsesLocalUrl: false, + }); const [magicString, setMagicString] = useState(''); const [showPass, setShowPass] = useState(false); /** After a valid magic string decode, do not allow revealing the password in the UI. */ @@ -42,6 +53,8 @@ export default function Login() { url: inv.url, username: inv.username, password: inv.password, + alternateUrl: inv.alternateUrl ?? '', + shareUsesLocalUrl: inv.shareUsesLocalUrl ?? false, }); setMagicString(encodeServerMagicString(inv)); navigate('/login', { replace: true, state: {} }); @@ -63,6 +76,8 @@ export default function Login() { url: decoded.url, username: decoded.username, password: decoded.password, + alternateUrl: decoded.alternateUrl ?? '', + shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false, }); if (status === 'error') { setStatus('idle'); @@ -71,7 +86,14 @@ export default function Login() { } }; - const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => { + const attemptConnect = async (profile: { + name: string; + url: string; + username: string; + password: string; + alternateUrl?: string; + shareUsesLocalUrl?: boolean; + }) => { if (!profile.url.trim()) { setTestMessage(t('login.urlRequired')); setStatus('error'); @@ -97,11 +119,21 @@ export default function Login() { if (ping.ok) { // Connection succeeded — now persist to store const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim()); + // Dual-address fields persist straight off a v2 magic invite even + // though Login itself never shows the second-address field. The + // user can edit/remove them later via Settings → Servers. + const altTrimmed = profile.alternateUrl?.trim() ?? ''; let serverId: string; if (existing) { updateServer(existing.id, { name: profile.name.trim() || profile.url.trim(), password: profile.password, + ...(altTrimmed + ? { + alternateUrl: altTrimmed, + shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false, + } + : {}), }); serverId = existing.id; } else { @@ -110,6 +142,12 @@ export default function Login() { url: profile.url.trim(), username: profile.username.trim(), password: profile.password, + ...(altTrimmed + ? { + alternateUrl: altTrimmed, + shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false, + } + : {}), }); } const identity = { @@ -152,18 +190,41 @@ export default function Login() { url: decoded.url, username: decoded.username, password: decoded.password, + alternateUrl: decoded.alternateUrl, + shareUsesLocalUrl: decoded.shareUsesLocalUrl, }); return; } - await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password }); + await attemptConnect({ + name: form.serverName, + url: form.url, + username: form.username, + password: form.password, + alternateUrl: form.alternateUrl, + shareUsesLocalUrl: form.shareUsesLocalUrl, + }); }; const handleQuickConnect = async (srv: typeof servers[0]) => { setMagicString(''); setBlockPasswordReveal(false); setShowPass(false); - setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password }); - await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password }); + setForm({ + serverName: srv.name, + url: srv.url, + username: srv.username, + password: srv.password, + alternateUrl: srv.alternateUrl ?? '', + shareUsesLocalUrl: srv.shareUsesLocalUrl ?? false, + }); + await attemptConnect({ + name: srv.name, + url: srv.url, + username: srv.username, + password: srv.password, + alternateUrl: srv.alternateUrl, + shareUsesLocalUrl: srv.shareUsesLocalUrl, + }); }; return ( diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 4e0dbcfb..16d52e2a 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -21,6 +21,8 @@ import { } from './authStoreDefaults'; import { computeAuthStoreRehydration } from './authStoreRehydrate'; import type { AuthState } from './authStoreTypes'; +import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint'; +import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl'; @@ -142,8 +144,13 @@ export const useAuthStore = create()( const s = get(); const server = s.servers.find(srv => srv.id === s.activeServerId); if (!server?.url) return ''; - const base = server.url.startsWith('http') ? server.url : `http://${server.url}`; - return base.replace(/\/$/, ''); + // Dual-address: read the runtime-probed connect URL from the + // serverEndpoint cache. `null` (no probe yet — first boot, switch + // happening right now) falls back to the normalized primary URL so + // callers running before the first probe still get a usable base. + const cached = getCachedConnectBaseUrl(server.id); + if (cached) return cached; + return serverProfileBaseUrl({ url: server.url }); }, getActiveServer: () => { diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 3701c7a3..6d840a26 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -7,7 +7,25 @@ import type { export interface ServerProfile { id: string; name: string; + /** + * Primary address. **Canonical source of the index key** — adding or changing + * `alternateUrl` never touches library/cover/analysis storage. Only editing + * `url` (host/port/path) triggers an index-key remigration. + */ url: string; + /** + * Optional second address (typically a LAN counterpart of a public `url`, or + * vice versa). Used by the connect layer as a sequential fallback and by the + * share layer when `shareUsesLocalUrl` flips. Never participates in the index + * key. + */ + alternateUrl?: string; + /** + * When both `url` and `alternateUrl` are set, controls which one is embedded + * in Orbit / entity / magic-string shares. Default behaviour (absent / false) + * is to prefer the **public** address. + */ + shareUsesLocalUrl?: boolean; username: string; password: string; } diff --git a/src/utils/library/librarySession.ts b/src/utils/library/librarySession.ts index 71d4a424..e0560f6e 100644 --- a/src/utils/library/librarySession.ts +++ b/src/utils/library/librarySession.ts @@ -3,11 +3,10 @@ import { librarySyncBindSession, } from '../../api/library'; import { enqueueLibrarySync, queueInitialSyncIfNeeded } from './librarySyncQueue'; -import { pingWithCredentials } from '../../api/subsonic'; import type { ServerProfile } from '../../store/authStoreTypes'; import { useAuthStore } from '../../store/authStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; -import { serverProfileBaseUrl } from '../server/serverBaseUrl'; +import { ensureConnectUrlResolved } from '../server/serverEndpoint'; import { serverIndexKeyForProfile } from '../server/serverIndexKey'; import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog'; @@ -18,15 +17,14 @@ export type BindServerResult = 'bound' | 'offline' | 'error'; */ export async function bindIndexedServer(server: ServerProfile): Promise { if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return 'error'; - const baseUrl = serverProfileBaseUrl(server); - if (!baseUrl) return 'error'; - try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (!ping.ok) return 'offline'; - } catch { - return 'offline'; - } + // Dual-address: resolve the connect URL once (LAN-first, sticky cached) and + // hand that to the Rust bind-session command — Rust then sees the reachable + // endpoint instead of the literal primary URL. Single-address profiles fall + // through to one ping, identical to the legacy path. + const probe = await ensureConnectUrlResolved(server); + if (!probe.ok) return 'offline'; + const baseUrl = probe.baseUrl; try { const t0 = performance.now(); diff --git a/src/utils/server/rewriteFrontendStoreKeys.test.ts b/src/utils/server/rewriteFrontendStoreKeys.test.ts new file mode 100644 index 00000000..65f967f5 --- /dev/null +++ b/src/utils/server/rewriteFrontendStoreKeys.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; +import { useCoverStrategyStore } from '../../store/coverStrategyStore'; +import { useHotCacheStore } from '../../store/hotCacheStore'; +import { useOfflineStore } from '../../store/offlineStore'; +import { usePlayerStore } from '../../store/playerStore'; +import { rewriteFrontendStoreKeysForRemap } from './rewriteFrontendStoreKeys'; + +describe('rewriteFrontendStoreKeysForRemap', () => { + beforeEach(() => { + useOfflineStore.setState({ tracks: {}, albums: {} }); + useHotCacheStore.setState({ entries: {} }); + useAnalysisStrategyStore.setState({ + strategyByServer: {}, + advancedParallelismByServer: {}, + }); + useCoverStrategyStore.setState({ strategyByServer: {} }); + usePlayerStore.setState({ queueServerId: null }); + }); + + it('no-ops on empty remap list', async () => { + useOfflineStore.setState({ + tracks: { 'old:t1': { serverId: 'old' } as never }, + albums: {}, + }); + await rewriteFrontendStoreKeysForRemap([]); + expect(useOfflineStore.getState().tracks).toHaveProperty('old:t1'); + }); + + it('no-ops when oldKey === newKey', async () => { + useOfflineStore.setState({ + tracks: { 'same:t1': { serverId: 'same' } as never }, + albums: {}, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'same', newKey: 'same' }]); + expect(useOfflineStore.getState().tracks).toHaveProperty('same:t1'); + }); + + it('rewrites offline tracks + albums under the new key', async () => { + useOfflineStore.setState({ + tracks: { 'old:t1': { serverId: 'old' } as never }, + albums: { 'old:al-1': { serverId: 'old' } as never }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const state = useOfflineStore.getState(); + expect(state.tracks).toHaveProperty('new:t1'); + expect(state.tracks).not.toHaveProperty('old:t1'); + expect(state.albums).toHaveProperty('new:al-1'); + expect(state.albums).not.toHaveProperty('old:al-1'); + }); + + it('rewrites hot-cache entries under the new key', async () => { + useHotCacheStore.setState({ + entries: { 'old:t1': { trackId: 't1' } as never }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const entries = useHotCacheStore.getState().entries; + expect(entries).toHaveProperty('new:t1'); + expect(entries).not.toHaveProperty('old:t1'); + }); + + it('moves analysis strategy + advanced-parallelism entries to the new key', async () => { + useAnalysisStrategyStore.setState({ + strategyByServer: { old: 'lazy' as never }, + advancedParallelismByServer: { old: 3 }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const s = useAnalysisStrategyStore.getState(); + expect(s.strategyByServer).toHaveProperty('new'); + expect(s.strategyByServer).not.toHaveProperty('old'); + expect(s.advancedParallelismByServer.new).toBe(3); + expect(s.advancedParallelismByServer.old).toBeUndefined(); + }); + + it('moves cover strategy entries to the new key', async () => { + useCoverStrategyStore.setState({ + strategyByServer: { old: 'aggressive' as never }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const s = useCoverStrategyStore.getState(); + expect(s.strategyByServer).toHaveProperty('new'); + expect(s.strategyByServer).not.toHaveProperty('old'); + }); + + it('repoints player queueServerId when it matches the old key', async () => { + usePlayerStore.setState({ queueServerId: 'old' }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + expect(usePlayerStore.getState().queueServerId).toBe('new'); + }); + + it('leaves queueServerId untouched when it is bound to a different server', async () => { + usePlayerStore.setState({ queueServerId: 'other' }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + expect(usePlayerStore.getState().queueServerId).toBe('other'); + }); + + it('does not clobber an existing entry under the new key', async () => { + useOfflineStore.setState({ + tracks: { + 'old:t1': { serverId: 'old', tag: 'from-old' } as never, + 'new:t1': { serverId: 'new', tag: 'from-new' } as never, + }, + albums: {}, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const tracks = useOfflineStore.getState().tracks as unknown as Record; + // Existing destination preserved — same prefer-existing semantics as + // the disk-side cover bucket merge. + expect(tracks['new:t1']?.tag).toBe('from-new'); + expect(tracks).not.toHaveProperty('old:t1'); + }); +}); diff --git a/src/utils/server/rewriteFrontendStoreKeys.ts b/src/utils/server/rewriteFrontendStoreKeys.ts index db8ab45f..9c3b5e7f 100644 --- a/src/utils/server/rewriteFrontendStoreKeys.ts +++ b/src/utils/server/rewriteFrontendStoreKeys.ts @@ -4,8 +4,15 @@ import { useCoverStrategyStore } from '../../store/coverStrategyStore'; import { useHotCacheStore } from '../../store/hotCacheStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { useOfflineStore } from '../../store/offlineStore'; +import { usePlayerStore } from '../../store/playerStore'; import { serverIndexKeyFromUrl } from './serverIndexKey'; +/** + * One `legacyId → indexKey` rewrite step. `legacyId` is whatever the keys + * used to be tagged with — the historical name reflects the very first + * migration (UUID → index key), but the same plumbing now also covers the + * URL-change remigration (oldKey → newKey). + */ type Mapping = { legacyId: string; indexKey: string }; function buildMappings(servers: ServerProfile[]): Mapping[] { @@ -111,3 +118,74 @@ export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promis useCoverStrategyStore.getState().migrateServerOverrides(servers); useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled })); } + +/** + * URL-change remigration entry point: rewrites every front-end keyed store + * for one or more explicit `oldKey → newKey` index-key remaps. Used after + * `migration_run` has re-tagged the SQLite tables (library + analysis) and + * `cover_cache_rename_server_bucket` has moved the disk bucket — without + * this step the in-memory zustand state would still point at the old keys. + * + * Player queue `queueServerId` is included here: if the queue is currently + * bound to a remapped index key, it gets re-pointed at the new one so the + * queue keeps playing through the rename instead of looking unbound. + */ +export async function rewriteFrontendStoreKeysForRemap( + remaps: ReadonlyArray<{ oldKey: string; newKey: string }>, +): Promise { + const mappings: Mapping[] = remaps + .map(r => ({ legacyId: r.oldKey.trim(), indexKey: r.newKey.trim() })) + .filter(m => m.legacyId.length > 0 && m.indexKey.length > 0 && m.legacyId !== m.indexKey); + if (mappings.length === 0) return; + + rewriteOfflineStoreKeys(mappings); + rewriteHotCacheStoreKeys(mappings); + rewriteAnalysisStrategyStoreKeys(mappings); + + // Player queue: queueServerId is a single string, not a keyed map. + const queueRemap = new Map(mappings.map(m => [m.legacyId, m.indexKey])); + usePlayerStore.setState(state => { + if (!state.queueServerId) return state; + const next = queueRemap.get(state.queueServerId); + if (!next) return state; + return { ...state, queueServerId: next }; + }); + + // The analysis/cover strategy stores carry per-server-id maps that the + // `migrateServerOverrides` helpers already handle for the UUID→indexKey + // case; for index-key→index-key we run the same map-remap path inline. + useAnalysisStrategyStore.setState(state => { + const strategyByServer = { ...state.strategyByServer }; + const advancedParallelismByServer = { ...state.advancedParallelismByServer }; + for (const { legacyId, indexKey } of mappings) { + if (strategyByServer[legacyId] !== undefined && strategyByServer[indexKey] === undefined) { + strategyByServer[indexKey] = strategyByServer[legacyId]; + } + delete strategyByServer[legacyId]; + if ( + advancedParallelismByServer[legacyId] !== undefined && + advancedParallelismByServer[indexKey] === undefined + ) { + advancedParallelismByServer[indexKey] = advancedParallelismByServer[legacyId]; + } + delete advancedParallelismByServer[legacyId]; + } + return { strategyByServer, advancedParallelismByServer }; + }); + + // Cover strategy overrides are keyed by the same index key — spec §8.2 + // lists "analysis/cover strategy maps", so remap both. Without this a + // user-set cover strategy on the old key drops silently on URL edit. + useCoverStrategyStore.setState(state => { + const strategyByServer = { ...state.strategyByServer }; + for (const { legacyId, indexKey } of mappings) { + if (strategyByServer[legacyId] !== undefined && strategyByServer[indexKey] === undefined) { + strategyByServer[indexKey] = strategyByServer[legacyId]; + } + delete strategyByServer[legacyId]; + } + return { strategyByServer }; + }); + + useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled })); +} diff --git a/src/utils/server/serverEndpoint.test.ts b/src/utils/server/serverEndpoint.test.ts new file mode 100644 index 00000000..6bdc7385 --- /dev/null +++ b/src/utils/server/serverEndpoint.test.ts @@ -0,0 +1,462 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../api/subsonic', () => ({ + pingWithCredentials: vi.fn(), +})); + +import { pingWithCredentials } from '../../api/subsonic'; +import { + allNormalizedAddresses, + ensureConnectUrlResolved, + getCachedConnectBaseUrl, + invalidateReachableEndpointCache, + isLanUrl, + normalizeServerBaseUrl, + pickReachableBaseUrl, + serverAddressEndpoints, + serverShareBaseUrl, +} from './serverEndpoint'; +import type { ServerProfile } from '../../store/authStoreTypes'; + +function makeProfile(overrides: Partial = {}): ServerProfile { + return { + id: 'profile-1', + name: 'Test', + url: 'https://music.example.com', + username: 'u', + password: 'p', + ...overrides, + }; +} + +function pingOk(overrides: Partial<{ type: string; serverVersion: string; openSubsonic: boolean }> = {}) { + return { + ok: true as const, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + ...overrides, + }; +} +function pingFail() { + return { ok: false as const }; +} + +describe('normalizeServerBaseUrl', () => { + it('strips a single trailing slash', () => { + expect(normalizeServerBaseUrl('https://music.example.com/')).toBe( + 'https://music.example.com', + ); + }); + + it('prefixes http:// for a bare host', () => { + expect(normalizeServerBaseUrl('music.example.com')).toBe('http://music.example.com'); + }); + + it('returns empty for empty input', () => { + expect(normalizeServerBaseUrl('')).toBe(''); + }); +}); + +describe('isLanUrl — IPv4', () => { + it.each([ + 'http://localhost', + 'http://localhost:4533', + 'http://musicbox.local', + 'http://127.0.0.1', + 'http://127.5.6.7', + 'http://10.0.0.5', + 'http://192.168.1.10', + 'http://172.16.0.1', + 'http://172.31.255.255', + ])('classifies %s as LAN', url => { + expect(isLanUrl(url)).toBe(true); + }); + + it.each([ + 'http://172.15.0.1', + 'http://172.32.0.1', + 'https://example.com', + 'https://music.example.com', + 'http://8.8.8.8', + ])('classifies %s as public', url => { + expect(isLanUrl(url)).toBe(false); + }); +}); + +describe('isLanUrl — IPv6', () => { + it.each([ + 'http://[::1]', + 'http://[::1]:4533', + 'http://[fe80::1]', + 'http://[fe80::abcd:1]', + 'http://[fc00::1]', + 'http://[fd12:3456:789a::1]', + 'http://[::ffff:127.0.0.1]', + 'http://[::ffff:192.168.0.1]', + ])('classifies %s as LAN', url => { + expect(isLanUrl(url)).toBe(true); + }); + + it.each([ + 'http://[2001:db8::1]', + 'http://[::ffff:8.8.8.8]', + 'http://[2606:4700:4700::1111]', + ])('classifies %s as public', url => { + expect(isLanUrl(url)).toBe(false); + }); +}); + +describe('isLanUrl — edge cases', () => { + it('handles bare hosts without scheme', () => { + expect(isLanUrl('192.168.0.1')).toBe(true); + expect(isLanUrl('example.com')).toBe(false); + }); + + it('returns false on empty / malformed', () => { + expect(isLanUrl('')).toBe(false); + expect(isLanUrl('not a url at all ')).toBe(false); + }); +}); + +describe('allNormalizedAddresses', () => { + it('returns single entry for profile with only url', () => { + expect( + allNormalizedAddresses({ url: 'https://music.example.com' }), + ).toEqual(['https://music.example.com']); + }); + + it('returns both addresses preserving order', () => { + expect( + allNormalizedAddresses({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + }), + ).toEqual(['https://music.example.com', 'http://192.168.0.10:4533']); + }); + + it('dedupes identical normalized addresses', () => { + expect( + allNormalizedAddresses({ + url: 'https://music.example.com/', + alternateUrl: 'https://music.example.com', + }), + ).toEqual(['https://music.example.com']); + }); + + it('drops empty alternateUrl', () => { + expect( + allNormalizedAddresses({ + url: 'https://music.example.com', + alternateUrl: '', + }), + ).toEqual(['https://music.example.com']); + }); +}); + +describe('serverAddressEndpoints', () => { + it('returns a single local endpoint for a LAN-only profile', () => { + expect( + serverAddressEndpoints({ url: 'http://192.168.0.10' }), + ).toEqual([{ url: 'http://192.168.0.10', kind: 'local' }]); + }); + + it('puts LAN before public when public is primary', () => { + expect( + serverAddressEndpoints({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ).toEqual([ + { url: 'http://192.168.0.10', kind: 'local' }, + { url: 'https://music.example.com', kind: 'public' }, + ]); + }); + + it('keeps LAN-first when LAN is already primary', () => { + expect( + serverAddressEndpoints({ + url: 'http://192.168.0.10', + alternateUrl: 'https://music.example.com', + }), + ).toEqual([ + { url: 'http://192.168.0.10', kind: 'local' }, + { url: 'https://music.example.com', kind: 'public' }, + ]); + }); + + it('preserves original order among endpoints of the same kind', () => { + expect( + serverAddressEndpoints({ + url: 'http://10.0.0.5', + alternateUrl: 'http://192.168.0.10', + }), + ).toEqual([ + { url: 'http://10.0.0.5', kind: 'local' }, + { url: 'http://192.168.0.10', kind: 'local' }, + ]); + }); +}); + +describe('pickReachableBaseUrl', () => { + beforeEach(() => { + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); + }); + + it('returns the single endpoint when it pings ok and caches it', async () => { + vi.mocked(pingWithCredentials).mockResolvedValue(pingOk()); + const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.baseUrl).toBe('https://music.example.com'); + expect(result.endpoint).toEqual({ url: 'https://music.example.com', kind: 'public' }); + expect(result.ping.ok).toBe(true); + expect(result.ping.type).toBe('navidrome'); + } + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + }); + + it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => { + vi.mocked(pingWithCredentials).mockResolvedValue(pingOk()); + const result = await pickReachableBaseUrl( + makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ); + 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'); + }); + + it('falls through to the public endpoint when LAN ping fails', async () => { + vi.mocked(pingWithCredentials) + .mockResolvedValueOnce(pingFail()) + .mockResolvedValueOnce(pingOk()); + const result = await pickReachableBaseUrl( + makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); + expect(pingWithCredentials).toHaveBeenCalledTimes(2); + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + }); + + it('returns unreachable and clears cache when every endpoint fails', async () => { + vi.mocked(pingWithCredentials).mockResolvedValue(pingFail()); + // Seed a stale cache entry first. + vi.mocked(pingWithCredentials).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()); + const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + expect(getCachedConnectBaseUrl('profile-1')).toBeNull(); + }); + + 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(); + }); + + it('tries the cached endpoint first on subsequent calls (sticky)', async () => { + const profile = makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + // First call: LAN responds ok, becomes cached. + vi.mocked(pingWithCredentials).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()); + 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'); + }); + + it('dedupes concurrent calls for the same profile (single shared probe)', async () => { + // Two callers in the same tick must observe the same promise — without + // the in-flight map both would ping every endpoint and race on the + // 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( + new Promise(r => { + resolvePing = r; + }), + ); + const profile = makeProfile({ url: 'http://192.168.0.10' }); + const p1 = pickReachableBaseUrl(profile); + const p2 = pickReachableBaseUrl(profile); + + // Both calls saw a pending probe — only one ping should have been fired. + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + + resolvePing!(pingOk()); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.ok).toBe(true); + expect(r2.ok).toBe(true); + if (r1.ok && r2.ok) { + expect(r1.baseUrl).toBe(r2.baseUrl); + } + expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); + }); + + 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()); + await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); + + vi.mocked(pingWithCredentials).mockClear(); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + }); + + it('falls back to the natural order if the cached endpoint stops answering', async () => { + const profile = makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + vi.mocked(pingWithCredentials).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(pingWithCredentials) + .mockResolvedValueOnce(pingFail()) + .mockResolvedValueOnce(pingOk()); + const result = await pickReachableBaseUrl(profile); + expect(result.ok).toBe(true); + if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + }); +}); + +describe('invalidateReachableEndpointCache', () => { + beforeEach(() => { + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); + }); + + it('clears a specific profile', async () => { + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await ensureConnectUrlResolved(makeProfile({ id: 'a' })); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await ensureConnectUrlResolved(makeProfile({ id: 'b' })); + expect(getCachedConnectBaseUrl('a')).not.toBeNull(); + expect(getCachedConnectBaseUrl('b')).not.toBeNull(); + + invalidateReachableEndpointCache('a'); + expect(getCachedConnectBaseUrl('a')).toBeNull(); + expect(getCachedConnectBaseUrl('b')).not.toBeNull(); + }); + + it('clears everything when called with no argument', async () => { + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await ensureConnectUrlResolved(makeProfile({ id: 'a' })); + invalidateReachableEndpointCache(); + expect(getCachedConnectBaseUrl('a')).toBeNull(); + }); +}); + +describe('serverShareBaseUrl', () => { + it('returns the single address for a single-URL profile', () => { + expect(serverShareBaseUrl({ url: 'https://music.example.com' })).toBe( + 'https://music.example.com', + ); + }); + + it('falls back to a normalized url even when empty', () => { + // Defensive — never throws; downstream consumers tolerate the empty string. + expect(serverShareBaseUrl({ url: '' })).toBe(''); + }); + + it('prefers the public address by default when both are set', () => { + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ).toBe('https://music.example.com'); + }); + + it('still prefers public when the LAN address is the primary', () => { + expect( + serverShareBaseUrl({ + url: 'http://192.168.0.10', + alternateUrl: 'https://music.example.com', + }), + ).toBe('https://music.example.com'); + }); + + it('returns the LAN address when shareUsesLocalUrl is true', () => { + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + shareUsesLocalUrl: true, + }), + ).toBe('http://192.168.0.10'); + }); + + it('falls back to the first endpoint when no LAN exists and flag is set', () => { + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'https://music-alt.example.com', + shareUsesLocalUrl: true, + }), + ).toBe('https://music.example.com'); + }); + + it('falls back to the first endpoint when both are LAN and flag is off', () => { + expect( + serverShareBaseUrl({ + url: 'http://10.0.0.5', + alternateUrl: 'http://192.168.0.10', + }), + ).toBe('http://10.0.0.5'); + }); + + it('returns the first LAN endpoint when both are LAN and flag is on', () => { + // Two LAN addresses + flag set: spec §5.1 says "local ?? endpoints[0]". + // `find(isLanUrl)` returns the first LAN, which is endpoints[0] either + // way — pin the test so future refactors don't accidentally drift. + expect( + serverShareBaseUrl({ + url: 'http://10.0.0.5', + alternateUrl: 'http://192.168.0.10', + shareUsesLocalUrl: true, + }), + ).toBe('http://10.0.0.5'); + }); + + it('returns the first endpoint when both are public and flag is off', () => { + // Reverse case: two publics, no LAN exists, flag default → publicEndpoint + // matches the first one. Identity, but locks the rule down. + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'https://backup.example.com', + }), + ).toBe('https://music.example.com'); + }); +}); diff --git a/src/utils/server/serverEndpoint.ts b/src/utils/server/serverEndpoint.ts new file mode 100644 index 00000000..bc541474 --- /dev/null +++ b/src/utils/server/serverEndpoint.ts @@ -0,0 +1,282 @@ +import { pingWithCredentials } from '../../api/subsonic'; +import type { PingWithCredentialsResult } from '../../api/subsonicTypes'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { serverProfileBaseUrl } from './serverBaseUrl'; + +export type ServerEndpointKind = 'local' | 'public'; + +export type ServerEndpoint = { + /** Normalized base URL, no trailing slash. */ + url: string; + kind: ServerEndpointKind; +}; + +export type PickReachableResult = + | { + ok: true; + baseUrl: string; + endpoint: ServerEndpoint; + /** + * The successful ping response — exposed so callers like + * `switchActiveServer` don't need to issue a second `pingWithCredentials` + * just to read `type` / `serverVersion` / `openSubsonic`. + */ + ping: PingWithCredentialsResult; + } + | { ok: false; reason: 'unreachable' }; + +/** + * Aligned with `serverProfileBaseUrl` so connect / share / index helpers all + * agree on the canonical form of an address (`http://` default, no trailing + * slash). Exposed separately so non-profile-shaped callers can normalize a + * raw string. + */ +export function normalizeServerBaseUrl(raw: string): string { + return serverProfileBaseUrl({ url: raw }); +} + +function isIpv4LanLiteral(ip: string): boolean { + return ( + /^127\./.test(ip) || + /^10\./.test(ip) || + /^192\.168\./.test(ip) || + /^172\.(1[6-9]|2\d|3[01])\./.test(ip) + ); +} + +function isIpv6LanHostname(hostname: string): boolean { + if (hostname === '::1') return true; + // fe80::/10 — link-local (first 10 bits 1111 1110 10..) + if (/^fe[89ab][0-9a-f]:/.test(hostname)) return true; + // fc00::/7 — ULA (includes fd00::/8) + if (/^f[cd][0-9a-f]{2}:/.test(hostname)) return true; + // IPv4-mapped IPv6 — accept dot-decimal (`::ffff:1.2.3.4`, raw user input) + // and the URL-API-normalized hex form (`::ffff:HHHH:HHHH`, which `new URL` + // produces from any dot-decimal input). + const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(hostname); + if (dotted) return isIpv4LanLiteral(dotted[1]!); + const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(hostname); + if (hexMapped) { + const v1 = parseInt(hexMapped[1]!, 16); + const v2 = parseInt(hexMapped[2]!, 16); + const ipv4 = `${(v1 >> 8) & 0xff}.${v1 & 0xff}.${(v2 >> 8) & 0xff}.${v2 & 0xff}`; + return isIpv4LanLiteral(ipv4); + } + return false; +} + +/** + * True when `url`'s hostname falls in a private / link-local range, or is a + * loopback / `.local` / `localhost`. IPv4 + IPv6 (incl. IPv4-mapped). Empty / + * malformed inputs return `false`. + * + * Mirrors the prior `isLanUrl` in `useConnectionStatus.ts` for IPv4 — the + * additions are the IPv6 cases. UI hints, endpoint ordering, and the + * share-link LAN warning all read this. + */ +export function isLanUrl(url: string): boolean { + if (!url) return false; + try { + const parsed = new URL(url.startsWith('http') ? url : `http://${url}`); + const raw = parsed.hostname; + // `URL().hostname` keeps IPv6 brackets — strip before pattern matches. + const hostname = raw.replace(/^\[|\]$/g, '').toLowerCase(); + if (!hostname) return false; + if (hostname === 'localhost' || hostname.endsWith('.local')) return true; + if (hostname.includes(':')) return isIpv6LanHostname(hostname); + return isIpv4LanLiteral(hostname); + } catch { + return false; + } +} + +/** + * Deduped normalized addresses for a profile (`url` plus optional + * `alternateUrl`). Both fields are passed through `normalizeServerBaseUrl` + * before dedupe so `https://x.example/` and `https://x.example` collapse. + * Order is preserved (`url` first); empty entries are dropped. + */ +export function allNormalizedAddresses( + profile: Pick, +): string[] { + const result: string[] = []; + const seen = new Set(); + for (const raw of [profile.url, profile.alternateUrl]) { + if (!raw) continue; + const normalized = normalizeServerBaseUrl(raw); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +/** + * Endpoint list for connect probing — LAN-first, stable within each class. + * Single-address profiles return one entry; dual-address returns up to two. + */ +export function serverAddressEndpoints( + profile: Pick, +): ServerEndpoint[] { + const endpoints: ServerEndpoint[] = allNormalizedAddresses(profile).map(url => ({ + url, + kind: isLanUrl(url) ? 'local' : 'public', + })); + return [ + ...endpoints.filter(e => e.kind === 'local'), + ...endpoints.filter(e => e.kind === 'public'), + ]; +} + +/** + * URL to embed in **shares** (Orbit invites, entity / queue share payloads, + * magic strings). Different from the connect URL: a guest opening the share + * link is not on the host's LAN, so the public address is the right default + * when both are configured. `shareUsesLocalUrl` flips that for the rare + * "share into a LAN-only group" case (spec §5). + * + * Single-address profiles return their one normalized address; empty + * profiles still return a normalized form of `url` (possibly empty). + */ +export function serverShareBaseUrl( + profile: Pick, +): string { + const endpoints = allNormalizedAddresses(profile); + if (endpoints.length === 0) return normalizeServerBaseUrl(profile.url); + if (endpoints.length === 1) return endpoints[0]!; + + const local = endpoints.find(isLanUrl); + const publicEndpoint = endpoints.find(u => !isLanUrl(u)); + + if (profile.shareUsesLocalUrl) return local ?? endpoints[0]!; + return publicEndpoint ?? endpoints[0]!; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connect cache (in-memory, per-session) +// +// `pickReachableBaseUrl` probes the LAN-first endpoint list with the existing +// `pingWithCredentials`, sequentially (not parallel) so LAN wins over public +// without racing. The first OK URL is cached against the profile id so the +// next sync `getBaseUrl()` lookup is instant. Cache is **session only** — +// never persisted; cleared on profile edit / credentials change / online +// event / manual retry via `invalidateReachableEndpointCache`. +// ───────────────────────────────────────────────────────────────────────────── + +const connectCache = new Map(); + +/** + * In-flight probes keyed by `profile.id`. Three call sites (useConnectionStatus + * 120-s tick, switchActiveServer, bindIndexedServer, plus retry / online + * handlers) can fire near-simultaneously; without this map two probes would + * each see an empty cache, both ping every endpoint, and race to set the + * sticky URL — the loser's `connectCache.set` would stomp the winner. + * Returning the existing promise dedupes them so every caller gets the + * same result. + */ +const inFlightProbes = new Map>(); + +/** + * Last resolved connect URL for the profile, if a probe has succeeded in this + * session. `null` means "no probe yet" — sync `getBaseUrl()` callers should + * fall back to the normalized primary `url`. + */ +export function getCachedConnectBaseUrl(profileId: string): string | null { + return connectCache.get(profileId) ?? null; +} + +/** + * Synchronous connect URL for any saved profile (active or not). Reads the + * cached probe result; falls back to the normalized primary `url` when no + * probe has run yet for that profile. **Use this** everywhere HTTP traffic + * is built against an explicit `server.url` — never read the raw `url` + * straight for HTTP. + */ +export function connectBaseUrlForServer( + server: Pick, +): string { + const cached = connectCache.get(server.id); + if (cached) return cached; + return serverProfileBaseUrl({ url: server.url }); +} + +/** + * Drop one or all cached connect URLs. Call when: + * - profile was edited (url / alternateUrl / credentials changed) + * - network went online (re-check sticky) + * - user explicitly retried the connection + */ +export function invalidateReachableEndpointCache(profileId?: string): void { + if (profileId === undefined) { + connectCache.clear(); + // Don't clear in-flight slots — they're already racing against the + // network, letting their own `finally` clean up keeps the dedup + // invariant. Their results will still write to the (now empty) cache, + // which is the right behaviour: the freshest probe wins. + return; + } + connectCache.delete(profileId); +} + +/** + * Sequentially ping the profile's endpoints (LAN-first), return the first one + * that answers OK. Sticky: if a cached endpoint exists and is still in the + * list, it's tried first; on failure, the cache entry is cleared and the full + * sequence runs. + * + * Single-address profiles: one ping, identical to the legacy behavior. + */ +export async function pickReachableBaseUrl( + profile: ServerProfile, +): Promise { + // Dedupe concurrent calls for the same profile — see `inFlightProbes`. + const existing = inFlightProbes.get(profile.id); + if (existing) return existing; + + const promise = (async (): Promise => { + const ordered = serverAddressEndpoints(profile); + if (ordered.length === 0) return { ok: false, reason: 'unreachable' }; + + // Apply sticky: move the cached endpoint (if still in the list) to the front. + const cached = connectCache.get(profile.id); + const endpoints = + cached && ordered.some(e => e.url === cached) + ? [ + ordered.find(e => e.url === cached)!, + ...ordered.filter(e => e.url !== cached), + ] + : ordered; + + for (const endpoint of endpoints) { + const ping = await pingWithCredentials(endpoint.url, profile.username, profile.password); + if (ping.ok) { + connectCache.set(profile.id, endpoint.url); + return { ok: true, baseUrl: endpoint.url, endpoint, ping }; + } + } + + // Every endpoint failed — drop any stale cache entry so the next probe + // starts from the natural LAN-first order. + connectCache.delete(profile.id); + return { ok: false, reason: 'unreachable' }; + })(); + + inFlightProbes.set(profile.id, promise); + try { + return await promise; + } finally { + // Always clear the in-flight slot when this promise settles — the next + // call (after a real boundary in time) starts a fresh probe. + inFlightProbes.delete(profile.id); + } +} + +/** + * Boot / switch / online-event entry point: same mechanism as + * `pickReachableBaseUrl` but named for intent at the call site. + */ +export async function ensureConnectUrlResolved( + profile: ServerProfile, +): Promise { + return pickReachableBaseUrl(profile); +} diff --git a/src/utils/server/serverFingerprint.test.ts b/src/utils/server/serverFingerprint.test.ts new file mode 100644 index 00000000..765cb517 --- /dev/null +++ b/src/utils/server/serverFingerprint.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the apiWithCredentials surface — fetchServerFingerprint pulls +// folders / user / license / indexes through it. +vi.mock('../../api/subsonicClient', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + apiWithCredentials: vi.fn(), + }; +}); + +import { apiWithCredentials } from '../../api/subsonicClient'; +import { + compareFingerprints, + fetchServerFingerprint, + verifySameServerEndpoints, + type ServerFingerprint, +} from './serverFingerprint'; + +function makeFingerprint(overrides: Partial = {}): ServerFingerprint { + return { + ping: { + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + apiVersion: '1.16.1', + }, + musicFolders: [{ id: '1', name: 'Music' }], + userId: 'tester', + licenseKey: 'tester@example.com', + indexesDigest: 'letters:26|a1,a2', + ...overrides, + }; +} + +function navidromePingResponse() { + return { + 'subsonic-response': { + status: 'ok', + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + version: '1.16.1', + }, + }; +} + +function ampachePingResponse() { + // Minimal Subsonic-shape — ampache doesn't always advertise openSubsonic etc. + return { + 'subsonic-response': { + status: 'ok', + type: 'ampache', + serverVersion: '6.0.0', + version: '1.13.0', + }, + }; +} + +/** Build a fetch-API-shaped mock response without depending on the global `Response` polyfill. */ +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: async () => body, + } as unknown as Response; +} + +describe('compareFingerprints', () => { + it('matches two identical fingerprints', () => { + const a = makeFingerprint(); + const b = makeFingerprint(); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('mismatches when type differs (case-insensitive)', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, type: 'navidrome' } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, type: 'subsonic' } }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when serverVersion differs', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, serverVersion: '0.55.0' } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, serverVersion: '0.54.9' } }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when openSubsonic differs', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, openSubsonic: true } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, openSubsonic: false } }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('treats envelope apiVersion as informational (no mismatch on its own)', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, apiVersion: '1.16.1' } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, apiVersion: '1.13.0' } }); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('mismatches when musicFolders differ', () => { + const a = makeFingerprint({ musicFolders: [{ id: '1', name: 'Music' }] }); + const b = makeFingerprint({ musicFolders: [{ id: '1', name: 'Music' }, { id: '2', name: 'Audiobooks' }] }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('treats empty musicFolders on both sides as a matching signal', () => { + const a = makeFingerprint({ musicFolders: [], userId: null, licenseKey: null, indexesDigest: null }); + const b = makeFingerprint({ musicFolders: [], userId: null, licenseKey: null, indexesDigest: null }); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('mismatches when userId differs', () => { + const a = makeFingerprint({ userId: 'tester' }); + const b = makeFingerprint({ userId: 'maria' }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when licenseKey differs', () => { + const a = makeFingerprint({ licenseKey: 'a@example.com' }); + const b = makeFingerprint({ licenseKey: 'b@example.com' }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when indexesDigest differs', () => { + const a = makeFingerprint({ indexesDigest: 'letters:5|x,y' }); + const b = makeFingerprint({ indexesDigest: 'letters:5|x,z' }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('ignores a body signal that is null on one side', () => { + // userId null on a — only license + folders + indexes contribute; they match. + const a = makeFingerprint({ userId: null }); + const b = makeFingerprint({ userId: 'tester' }); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('returns insufficient when ping matches but every body signal is null on at least one side', () => { + const a = makeFingerprint({ + musicFolders: null, + userId: null, + licenseKey: null, + indexesDigest: null, + }); + const b = makeFingerprint(); + expect(compareFingerprints(a, b)).toBe('insufficient'); + }); +}); + +describe('fetchServerFingerprint', () => { + beforeEach(() => { + vi.mocked(apiWithCredentials).mockReset(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('returns a populated fingerprint for a Navidrome-shaped server', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(navidromePingResponse()), + ); + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester', username: 'tester' } }) + .mockResolvedValueOnce({ license: { email: 'tester@example.com' } }) + .mockResolvedValueOnce({ + indexes: { + index: [ + { name: 'A', artist: [{ id: 'a1' }, { id: 'a2' }] }, + { name: 'B', artist: [{ id: 'b1' }] }, + ], + }, + }); + + const fp = await fetchServerFingerprint('https://music.example.com', 'tester', 'pw'); + expect(fp.ping.type).toBe('navidrome'); + expect(fp.ping.serverVersion).toBe('0.55.0'); + expect(fp.ping.openSubsonic).toBe(true); + expect(fp.ping.apiVersion).toBe('1.16.1'); + expect(fp.musicFolders).toEqual([{ id: '1', name: 'Music' }]); + expect(fp.userId).toBe('tester'); + expect(fp.licenseKey).toBe('tester@example.com'); + expect(fp.indexesDigest).toMatch(/^letters:2\|/); + }); + + it('extracts userId only when the server supplies an explicit id (no username fallback)', async () => { + // Two servers return the same authenticated user but only one of them + // surfaces a `user.id` — the username-only side must extract null so the + // comparator skips the signal instead of comparing two unrelated strings. + vi.mocked(fetch).mockResolvedValue(jsonResponse(navidromePingResponse())); + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [] } }) + .mockResolvedValueOnce({ user: { username: 'tester' } }) // no `id` field + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const fp = await fetchServerFingerprint('https://x.example.com', 'tester', 'pw'); + expect(fp.userId).toBeNull(); + }); + + it('handles a partial-fail mix — some optional calls ok, others rejected', async () => { + // Real-world: a server answers getMusicFolders + getUser but rejects + // getLicense / getIndexes (subset of OpenSubsonic supported). The + // fingerprint must surface what succeeded and leave the rest null — + // guards against a Promise.all-vs-allSettled regression that would + // collapse the whole fingerprint when one call throws. + vi.mocked(fetch).mockResolvedValue(jsonResponse(navidromePingResponse())); + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester' } }) + .mockRejectedValueOnce(new Error('license not implemented')) + .mockRejectedValueOnce(new Error('indexes not implemented')); + const fp = await fetchServerFingerprint('https://music.example.com', 'tester', 'pw'); + expect(fp.musicFolders).toEqual([{ id: '1', name: 'Music' }]); + expect(fp.userId).toBe('tester'); + expect(fp.licenseKey).toBeNull(); + expect(fp.indexesDigest).toBeNull(); + }); + + it('soft-fails optional calls — minimal Subsonic-shape', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(ampachePingResponse()), + ); + // All four optional calls fail (server doesn't support them). + vi.mocked(apiWithCredentials).mockRejectedValue(new Error('Not implemented')); + + const fp = await fetchServerFingerprint('https://ampache.example.com', 'u', 'p'); + expect(fp.ping.type).toBe('ampache'); + expect(fp.ping.serverVersion).toBe('6.0.0'); + expect(fp.ping.openSubsonic).toBe(false); + expect(fp.musicFolders).toBeNull(); + expect(fp.userId).toBeNull(); + expect(fp.licenseKey).toBeNull(); + expect(fp.indexesDigest).toBeNull(); + }); + + it('returns a null-bodied fingerprint when ping itself fails', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(null, false, 500)); + const fp = await fetchServerFingerprint('https://broken.example.com', 'u', 'p'); + expect(fp.ping.type).toBeNull(); + expect(fp.ping.serverVersion).toBeNull(); + expect(fp.musicFolders).toBeNull(); + expect(fp.userId).toBeNull(); + // Optional calls are skipped entirely once ping fails. + expect(apiWithCredentials).not.toHaveBeenCalled(); + }); +}); + +describe('verifySameServerEndpoints', () => { + beforeEach(() => { + vi.mocked(apiWithCredentials).mockReset(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('short-circuits to ok:true for a single-address profile', async () => { + const result = await verifySameServerEndpoints( + { url: 'https://music.example.com' }, + 'u', + 'p', + ); + expect(result).toEqual({ ok: true }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns ok:true when both endpoints fingerprint the same server', async () => { + // Both pings succeed; both return identical Navidrome payloads. + vi.mocked(fetch).mockResolvedValue( + jsonResponse(navidromePingResponse()), + ); + vi.mocked(apiWithCredentials).mockResolvedValue({ + musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] }, + }); + + const result = await verifySameServerEndpoints( + { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }, + 'u', + 'p', + ); + expect(result).toEqual({ ok: true }); + }); + + it('returns unreachable when one endpoint ping fails', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(navidromePingResponse())) + .mockResolvedValueOnce(jsonResponse(null, false, 500)); + vi.mocked(apiWithCredentials).mockResolvedValue({}); + + const result = await verifySameServerEndpoints( + { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }, + 'u', + 'p', + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('unreachable'); + expect(result.unreachableHost).toBe('http://192.168.0.10'); + } + }); + + it('returns mismatch when fingerprints disagree on a body signal', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(navidromePingResponse()), + ); + // Two different folder lists. + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester' } }) + .mockResolvedValueOnce({ license: { email: 'a@e.com' } }) + .mockResolvedValueOnce({ + indexes: { index: [{ name: 'A', artist: [{ id: 'a1' }] }] }, + }) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '99', name: 'Other' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester' } }) + .mockResolvedValueOnce({ license: { email: 'a@e.com' } }) + .mockResolvedValueOnce({ + indexes: { index: [{ name: 'A', artist: [{ id: 'a1' }] }] }, + }); + + const result = await verifySameServerEndpoints( + { + url: 'https://a.example.com', + alternateUrl: 'https://b.example.com', + }, + 'u', + 'p', + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('mismatch'); + }); + + it('returns insufficient when pings agree but no body signal is comparable', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(ampachePingResponse()), + ); + // Both servers don't respond to any optional call. + vi.mocked(apiWithCredentials).mockRejectedValue(new Error('Not implemented')); + + const result = await verifySameServerEndpoints( + { + url: 'https://ampache-1.example.com', + alternateUrl: 'https://ampache-2.example.com', + }, + 'u', + 'p', + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('insufficient'); + }); +}); diff --git a/src/utils/server/serverFingerprint.ts b/src/utils/server/serverFingerprint.ts new file mode 100644 index 00000000..40866886 --- /dev/null +++ b/src/utils/server/serverFingerprint.ts @@ -0,0 +1,332 @@ +/** + * Same-server verification for dual-address profiles. + * + * When a user enters both a LAN and a public address for "their" server, the + * UI must refuse to save the pair if the two addresses are actually different + * boxes (e.g. typo, copy-paste mishap, two different Navidromes). We probe + * each address for an **idempotent fingerprint** — a small read-only snapshot + * of server identity — and compare them. + * + * Subsonic-generic by design: no branch on `type === 'navidrome'`. Whatever + * the server reports for `ping.view` plus the optional folders / user / + * license / indexes calls defines the fingerprint. Servers that only respond + * to `ping.view` produce an "insufficient" result, which blocks save in v1 + * (no "save anyway" escape hatch — see spec §7.3). + */ + +import md5 from 'md5'; +import { apiWithCredentials, restBaseFromUrl, secureRandomSalt, SUBSONIC_CLIENT } from '../../api/subsonicClient'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { allNormalizedAddresses } from './serverEndpoint'; + +export type ServerFingerprint = { + ping: { + type: string | null; + serverVersion: string | null; + openSubsonic: boolean; + apiVersion: string | null; + }; + musicFolders: Array<{ id: string; name: string }> | null; + userId: string | null; + /** Normalized lowercased email if present, else null. */ + licenseKey: string | null; + indexesDigest: string | null; +}; + +export type FingerprintCompareResult = 'match' | 'mismatch' | 'insufficient'; + +export type VerifySameServerResult = + | { ok: true } + | { + ok: false; + reason: 'mismatch' | 'insufficient' | 'unreachable'; + unreachableHost?: string; + }; + +// ─── ping (with envelope `version`) ────────────────────────────────────────── +// +// `pingWithCredentials` in api/subsonic.ts drops the envelope `version`. We +// need it for the fingerprint (informational, per spec §7.3) so we do the +// ping call here against the same Subsonic shape. Failure → throws. + +type PingFingerprint = ServerFingerprint['ping'] & { ok: boolean }; + +async function fetchPingFingerprint( + baseUrl: string, + username: string, + password: string, +): Promise { + // Mirrors pingWithCredentials but also extracts envelope `version`. + // Using fetch (the bundled axios pulls in extra noise here; one call is fine). + const salt = secureRandomSalt(); + const token = md5(password + salt); + const params = new URLSearchParams({ + u: username, + t: token, + s: salt, + v: '1.16.1', + c: SUBSONIC_CLIENT, + f: 'json', + }); + const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`; + let body: Record | null = null; + try { + const resp = await fetch(url, { method: 'GET' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const json = (await resp.json()) as Record; + body = (json?.['subsonic-response'] as Record) ?? null; + } catch { + return { + ok: false, + type: null, + serverVersion: null, + openSubsonic: false, + apiVersion: null, + }; + } + if (!body) { + return { + ok: false, + type: null, + serverVersion: null, + openSubsonic: false, + apiVersion: null, + }; + } + const ok = body.status === 'ok'; + return { + ok, + type: typeof body.type === 'string' ? body.type : null, + serverVersion: typeof body.serverVersion === 'string' ? body.serverVersion : null, + openSubsonic: body.openSubsonic === true, + apiVersion: typeof body.version === 'string' ? body.version : null, + }; +} + +// ─── optional fingerprint calls (failures soft-fail to null) ───────────────── + +function extractMusicFolders(data: unknown): Array<{ id: string; name: string }> | null { + const folders = (data as Record | null)?.['musicFolders'] as + | { musicFolder?: Array<{ id: unknown; name: unknown }> } + | undefined; + const list = folders?.musicFolder; + if (!Array.isArray(list)) return null; + const normalized = list + .map(f => ({ id: String(f.id ?? ''), name: typeof f.name === 'string' ? f.name : '' })) + .filter(f => f.id !== ''); + // Sort by id so order differences from the server don't trip the compare. + normalized.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return normalized; +} + +function extractUserId(data: unknown): string | null { + const user = (data as Record | null)?.['user'] as + | Record + | undefined; + if (!user) return null; + // Only count the server-supplied id as a signal. A username-only fallback + // (spec §7.2 footnote) would create false `mismatch` results when one + // endpoint surfaces an explicit id and the other only returns the + // username we already authenticated with — both sides would carry a + // value and the comparator would compare unrelated strings. + const explicit = typeof user.id === 'string' ? user.id.trim() : ''; + return explicit || null; +} + +function extractLicenseEmail(data: unknown): string | null { + const license = (data as Record | null)?.['license'] as + | Record + | undefined; + if (!license) return null; + const email = typeof license.email === 'string' ? license.email.trim().toLowerCase() : ''; + return email || null; +} + +function extractIndexesDigest(data: unknown): string | null { + const indexes = (data as Record | null)?.['indexes'] as + | Record + | undefined; + if (!indexes) return null; + const letters = Array.isArray(indexes.index) ? (indexes.index as Array>) : []; + if (letters.length === 0) return null; + const letterCount = letters.length; + const artistIds: string[] = []; + for (const letter of letters) { + const artists = Array.isArray(letter.artist) ? (letter.artist as Array>) : []; + for (const artist of artists) { + const id = typeof artist.id === 'string' ? artist.id : null; + if (id) artistIds.push(id); + if (artistIds.length >= 20) break; + } + if (artistIds.length >= 20) break; + } + if (artistIds.length === 0) return `letters:${letterCount}|`; + artistIds.sort(); + return `letters:${letterCount}|${artistIds.slice(0, 20).join(',')}`; +} + +// ─── fetchServerFingerprint ────────────────────────────────────────────────── + +export async function fetchServerFingerprint( + baseUrl: string, + username: string, + password: string, +): 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); + + // The optional calls only make sense once ping succeeded — without that, + // any subsequent call against the same URL is just wasted bandwidth. + if (!ping.ok) { + return { + ping: { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + apiVersion: ping.apiVersion, + }, + musicFolders: null, + userId: null, + licenseKey: null, + indexesDigest: null, + }; + } + + 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'), + ]); + + const [foldersResult, userResult, licenseResult, indexesResult] = settled; + + const value = (r: PromiseSettledResult): T | null => + r.status === 'fulfilled' ? r.value : null; + + return { + ping: { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + apiVersion: ping.apiVersion, + }, + musicFolders: extractMusicFolders(value(foldersResult)), + userId: extractUserId(value(userResult)), + licenseKey: extractLicenseEmail(value(licenseResult)), + indexesDigest: extractIndexesDigest(value(indexesResult)), + }; +} + +// ─── compareFingerprints ───────────────────────────────────────────────────── + +/** + * Strict comparison rule for the ping triple, plus a "common-signals" rule + * for the body. Envelope `apiVersion` is informational only and never causes + * mismatch on its own (spec §7.3). + * + * `match` — every common signal agrees + at least one common body signal + * `mismatch` — any common signal differs (ping or body) + * `insufficient` — pings ok but no body signal is present on both sides + */ +export function compareFingerprints( + a: ServerFingerprint, + b: ServerFingerprint, +): FingerprintCompareResult { + // Ping strictness — these MUST agree once both pings succeeded. (Callers + // upstream of compareFingerprints handle the "ping failed" case as + // `unreachable`.) + const aType = a.ping.type?.trim().toLowerCase() ?? null; + const bType = b.ping.type?.trim().toLowerCase() ?? null; + if (aType !== bType) return 'mismatch'; + + const aVersion = a.ping.serverVersion ?? null; + const bVersion = b.ping.serverVersion ?? null; + if (aVersion !== bVersion) return 'mismatch'; + + if (a.ping.openSubsonic !== b.ping.openSubsonic) return 'mismatch'; + + // Body signals — only count when both sides have a value. Empty array on + // both sides for musicFolders is itself a matching signal (spec §7.3). + let common = 0; + + // musicFolders — array equality after sort (extract already sorts). + if (a.musicFolders !== null && b.musicFolders !== null) { + common += 1; + if (!musicFoldersEqual(a.musicFolders, b.musicFolders)) return 'mismatch'; + } + + if (a.userId !== null && b.userId !== null) { + common += 1; + if (a.userId !== b.userId) return 'mismatch'; + } + + if (a.licenseKey !== null && b.licenseKey !== null) { + common += 1; + if (a.licenseKey !== b.licenseKey) return 'mismatch'; + } + + if (a.indexesDigest !== null && b.indexesDigest !== null) { + common += 1; + if (a.indexesDigest !== b.indexesDigest) return 'mismatch'; + } + + if (common === 0) return 'insufficient'; + return 'match'; +} + +function musicFoldersEqual( + a: Array<{ id: string; name: string }>, + b: Array<{ id: string; name: string }>, +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const ai = a[i]!; + const bi = b[i]!; + if (ai.id !== bi.id || ai.name !== bi.name) return false; + } + return true; +} + +// ─── verifySameServerEndpoints ─────────────────────────────────────────────── + +/** + * Top-level orchestrator: probe each configured address in parallel, then + * compare the fingerprints pairwise. Single-address profiles short-circuit to + * `ok: true` — nothing to verify. + */ +export async function verifySameServerEndpoints( + profile: Pick, + username: string, + password: string, +): Promise { + const endpoints = allNormalizedAddresses(profile); + if (endpoints.length <= 1) return { ok: true }; + + const fingerprints = await Promise.all( + endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password)), + ); + + // If any ping failed → unreachable (with the offending host for the UI). + for (let i = 0; i < endpoints.length; i++) { + if (!fingerprints[i]!.ping.type && !fingerprints[i]!.ping.serverVersion) { + // ping.type/serverVersion are null only when the ping itself failed + // (fetchPingFingerprint zeroes everything on failure). + return { ok: false, reason: 'unreachable', unreachableHost: endpoints[i]! }; + } + } + + // Compare every pair. N=2 in v1 (spec §6), but the loop generalises. + for (let i = 0; i < fingerprints.length; i++) { + for (let j = i + 1; j < fingerprints.length; j++) { + const result = compareFingerprints(fingerprints[i]!, fingerprints[j]!); + if (result === 'mismatch') return { ok: false, reason: 'mismatch' }; + if (result === 'insufficient') return { ok: false, reason: 'insufficient' }; + } + } + + return { ok: true }; +} diff --git a/src/utils/server/serverMagicString.test.ts b/src/utils/server/serverMagicString.test.ts index 02f16355..52d34045 100644 --- a/src/utils/server/serverMagicString.test.ts +++ b/src/utils/server/serverMagicString.test.ts @@ -65,8 +65,9 @@ describe('serverMagicString', () => { expect(decodeServerMagicString(garbage)).toBeNull(); }); - it('rejects a payload with the wrong version', () => { - const wrongVersion = btoa(JSON.stringify({ v: 2, url: 'https://x', u: 'u', w: 'p' })) + it('rejects a payload with an unknown version', () => { + // v: 3 is out of range; v1 + v2 are both accepted. + const wrongVersion = btoa(JSON.stringify({ v: 3, url: 'https://x', u: 'u', w: 'p' })) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + wrongVersion)).toBeNull(); }); @@ -100,6 +101,110 @@ describe('serverMagicString', () => { it('rejects text that contains only the bare prefix', () => { expect(decodeServerMagicStringFromText(`prefix only: ${SERVER_MAGIC_STRING_PREFIX} done`)).toBeNull(); }); + + // ─── v2 (dual-address) ────────────────────────────────────────────────── + + it('emits v1 for a single-address invite (backward-compatible)', () => { + // No alternateUrl, no shareUsesLocalUrl → v1 wire format. Verified by + // round-tripping through a v1-decode of the inner JSON. + const encoded = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'alice', + password: 'pw', + }); + const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length); + const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4); + const inner = JSON.parse(atob(padded)); + expect(inner.v).toBe(1); + expect(inner.alt).toBeUndefined(); + expect(inner.shareLocal).toBeUndefined(); + }); + + it('emits v2 when alternateUrl is set', () => { + const encoded = encodeServerMagicString({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + username: 'alice', + password: 'pw', + }); + const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length); + const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4); + const inner = JSON.parse(atob(padded)); + expect(inner.v).toBe(2); + expect(inner.url).toBe('https://music.example.com'); + expect(inner.alt).toBe('http://192.168.0.10:4533'); + expect(inner.shareLocal).toBeUndefined(); + }); + + it('emits v2 when only shareUsesLocalUrl is set (no alt)', () => { + // Edge: the host has dropped the second address but kept the share flag + // on. We still emit v2 so the receiver picks up the preference. + const encoded = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'alice', + password: 'pw', + shareUsesLocalUrl: true, + }); + const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length); + const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4); + const inner = JSON.parse(atob(padded)); + expect(inner.v).toBe(2); + expect(inner.alt).toBeUndefined(); + expect(inner.shareLocal).toBe(true); + }); + + it('round-trips v2 with alternateUrl + shareUsesLocalUrl + name', () => { + const original = { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + shareUsesLocalUrl: true, + username: 'alice', + password: 'pw', + name: 'Home', + }; + const encoded = encodeServerMagicString(original); + expect(decodeServerMagicString(encoded)).toEqual(original); + }); + + it('round-trips v2 with alternateUrl only (default share flag absent)', () => { + const original = { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + username: 'alice', + password: 'pw', + }; + const decoded = decodeServerMagicString(encodeServerMagicString(original)); + expect(decoded).toEqual(original); + // shareUsesLocalUrl must NOT be set on the decoded payload when the + // host didn't flip the flag — kept absent rather than `false` so + // existing zustand persist diffs stay clean. + expect(decoded?.shareUsesLocalUrl).toBeUndefined(); + }); + + it('decodes a v1 invite without alternateUrl / shareUsesLocalUrl', () => { + // Hand-built v1 payload → verify backward-compat decode path leaves the + // v2-only fields undefined. + const v1 = btoa(JSON.stringify({ v: 1, url: 'https://x.example', u: 'u', w: 'p' })) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const decoded = decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + v1); + expect(decoded?.url).toBe('https://x.example'); + expect(decoded?.alternateUrl).toBeUndefined(); + expect(decoded?.shareUsesLocalUrl).toBeUndefined(); + }); + + it('treats an empty alt field on v2 as absent (no alternateUrl)', () => { + // Defensive — a misformed v2 invite with `alt: ''` should decode as if + // no alternate were set, not as an empty-string alternateUrl that + // would later fail isLanUrl / normalize checks. + const v2 = btoa(JSON.stringify({ + v: 2, url: 'https://x.example', alt: ' ', u: 'u', w: 'p', + })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const decoded = decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + v2); + expect(decoded?.alternateUrl).toBeUndefined(); + }); }); describe('copyTextToClipboard', () => { diff --git a/src/utils/server/serverMagicString.ts b/src/utils/server/serverMagicString.ts index 15ff40df..ca5018f3 100644 --- a/src/utils/server/serverMagicString.ts +++ b/src/utils/server/serverMagicString.ts @@ -1,3 +1,6 @@ +import type { ServerProfile } from '../../store/authStoreTypes'; +import { normalizeServerBaseUrl, serverShareBaseUrl } from './serverEndpoint'; + /** * Prefix for server invite strings (Subsonic credentials). Same family as library * shares in `shareLink.ts` (`psysonic2-` + payload). @@ -8,7 +11,26 @@ export const SERVER_MAGIC_STRING_PREFIX = 'psysonic1-'; export const DECODED_PASSWORD_VISUAL_MASK = '••••••••••'; export interface ServerMagicPayload { + /** + * **v1:** the host's primary URL (also the connect URL for single-address + * profiles). + * **v2:** the host's **share URL** — public by default when both + * addresses are set, or the local address if `shareUsesLocalUrl` flips it. + * The receiver takes whichever they got and treats it as the primary URL + * of the new profile (their own index key). + */ url: string; + /** + * v2 only — the host's alternate address. Empty / absent when the host + * has a single-address profile or chose to share a v1 invite. + */ + alternateUrl?: string; + /** + * v2 only — the host's preference for which address goes into future + * share links from the receiver's side. Mirrors the source profile's + * checkbox so a guest's onward shares behave the same way. + */ + shareUsesLocalUrl?: boolean; username: string; password: string; /** Optional display name for the saved server entry */ @@ -32,16 +54,34 @@ function base64UrlToUtf8(b64url: string): string { return new TextDecoder().decode(bytes); } -/** Encode server URL + Subsonic credentials into a single pasteable string. */ +/** + * Encode server URL + Subsonic credentials into a single pasteable string. + * + * Emits **v2** when the payload carries an `alternateUrl` or + * `shareUsesLocalUrl=true` (the dual-address shape — spec §10). Falls back + * to **v1** otherwise, byte-identical with the pre-dual-address format so + * older receivers keep working. + */ export function encodeServerMagicString(p: ServerMagicPayload): string { - const payload = { - v: 1 as const, + const alt = p.alternateUrl?.trim() ?? ''; + const useV2 = alt.length > 0 || p.shareUsesLocalUrl === true; + const base = { url: p.url.trim(), u: p.username, w: p.password, ...(p.name?.trim() ? { n: p.name.trim() } : {}), }; - return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(payload)); + if (useV2) { + const v2 = { + v: 2 as const, + ...base, + ...(alt ? { alt } : {}), + ...(p.shareUsesLocalUrl ? { shareLocal: true as const } : {}), + }; + return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(v2)); + } + const v1 = { v: 1 as const, ...base }; + return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(v1)); } /** @@ -61,6 +101,11 @@ export function decodeServerMagicStringFromText(text: string): ServerMagicPayloa return decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + token); } +/** + * Decode either a v1 or v2 invite. v2 surfaces `alternateUrl` + the share + * preference; v1 leaves those fields undefined so single-address profiles + * keep the same persisted shape. + */ export function decodeServerMagicString(raw: string): ServerMagicPayload | null { const s = raw.trim(); if (!s.startsWith(SERVER_MAGIC_STRING_PREFIX)) return null; @@ -74,15 +119,60 @@ export function decodeServerMagicString(raw: string): ServerMagicPayload | null } if (!obj || typeof obj !== 'object') return null; const o = obj as Record; - if (o.v !== 1) return null; + if (o.v !== 1 && o.v !== 2) return null; const url = typeof o.url === 'string' ? o.url.trim() : ''; const username = typeof o.u === 'string' ? o.u : ''; const password = typeof o.w === 'string' ? o.w : ''; const name = typeof o.n === 'string' && o.n.trim() ? o.n.trim() : undefined; if (!url || !username) return null; + + if (o.v === 2) { + const alt = typeof o.alt === 'string' ? o.alt.trim() : ''; + return { + url, + ...(alt ? { alternateUrl: alt } : {}), + ...(o.shareLocal === true ? { shareUsesLocalUrl: true } : {}), + username, + password, + name, + }; + } return { url, username, password, name }; } +/** + * Pick out the dual-address fields for the saved profile whose primary URL + * or `alternateUrl` matches the given `serverUrl`. Returns the share URL + + * the alternate + the share flag when those exist; falls back to just the + * raw `serverUrl` for legacy / single-address profiles (v1 wire shape). + * + * Shared by `MagicStringModal` and `UserForm` (the two places we encode + * server invites from). The lookup is normalize-aware so it tolerates + * trailing-slash / scheme differences between the input URL and the saved + * profile's URL. + */ +export function magicPayloadAddressFields( + serverUrl: string, + servers: ServerProfile[], +): { + url: string; + alternateUrl?: string; + shareUsesLocalUrl?: boolean; +} { + const normalized = normalizeServerBaseUrl(serverUrl); + const match = servers.find( + s => + normalizeServerBaseUrl(s.url) === normalized || + (s.alternateUrl != null && normalizeServerBaseUrl(s.alternateUrl) === normalized), + ); + if (!match) return { url: serverUrl }; + return { + url: serverShareBaseUrl(match), + ...(match.alternateUrl ? { alternateUrl: match.alternateUrl } : {}), + ...(match.shareUsesLocalUrl ? { shareUsesLocalUrl: true } : {}), + }; +} + export async function copyTextToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); diff --git a/src/utils/server/serverUrlRemigration.test.ts b/src/utils/server/serverUrlRemigration.test.ts new file mode 100644 index 00000000..7b55f43e --- /dev/null +++ b/src/utils/server/serverUrlRemigration.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the Tauri core invoke surface — the orchestrator calls +// `migration_inspect`, `migration_run`, and `cover_cache_rename_server_bucket` +// through this single entry point. +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +import { invoke } from '@tauri-apps/api/core'; +import { + indexKeyRemapForUrlChange, + runIndexKeyRemigration, +} from './serverUrlRemigration'; + +function inspectStub(needs = true) { + return { + needsMigration: needs, + hasSkippedUnknownServerRows: false, + canRun: true, + warnings: [], + unmappedEmptyBucket: false, + library: { totalLegacyRows: 100, skippedUnknownServerRows: 0, tables: {} }, + analysis: { totalLegacyRows: 50, skippedUnknownServerRows: 0, tables: {} }, + mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }], + }; +} + +function runStub() { + return { + library: { importedRows: 100, sourceRows: 100, skippedUnknownServerRows: 0 }, + analysis: { importedRows: 50, sourceRows: 50, skippedUnknownServerRows: 0 }, + hasSkippedUnknownServerRows: false, + switched: true, + backupRemoved: true, + }; +} + +describe('indexKeyRemapForUrlChange', () => { + it('returns null when both urls collapse to the same index key', () => { + expect( + indexKeyRemapForUrlChange({ url: 'https://music.example.com' }, { url: 'http://music.example.com/' }), + ).toBeNull(); + }); + + it('returns null when only the scheme changes', () => { + expect( + indexKeyRemapForUrlChange({ url: 'http://music.example.com' }, { url: 'https://music.example.com' }), + ).toBeNull(); + }); + + it('returns null for missing urls', () => { + expect(indexKeyRemapForUrlChange({ url: '' }, { url: 'https://x.example' })).toBeNull(); + expect(indexKeyRemapForUrlChange({ url: 'https://x.example' }, { url: '' })).toBeNull(); + }); + + it('returns the remap when the host changes', () => { + expect( + indexKeyRemapForUrlChange( + { url: 'https://old.example.com' }, + { url: 'https://new.example.com' }, + ), + ).toEqual({ oldKey: 'old.example.com', newKey: 'new.example.com' }); + }); + + it('returns the remap when the path part changes', () => { + expect( + indexKeyRemapForUrlChange( + { url: 'https://music.example.com' }, + { url: 'https://music.example.com/navidrome' }, + ), + ).toEqual({ oldKey: 'music.example.com', newKey: 'music.example.com/navidrome' }); + }); +}); + +describe('runIndexKeyRemigration', () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it('runs the full pipeline on the happy path', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) // migration_inspect + .mockResolvedValueOnce(runStub()) // migration_run + .mockResolvedValueOnce(undefined); // cover_cache_rename_server_bucket + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(true); + expect(invoke).toHaveBeenCalledTimes(3); + expect(vi.mocked(invoke).mock.calls[0]![0]).toBe('migration_inspect'); + expect(vi.mocked(invoke).mock.calls[1]![0]).toBe('migration_run'); + expect(vi.mocked(invoke).mock.calls[2]![0]).toBe('cover_cache_rename_server_bucket'); + expect(vi.mocked(invoke).mock.calls[2]![1]).toEqual({ oldKey: 'old', newKey: 'new' }); + }); + + it('hands the same { legacyId, indexKey } mapping to inspect + run', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) + .mockResolvedValueOnce(runStub()) + .mockResolvedValueOnce(undefined); + + await runIndexKeyRemigration({ oldKey: 'old.example.com', newKey: 'new.example.com' }); + + const inspectCall = vi.mocked(invoke).mock.calls[0]!; + const runCall = vi.mocked(invoke).mock.calls[1]!; + expect(inspectCall[1]).toEqual({ + mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }], + }); + expect(runCall[1]).toEqual({ + mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }], + }); + }); + + it('reports inspect failure and stops without running the destructive step', async () => { + vi.mocked(invoke).mockRejectedValueOnce(new Error('locked')); // inspect throws + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.stage).toBe('inspect'); + expect(result.failure.error).toContain('locked'); + } + expect(invoke).toHaveBeenCalledTimes(1); // only inspect ran + }); + + it('reports run failure and does not attempt the cover rename', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) + .mockRejectedValueOnce(new Error('disk full')); + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure.stage).toBe('run'); + expect(invoke).toHaveBeenCalledTimes(2); // inspect + failed run + }); + + it('reports cover-rename failure (DB rows already moved — recoverable)', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) + .mockResolvedValueOnce(runStub()) + .mockRejectedValueOnce(new Error('rename: permission denied')); + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.stage).toBe('cover-rename'); + expect(result.failure.error).toContain('permission denied'); + } + expect(invoke).toHaveBeenCalledTimes(3); // all three were attempted + }); +}); diff --git a/src/utils/server/serverUrlRemigration.ts b/src/utils/server/serverUrlRemigration.ts new file mode 100644 index 00000000..f28ad18d --- /dev/null +++ b/src/utils/server/serverUrlRemigration.ts @@ -0,0 +1,124 @@ +/** + * Primary-URL change remigration — moves library / analysis SQLite rows and + * the cover-cache disk bucket from one index key to another when the user + * edits a server profile's primary `url` in a way that changes the derived + * `serverIndexKeyFromUrl(url)`. + * + * Spec §8 ("URL change remigration"). The pipeline: + * + * 1. detect — `indexKeyRemapForUrlChange(prev, next)` returns null when the + * index key is unchanged (scheme-only flip, alternateUrl-only edit, + * trailing-slash edit, etc.) so callers can short-circuit without doing + * any work. + * 2. inspect — `migration_inspect` confirms how many rows would move (also + * surfaces warnings before the destructive step runs). + * 3. run — `migration_run` re-tags the SQLite rows (library + analysis). + * 4. front-end rewrite — `rewriteFrontendStoreKeysForRemap` repoints + * offlineStore / hotCacheStore / analysisStrategyStore / + * playerStore.queueServerId. + * 5. cover bucket — `cover_cache_rename_server_bucket` moves + * `{cover_root}/{oldKey}/` to `{newKey}/`. + * + * Failures abort early; the caller blocks the profile save and shows the + * user a retry. We never partially commit: the SQLite step is the latest + * destructive step before the front-end rewrite and disk rename, so the + * worst case (DB ok, cover rename fails) leaves the index key consistent + * but covers under the new key may need to be re-downloaded — handled by + * the existing cover backfill on next access. + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { + migrationInspect, + migrationRun, + type MigrationInspectReport, + type MigrationRunResult, +} from '../../api/migration'; +import { rewriteFrontendStoreKeysForRemap } from './rewriteFrontendStoreKeys'; +import { serverIndexKeyFromUrl } from './serverIndexKey'; + +export type IndexKeyRemap = { oldKey: string; newKey: string }; + +export type IndexKeyRemigrationFailure = + | { stage: 'inspect'; error: string } + | { stage: 'run'; error: string } + | { stage: 'cover-rename'; error: string }; + +export type IndexKeyRemigrationResult = + | { ok: true; inspect: MigrationInspectReport; run: MigrationRunResult } + | { ok: false; failure: IndexKeyRemigrationFailure }; + +/** + * Detect whether a profile edit changes the **index key** derived from the + * primary url. Returns `null` when both sides normalize to the same key — + * scheme-only edits (`http://x` → `https://x`), trailing-slash differences, + * and any change that only touches `alternateUrl` / credentials / name all + * fall through. + * + * Empty / missing urls return null (no remigration to do). + */ +export function indexKeyRemapForUrlChange( + previous: Pick, + next: Pick, +): IndexKeyRemap | null { + const oldKey = serverIndexKeyFromUrl(previous.url ?? '').trim(); + const newKey = serverIndexKeyFromUrl(next.url ?? '').trim(); + if (!oldKey || !newKey) return null; + if (oldKey === newKey) return null; + return { oldKey, newKey }; +} + +/** + * Run the four-stage pipeline for a single oldKey → newKey remap. + * + * Returns a structured result the caller can branch on — UI surfaces the + * failure stage so the user knows whether to retry the DB step, fix + * network, or report a cover-rename bug. + */ +export async function runIndexKeyRemigration( + remap: IndexKeyRemap, +): Promise { + const mappings = [{ legacyId: remap.oldKey, indexKey: remap.newKey }]; + + let inspect: MigrationInspectReport; + try { + inspect = await migrationInspect(mappings); + } catch (e) { + return { ok: false, failure: { stage: 'inspect', error: String(e) } }; + } + + let run: MigrationRunResult; + try { + run = await migrationRun(mappings); + } catch (e) { + return { ok: false, failure: { stage: 'run', error: String(e) } }; + } + + // Frontend stores — this is in-memory only; if zustand throws (it + // shouldn't), the user is left with a DB tagged on newKey and stores on + // oldKey. That recovers on next app start via the existing rehydration + // path, so we don't treat it as a failure here. + try { + await rewriteFrontendStoreKeysForRemap([remap]); + } catch { + /* in-memory rewrite is best-effort; the persisted state catches up at + the next zustand persist tick. */ + } + + try { + await invoke('cover_cache_rename_server_bucket', { + oldKey: remap.oldKey, + newKey: remap.newKey, + }); + } catch (e) { + // Cover rename is the latest step and the most recoverable failure: + // the disk bucket is still under oldKey, library + analysis already + // point at newKey, so covers will look "missing" until the user re- + // triggers a sync or the backfill catches them. Surface the failure + // but don't undo the DB step — that would be far more destructive. + return { ok: false, failure: { stage: 'cover-rename', error: String(e) } }; + } + + return { ok: true, inspect, run }; +} diff --git a/src/utils/server/switchActiveServer.ts b/src/utils/server/switchActiveServer.ts index 84f17d62..d9b311c0 100644 --- a/src/utils/server/switchActiveServer.ts +++ b/src/utils/server/switchActiveServer.ts @@ -1,5 +1,5 @@ import type { ServerProfile } from '../../store/authStoreTypes'; -import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; +import { scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { coverTrafficBeginServerSwitch, coverTrafficEndServerSwitch, @@ -7,12 +7,17 @@ import { import { useAuthStore } from '../../store/authStore'; import { useOrbitStore } from '../../store/orbitStore'; import { endOrbitSession, leaveOrbitSession } from '../orbit'; +import { ensureConnectUrlResolved } from './serverEndpoint'; export async function switchActiveServer(server: ServerProfile): Promise { coverTrafficBeginServerSwitch(); try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (!ping.ok) return false; + // Resolve the reachable endpoint (LAN-first, sticky cached); this also + // populates the connect cache so the sync `getBaseUrl()` lookup serves the + // probed URL on the very next read. Single-address profiles fall through + // to one ping, identical to the legacy behaviour. + const probe = await ensureConnectUrlResolved(server); + if (!probe.ok) return false; // Tear down any active Orbit session before we actually switch. The // session's playlists live on the *old* server — once we flip the @@ -32,13 +37,13 @@ export async function switchActiveServer(server: ServerProfile): Promise { - const srv = useAuthStore.getState().getBaseUrl(); - if (!srv || !id.trim()) return false; + const active = useAuthStore.getState().getActiveServer(); + if (!active || !id.trim()) return false; + // Share URL ≠ connect URL — a guest opening this link is not on our LAN, so + // a dual-address profile defaults to the public address (overridable via + // shareUsesLocalUrl when the user explicitly shares into a LAN group). + const srv = serverShareBaseUrl(active); + if (!srv) return false; return copyTextToClipboard(encodeSharePayload({ srv, k: kind, id: id.trim() })); } diff --git a/src/utils/share/enqueueShareSearchPayload.ts b/src/utils/share/enqueueShareSearchPayload.ts index ec91111e..2cce0104 100644 --- a/src/utils/share/enqueueShareSearchPayload.ts +++ b/src/utils/share/enqueueShareSearchPayload.ts @@ -14,6 +14,7 @@ import { songToTrack } from '../playback/songToTrack'; import type { Track } from '../../store/playerStoreTypes'; import { orbitBulkGuard } from '../orbitBulkGuard'; import { findServerIdForShareUrl } from './shareLink'; +import { connectBaseUrlForServer } from '../server/serverEndpoint'; import { serverIndexKeyFromUrl } from '../server/serverIndexKey'; import type { AlbumShareSearchPayload, @@ -104,7 +105,12 @@ async function resolveSharedSong( activateShareServer(lookup.serverId); return getSong(id); } - return getSongWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, id); + return getSongWithCredentials( + connectBaseUrlForServer(lookup.server), + lookup.server.username, + lookup.server.password, + id, + ); } async function getAlbumAfterActivation( @@ -172,7 +178,12 @@ export async function resolveShareSearchAlbum( try { const { album } = options.activateServer ? await getAlbumAfterActivation(payload.id, lookup.serverId) - : await getAlbumWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id); + : await getAlbumWithCredentials( + connectBaseUrlForServer(lookup.server), + lookup.server.username, + lookup.server.password, + payload.id, + ); return { type: 'ok', album }; } catch { return { type: 'unavailable' }; @@ -194,7 +205,12 @@ export async function resolveShareSearchArtist( try { const { artist } = options.activateServer ? await getArtistAfterActivation(payload.id, lookup.serverId) - : await getArtistWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id); + : await getArtistWithCredentials( + connectBaseUrlForServer(lookup.server), + lookup.server.username, + lookup.server.password, + payload.id, + ); return { type: 'ok', artist }; } catch { return { type: 'unavailable' }; diff --git a/src/utils/share/shareLink.test.ts b/src/utils/share/shareLink.test.ts index a5c3c2cd..a6f6aa10 100644 --- a/src/utils/share/shareLink.test.ts +++ b/src/utils/share/shareLink.test.ts @@ -267,4 +267,31 @@ describe('findServerIdForShareUrl', () => { it('returns null on an empty server list', () => { expect(findServerIdForShareUrl([], 'https://x.example')).toBeNull(); }); + + it('matches a dual-address profile by its alternateUrl', () => { + // Host generated a v2 invite with shareUsesLocalUrl=true → the share URL + // in the payload is the LAN side. The receiver pastes that URL; their + // saved profile has the public URL as primary and the LAN as alternate, + // so the lookup must match on alternateUrl, not just url. + const a = makeServer({ + id: 'a', + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + }); + expect(findServerIdForShareUrl([a], 'http://192.168.0.10:4533')).toBe('a'); + expect(findServerIdForShareUrl([a], 'http://192.168.0.10:4533/')).toBe('a'); + }); + + it('matches either primary or alternate, prefers primary when both exist on different profiles', () => { + const a = makeServer({ + id: 'a', + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + const b = makeServer({ id: 'b', url: 'http://192.168.0.10:4533' }); + // 'a' matches via alternateUrl, 'b' matches via url — both are valid + // hits; the function returns the first one (insertion order). + expect(findServerIdForShareUrl([a, b], 'http://192.168.0.10')).toBe('a'); + expect(findServerIdForShareUrl([b, a], 'http://192.168.0.10:4533')).toBe('b'); + }); }); diff --git a/src/utils/share/shareLink.ts b/src/utils/share/shareLink.ts index 942cb699..993f728b 100644 --- a/src/utils/share/shareLink.ts +++ b/src/utils/share/shareLink.ts @@ -105,7 +105,14 @@ export function decodeSharePayloadFromText(text: string): EntitySharePayloadV1 | export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: string): string | null { const norm = normalizeShareServerUrl(shareSrv); - const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm); + // Dual-address: a paste may carry either the host's primary url or the + // alternateUrl (depending on which the host had as their share URL at + // encode time, modulated by shareUsesLocalUrl). Match either. + const hit = servers.find( + s => + normalizeShareServerUrl(s.url) === norm || + (!!s.alternateUrl && normalizeShareServerUrl(s.alternateUrl) === norm), + ); return hit?.id ?? null; }