diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f660384..6d766f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### PsyLab — Performance Probe rename, Tuning tab, and log tools + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)** + +* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab. +* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection. +* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable. + + + ## Fixed ### Servers — complete border on the active server card diff --git a/src-tauri/crates/psysonic-core/src/lib.rs b/src-tauri/crates/psysonic-core/src/lib.rs index 5ad81722..4891a3be 100644 --- a/src-tauri/crates/psysonic-core/src/lib.rs +++ b/src-tauri/crates/psysonic-core/src/lib.rs @@ -5,6 +5,7 @@ //! between `psysonic-audio`, `psysonic-analysis`, and other domain crates. pub mod cover_cache_layout; +pub mod log_sanitize; pub mod media_layout; pub mod logging; pub mod ports; diff --git a/src-tauri/crates/psysonic-core/src/log_sanitize.rs b/src-tauri/crates/psysonic-core/src/log_sanitize.rs new file mode 100644 index 00000000..c52f8d96 --- /dev/null +++ b/src-tauri/crates/psysonic-core/src/log_sanitize.rs @@ -0,0 +1,378 @@ +//! Redact secrets and partially mask remote server hostnames before log lines +//! are stored or exported (PsyLab / Settings log export). + +const SENSITIVE_QUERY_KEYS: &[&str] = &[ + "t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey", + "access_token", "refresh_token", "auth", +]; + +const SENSITIVE_KV_KEYS: &[&str] = &[ + "password", "passwd", "token", "secret", "api_key", "apikey", "access_token", + "refresh_token", "authorization", "auth", +]; + +/// Sanitize one runtime log line for display and export. +pub fn sanitize_log_line(line: &str) -> String { + let mut out = redact_bearer_tokens(line); + out = redact_sensitive_key_values(&out); + out = redact_urls_in_text(&out); + out +} + +/// Never panic on the logging hot path — fall back to the raw line if needed. +pub fn sanitize_log_line_infallible(line: &str) -> String { + std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string()) +} + +fn redact_bearer_tokens(line: &str) -> String { + let marker = "Bearer "; + let mut s = line.to_string(); + let mut search_from = 0; + while let Some(rel) = s[search_from..].find(marker) { + let idx = search_from + rel; + let start = idx + marker.len(); + let end = s[start..] + .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']') + .map(|i| start + i) + .unwrap_or(s.len()); + if end > start { + s.replace_range(start..end, "REDACTED"); + } + search_from = start + "REDACTED".len(); + } + s +} + +fn redact_sensitive_key_values(line: &str) -> String { + let mut out = line.to_string(); + for key in SENSITIVE_KV_KEYS { + for sep in [':', '='] { + let needle = format!("{key}{sep}"); + let lower = out.to_ascii_lowercase(); + let mut search_from = 0; + while let Some(rel) = lower[search_from..].find(&needle) { + let idx = search_from + rel; + let val_start = idx + needle.len(); + let slice = &out[val_start..]; + let trimmed = slice.trim_start(); + let ws = slice.len().saturating_sub(trimmed.len()); + let val_start = val_start + ws; + let end = trimmed + .find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')') + .unwrap_or(trimmed.len()); + if end > 0 { + out.replace_range(val_start..val_start + end, "REDACTED"); + } + search_from = val_start + "REDACTED".len(); + if search_from >= out.len() { + break; + } + } + } + } + out +} + +fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool { + if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' { + return true; + } + if ch == ')' || ch == ']' || ch == ',' { + if let Some(next) = s[byte_off..].chars().nth(1) { + return next.is_whitespace() || next == '"' || next == '\''; + } + } + false +} + +fn redact_urls_in_text(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut cursor = 0; + while cursor < line.len() { + let slice = &line[cursor..]; + let rel = match (slice.find("http://"), slice.find("https://")) { + (Some(h), Some(s)) => Some(h.min(s)), + (Some(h), None) => Some(h), + (None, Some(s)) => Some(s), + (None, None) => None, + }; + let Some(rel) = rel else { + out.push_str(slice); + break; + }; + out.push_str(&slice[..rel]); + let url_start = cursor + rel; + let url_slice = &line[url_start..]; + let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 }; + let mut url_end = scheme_len; + for (off, ch) in url_slice[scheme_len..].char_indices() { + let abs = scheme_len + off; + if url_char_ends_url(ch, url_slice, abs) { + break; + } + url_end = abs + ch.len_utf8(); + } + out.push_str(&redact_url(&line[url_start..url_start + url_end])); + cursor = url_start + url_end; + } + out +} + +fn redact_url(raw: &str) -> String { + let (url, suffix) = split_trailing_punct(raw); + let mut out = String::new(); + + let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0); + out.push_str(&url[..scheme_end]); + + let mut rest = &url[scheme_end..]; + if let Some(at) = rest.rfind('@') { + // Drop userinfo entirely. + out.push_str("***@"); + rest = &rest[at + 1..]; + } + + let (hostport, path) = split_host_path(rest); + let (host, port) = split_host_port(&hostport); + let masked_host = mask_hostname(&host); + out.push_str(&masked_host); + if let Some(p) = port { + out.push(':'); + out.push_str(&p); + } + if let Some((path_only, query)) = path.split_once('?') { + out.push_str(path_only); + out.push('?'); + out.push_str(&redact_query_string(query)); + } else { + out.push_str(&path); + } + + format!("{out}{suffix}") +} + +fn split_trailing_punct(raw: &str) -> (&str, &str) { + let mut end = raw.len(); + while end > 0 { + let ch = raw.as_bytes()[end - 1] as char; + if ch == ')' || ch == ']' || ch == ',' { + end -= 1; + continue; + } + break; + } + (&raw[..end], &raw[end..]) +} + +fn split_host_path(rest: &str) -> (String, String) { + if rest.starts_with('[') { + if let Some(end) = rest.find(']') { + let hostport = &rest[..=end]; + return (hostport.to_string(), rest[end + 1..].to_string()); + } + } + if let Some(slash) = rest.find('/') { + (rest[..slash].to_string(), rest[slash..].to_string()) + } else { + (rest.to_string(), String::new()) + } +} + +fn split_host_port(hostport: &str) -> (String, Option) { + if hostport.starts_with('[') { + if let Some(end) = hostport.find("]:") { + return ( + hostport[..=end].to_string(), + Some(hostport[end + 2..].to_string()), + ); + } + return (hostport.to_string(), None); + } + if let Some((h, p)) = hostport.rsplit_once(':') { + if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') { + return (h.to_string(), Some(p.to_string())); + } + } + (hostport.to_string(), None) +} + +fn redact_query_string(query: &str) -> String { + query + .split('&') + .map(|pair| { + let (k, _v) = pair.split_once('=').unwrap_or((pair, "")); + if is_sensitive_query_key(k) { + format!("{k}=REDACTED") + } else { + pair.to_string() + } + }) + .collect::>() + .join("&") +} + +fn is_sensitive_query_key(key: &str) -> bool { + let k = key.trim().to_ascii_lowercase(); + SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k) +} + +fn is_lan_ipv4(ip: &str) -> bool { + let parts: Vec<&str> = ip.split('.').collect(); + if parts.len() != 4 { + return false; + } + let Ok(a) = parts[0].parse::() else { return false }; + let Ok(b) = parts[1].parse::() else { return false }; + a == 127 + || a == 10 + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) +} + +fn is_lan_ipv6(host: &str) -> bool { + let h = host.to_ascii_lowercase(); + if h == "::1" { + return true; + } + if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") { + return true; + } + if h.starts_with("fc") || h.starts_with("fd") { + return true; + } + if let Some(rest) = h.strip_prefix("::ffff:") { + if rest.contains('.') { + return is_lan_ipv4(rest); + } + if let Some((a, b)) = rest.split_once(':') { + if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) { + let ip = format!( + "{}.{}.{}.{}", + (v1 >> 8) & 0xff, + v1 & 0xff, + (v2 >> 8) & 0xff, + v2 & 0xff + ); + return is_lan_ipv4(&ip); + } + } + } + false +} + +fn is_lan_host(host: &str) -> bool { + let stripped = host.trim().trim_matches(|c| c == '[' || c == ']'); + let lower = stripped.to_ascii_lowercase(); + if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") { + return true; + } + if stripped.contains(':') { + return is_lan_ipv6(stripped); + } + if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 { + return is_lan_ipv4(stripped); + } + false +} + +fn mask_label_prefix(label: &str) -> String { + let mut chars = label.chars(); + let c1 = chars.next(); + let c2 = chars.next(); + match (c1, c2) { + (None, _) => "*".to_string(), + (Some(a), None) => a.to_string(), + (Some(a), Some(b)) => { + let rest = label.chars().count().saturating_sub(2); + let stars = rest.clamp(1, 4); + format!("{a}{b}{}", "*".repeat(stars)) + } + } +} + +fn mask_public_ipv4(ip: &str) -> String { + let parts: Vec<&str> = ip.split('.').collect(); + if parts.len() != 4 { + return "***".to_string(); + } + format!("{}.*.*.{}", parts[0], parts[3]) +} + +fn mask_hostname(host: &str) -> String { + let stripped = host.trim().trim_matches(|c| c == '[' || c == ']'); + if is_lan_host(stripped) { + return host.to_string(); + } + + if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 { + return mask_public_ipv4(stripped); + } + + if stripped.contains(':') { + return "[ipv6-redacted]".to_string(); + } + + let parts: Vec<&str> = stripped.split('.').collect(); + if parts.is_empty() { + return "***".to_string(); + } + + let masked_first = mask_label_prefix(parts[0]); + + if parts.len() == 1 { + masked_first + } else { + format!("{}.{}", masked_first, parts[1..].join(".")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_subsonic_wire_auth_params() { + let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi"; + let out = sanitize_log_line(line); + assert!(out.contains("t=REDACTED")); + assert!(out.contains("s=REDACTED")); + assert!(out.contains("p=REDACTED")); + assert!(!out.contains("abc")); + } + + #[test] + fn masks_remote_hostname_keeps_lan_ip() { + let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping"); + assert!(remote.contains("my****.example.com")); + assert!(!remote.contains("my-server.example.com")); + + let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping"); + assert!(lan.contains("192.168.1.42")); + } + + #[test] + fn redacts_bearer_and_password_kv() { + let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit"; + let out = sanitize_log_line(line); + assert!(out.contains("Bearer REDACTED")); + assert!(!out.contains("eyJhbGci")); + assert!(out.contains("password=REDACTED")); + assert!(!out.contains("sekrit")); + } + + #[test] + fn strips_url_userinfo() { + let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping"; + let out = sanitize_log_line(line); + assert!(out.contains("***@10.0.0.5")); + assert!(!out.contains("user:pass")); + } + + #[test] + fn stream_log_with_em_dash_does_not_panic() { + let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")"; + let out = sanitize_log_line(line); + assert!(out.contains('—')); + assert!(out.contains("RangedHttpSource")); + } +} diff --git a/src-tauri/crates/psysonic-core/src/logging.rs b/src-tauri/crates/psysonic-core/src/logging.rs index b387b700..2cab64ec 100644 --- a/src-tauri/crates/psysonic-core/src/logging.rs +++ b/src-tauri/crates/psysonic-core/src/logging.rs @@ -101,6 +101,7 @@ pub fn should_log_debug() -> bool { } pub fn append_log_line(line: String) { + let line = crate::log_sanitize::sanitize_log_line_infallible(&line); let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1; { let mut buf = log_buffer().lock().unwrap(); diff --git a/src-tauri/src/lib_commands/app_api/core.rs b/src-tauri/src/lib_commands/app_api/core.rs index 19f81fc5..3312e1e9 100644 --- a/src-tauri/src/lib_commands/app_api/core.rs +++ b/src-tauri/src/lib_commands/app_api/core.rs @@ -49,8 +49,8 @@ pub(crate) struct LogTailDto { pub dropped: bool, } -/// Incremental tail of the in-memory runtime log buffer for the Performance -/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for +/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab. +/// `after_seq` is the highest seq the UI already has (omit for /// the initial fetch of the most recent `max` lines). #[tauri::command] pub(crate) fn tail_runtime_logs(after_seq: Option, max: Option) -> LogTailDto { diff --git a/src/components/FpsOverlay.tsx b/src/components/FpsOverlay.tsx index d86b0757..35950222 100644 --- a/src/components/FpsOverlay.tsx +++ b/src/components/FpsOverlay.tsx @@ -59,7 +59,7 @@ function LiveOverlayPinnedMetric({ ); } -/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */ +/** FPS + pipeline + pinned live metrics overlay (PsyLab). */ export default function FpsOverlay() { const overlayMode = usePerfOverlayMode(); const perfFlags = usePerfProbeFlags(); diff --git a/src/components/sidebar/SidebarPerfProbeModal.tsx b/src/components/sidebar/SidebarPerfProbeModal.tsx index 6b20a20e..1577380e 100644 --- a/src/components/sidebar/SidebarPerfProbeModal.tsx +++ b/src/components/sidebar/SidebarPerfProbeModal.tsx @@ -1,15 +1,16 @@ import { useState } from 'react'; -import { Activity, ScrollText, SlidersHorizontal, X } from 'lucide-react'; +import { Activity, ScrollText, SlidersHorizontal, Wrench, X } from 'lucide-react'; import { createPortal } from 'react-dom'; import SidebarPerfProbeMonitorTab from './perfProbe/SidebarPerfProbeMonitorTab'; import SidebarPerfProbeTogglesTab from './perfProbe/SidebarPerfProbeTogglesTab'; +import SidebarPerfProbeTuningTab from './perfProbe/SidebarPerfProbeTuningTab'; import SidebarPerfProbeLogsTab from './perfProbe/SidebarPerfProbeLogsTab'; import { resetPerfProbeFlags, type PerfProbeFlags } from '../../utils/perf/perfFlags'; import { clearPerfLiveOverlayPins } from '../../utils/perf/perfOverlayPins'; import { resetPerfOverlayAppearance } from '../../utils/perf/perfOverlayAppearance'; import { resetPerfOverlayMode } from '../../utils/perf/perfOverlayMode'; -type TabId = 'monitor' | 'toggles' | 'logs'; +type TabId = 'monitor' | 'toggles' | 'tuning' | 'logs'; interface Props { open: boolean; @@ -51,7 +52,7 @@ export default function SidebarPerfProbeModal({ onClick={() => onClose()} role="dialog" aria-modal="true" - aria-labelledby="perf-probe-title" + aria-labelledby="psylab-title" >
-

Performance Probe

+

PsyLab

- Live metrics with optional on-screen overlays, plus diagnostic disable toggles. + Live metrics with optional on-screen overlays, runtime tuning, and diagnostic disable toggles.

-
+
+ + +
{visible.length === 0 ? (
@@ -239,6 +361,28 @@ export default function SidebarPerfProbeLogsTab() { )}
+ + {contextMenu && createPortal( +
e.preventDefault()} + > + +
, + document.body, + )}
); } diff --git a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx index 68a6a966..03d8315e 100644 --- a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx +++ b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx @@ -12,7 +12,6 @@ import PerfProbeMetricCard, { PerfProbeMetricSection } from './PerfProbeMetricCa import PerfOverlayAppearanceControls from './PerfOverlayAppearanceControls'; import PerfOverlayModeControls from './PerfOverlayModeControls'; import PerfLivePollControls from './PerfLivePollControls'; -import PerfCoverThreadsControl from './PerfCoverThreadsControl'; function memoryBarPct(rssKb: number, maxKb: number): number { if (maxKb <= 0) return 0; @@ -61,7 +60,6 @@ export default function SidebarPerfProbeMonitorTab() { - +

+ Live runtime knobs for experiments — not persisted in Settings. Values reset on restart unless noted. +

+ +
+ ); +} diff --git a/src/hooks/useSidebarPerfProbe.ts b/src/hooks/useSidebarPerfProbe.ts index 25e6e9cb..8fcedfff 100644 --- a/src/hooks/useSidebarPerfProbe.ts +++ b/src/hooks/useSidebarPerfProbe.ts @@ -45,7 +45,7 @@ function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet { + it('redacts Subsonic wire-auth query params', () => { + const line = 'GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi'; + const out = sanitizeLogLine(line); + expect(out).toContain('t=REDACTED'); + expect(out).toContain('s=REDACTED'); + expect(out).toContain('p=REDACTED'); + expect(out).not.toContain('abc'); + }); + + it('masks remote hostnames but keeps LAN IPs', () => { + const remote = sanitizeLogLine('connect https://my-server.example.com:4533/rest/ping'); + expect(remote).toContain('my****.example.com'); + expect(remote).not.toContain('my-server.example.com'); + + const lan = sanitizeLogLine('connect http://192.168.1.42:4533/rest/ping'); + expect(lan).toContain('192.168.1.42'); + }); + + it('handles stream logs with em dashes (UTF-8 safe)', () => { + const line = '[stream] RangedHttpSource selected — total=15666KB, hint=Some("mp3")'; + expect(() => sanitizeLogLine(line)).not.toThrow(); + expect(sanitizeLogLine(line)).toContain('—'); + }); + + it('redacts bearer tokens and password key/value pairs', () => { + const line = 'auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit'; + const out = sanitizeLogLine(line); + expect(out).toContain('Bearer REDACTED'); + expect(out).not.toContain('eyJhbGci'); + expect(out).toContain('password=REDACTED'); + expect(out).not.toContain('sekrit'); + }); +}); diff --git a/src/utils/perf/sanitizeLogLine.ts b/src/utils/perf/sanitizeLogLine.ts new file mode 100644 index 00000000..ff2837ae --- /dev/null +++ b/src/utils/perf/sanitizeLogLine.ts @@ -0,0 +1,235 @@ +/** + * Redact secrets and partially mask remote server hostnames in PsyLab log lines. + * Mirrors `psysonic_core::log_sanitize` (defense in depth for lines already buffered). + */ + +const SENSITIVE_QUERY_KEYS = new Set([ + 't', 's', 'p', 'token', 'password', 'passwd', 'secret', 'api_key', 'apikey', + 'access_token', 'refresh_token', 'auth', +]); + +const SENSITIVE_KV_KEYS = [ + 'password', 'passwd', 'token', 'secret', 'api_key', 'apikey', + 'access_token', 'refresh_token', 'authorization', 'auth', +]; + +function isIpv4LanLiteral(ip: string): boolean { + const parts = ip.split('.'); + if (parts.length !== 4) return false; + const a = Number(parts[0]); + const b = Number(parts[1]); + if (!Number.isInteger(a) || !Number.isInteger(b)) return false; + return ( + a === 127 || + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); +} + +function isIpv6LanHostname(hostname: string): boolean { + const h = hostname.toLowerCase(); + if (h === '::1') return true; + if (/^fe[89ab][0-9a-f]:/.test(h)) return true; + if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; + const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h); + if (dotted) return isIpv4LanLiteral(dotted[1]!); + const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h); + 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; +} + +function isLanHost(host: string): boolean { + const stripped = host.replace(/^\[|\]$/g, '').trim().toLowerCase(); + if (!stripped || stripped === 'localhost' || stripped.endsWith('.local')) return true; + if (stripped.includes(':')) return isIpv6LanHostname(stripped); + if (/^\d+\.\d+\.\d+\.\d+$/.test(stripped)) return isIpv4LanLiteral(stripped); + return false; +} + +function maskPublicIpv4(ip: string): string { + const parts = ip.split('.'); + if (parts.length !== 4) return '***'; + return `${parts[0]}.*.*.${parts[3]}`; +} + +function maskHostname(host: string): string { + const stripped = host.replace(/^\[|\]$/g, ''); + if (isLanHost(stripped)) return host; + + if (/^\d+\.\d+\.\d+\.\d+$/.test(stripped)) return maskPublicIpv4(stripped); + if (stripped.includes(':')) return '[ipv6-redacted]'; + + const parts = stripped.split('.'); + if (parts.length === 0) return '***'; + + const first = parts[0]!; + const maskedFirst = first.length <= 2 + ? '*'.repeat(Math.max(1, first.length)) + : `${first.slice(0, 2)}${'*'.repeat(Math.min(4, Math.max(1, first.length - 2)))}`; + + return parts.length === 1 ? maskedFirst : `${maskedFirst}.${parts.slice(1).join('.')}`; +} + +function splitHostPort(hostport: string): [string, string | null] { + if (hostport.startsWith('[')) { + const end = hostport.indexOf(']:'); + if (end !== -1) return [hostport.slice(0, end + 1), hostport.slice(end + 2)]; + return [hostport, null]; + } + const colon = hostport.lastIndexOf(':'); + if (colon > 0) { + const host = hostport.slice(0, colon); + const port = hostport.slice(colon + 1); + if (/^\d+$/.test(port) && !host.includes(':')) return [host, port]; + } + return [hostport, null]; +} + +function splitHostPath(rest: string): [string, string] { + if (rest.startsWith('[')) { + const end = rest.indexOf(']'); + if (end !== -1) return [rest.slice(0, end + 1), rest.slice(end + 1)]; + } + const slash = rest.indexOf('/'); + if (slash === -1) return [rest, '']; + return [rest.slice(0, slash), rest.slice(slash)]; +} + +function redactQueryString(query: string): string { + return query.split('&').map(pair => { + const eq = pair.indexOf('='); + const key = (eq === -1 ? pair : pair.slice(0, eq)).trim().toLowerCase(); + if (SENSITIVE_QUERY_KEYS.has(key)) { + const rawKey = eq === -1 ? pair : pair.slice(0, eq); + return `${rawKey}=REDACTED`; + } + return pair; + }).join('&'); +} + +function splitTrailingPunct(raw: string): [string, string] { + let end = raw.length; + while (end > 0) { + const ch = raw[end - 1]!; + if (ch === ')' || ch === ']' || ch === ',') { + end -= 1; + continue; + } + break; + } + return [raw.slice(0, end), raw.slice(end)]; +} + +function redactUrl(raw: string): string { + const [url, suffix] = splitTrailingPunct(raw); + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) return raw; + + let out = url.slice(0, schemeEnd + 3); + let rest = url.slice(schemeEnd + 3); + + const at = rest.lastIndexOf('@'); + if (at !== -1) { + out += '***@'; + rest = rest.slice(at + 1); + } + + const [hostport, path] = splitHostPath(rest); + const [host, port] = splitHostPort(hostport); + out += maskHostname(host); + if (port) out += `:${port}`; + + const q = path.indexOf('?'); + if (q === -1) { + out += path; + } else { + out += path.slice(0, q + 1); + out += redactQueryString(path.slice(q + 1)); + } + + return out + suffix; +} + +function redactBearerTokens(line: string): string { + const marker = 'Bearer '; + let s = line; + let searchFrom = 0; + while (true) { + const idx = s.indexOf(marker, searchFrom); + if (idx === -1) break; + const start = idx + marker.length; + const tail = s.slice(start); + const endRel = tail.search(/[\s"')\]]/); + const end = endRel === -1 ? s.length : start + endRel; + if (end > start) { + s = `${s.slice(0, start)}REDACTED${s.slice(end)}`; + } + searchFrom = start + 'REDACTED'.length; + } + return s; +} + +function redactSensitiveKeyValues(line: string): string { + let out = line; + for (const key of SENSITIVE_KV_KEYS) { + for (const sep of [':', '='] as const) { + const needle = `${key}${sep}`; + const lower = out.toLowerCase(); + let searchFrom = 0; + while (true) { + const rel = lower.indexOf(needle, searchFrom); + if (rel === -1) break; + const idx = rel; + let valStart = idx + needle.length; + while (out[valStart] === ' ') valStart += 1; + const tail = out.slice(valStart); + const endRel = tail.search(/[\s&,;)]/); + const end = endRel === -1 ? out.length : valStart + endRel; + if (end > valStart) { + out = `${out.slice(0, valStart)}REDACTED${out.slice(end)}`; + } + searchFrom = valStart + 'REDACTED'.length; + if (searchFrom >= out.length) break; + } + } + } + return out; +} + +function redactUrlsInText(line: string): string { + let out = ''; + let i = 0; + while (i < line.length) { + const http = line.startsWith('http://', i); + const https = line.startsWith('https://', i); + const schemeLen = http ? 7 : https ? 8 : 0; + if (schemeLen > 0) { + const start = i; + i += schemeLen; + while (i < line.length) { + const c = line[i]!; + if (/\s/.test(c) || c === '"' || c === "'" || c === '>') break; + if ((c === ')' || c === ']' || c === ',') && i + 1 < line.length) { + const next = line[i + 1]!; + if (/\s/.test(next) || next === '"' || next === "'") break; + } + i += 1; + } + out += redactUrl(line.slice(start, i)); + } else { + out += line[i]; + i += 1; + } + } + return out; +} + +export function sanitizeLogLine(line: string): string { + return redactUrlsInText(redactSensitiveKeyValues(redactBearerTokens(line))); +} diff --git a/src/utils/server/redactSubsonicUrl.ts b/src/utils/server/redactSubsonicUrl.ts index d3f78424..bd0238f8 100644 --- a/src/utils/server/redactSubsonicUrl.ts +++ b/src/utils/server/redactSubsonicUrl.ts @@ -3,7 +3,7 @@ * (`t` salt, `s` token hash, `p` password when present.) */ export function redactSubsonicUrlForLog(url: string): string { - if (!url || !url.includes('stream.view')) return url; + if (!url) return url; try { const u = new URL(url); // Placeholder must stay URL-safe (no `<>` — URLSearchParams percent-encodes them).