mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)
* feat(psylab): rename probe UI, add Tuning tab and log copy/export PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain selectable text, toolbar copy/export, and a selection-only context menu. * fix(logging): redact secrets and mask remote hosts in runtime logs Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab: Subsonic/auth query params, bearer tokens, password fields, URL userinfo; remote hostnames partially starred, LAN/localhost left readable. * fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream logs), killing tokio workers and aborting playback. Iterate by char boundary; add infallible wrapper on the append hot path. * docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>) {
|
||||
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::<Vec<_>>()
|
||||
.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::<u8>() else { return false };
|
||||
let Ok(b) = parts[1].parse::<u8>() 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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<u64>, max: Option<usize>) -> LogTailDto {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div
|
||||
className="modal-content sidebar-perf-modal"
|
||||
@@ -62,13 +63,13 @@ export default function SidebarPerfProbeModal({
|
||||
</button>
|
||||
|
||||
<header className="sidebar-perf-modal__header">
|
||||
<h3 id="perf-probe-title" className="modal-title">Performance Probe</h3>
|
||||
<h3 id="psylab-title" className="modal-title">PsyLab</h3>
|
||||
<p className="sidebar-perf-modal__hint">
|
||||
Live metrics with optional on-screen overlays, plus diagnostic disable toggles.
|
||||
Live metrics with optional on-screen overlays, runtime tuning, and diagnostic disable toggles.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="Performance probe sections">
|
||||
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="PsyLab sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -89,6 +90,16 @@ export default function SidebarPerfProbeModal({
|
||||
<SlidersHorizontal size={15} />
|
||||
Toggles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'tuning'}
|
||||
className={`sidebar-perf-modal__tab${tab === 'tuning' ? ' sidebar-perf-modal__tab--active' : ''}`}
|
||||
onClick={() => setTab('tuning')}
|
||||
>
|
||||
<Wrench size={15} />
|
||||
Tuning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -103,6 +114,7 @@ export default function SidebarPerfProbeModal({
|
||||
|
||||
<div className={`sidebar-perf-modal__body${tab === 'logs' ? ' sidebar-perf-modal__body--logs' : ''}`}>
|
||||
{tab === 'monitor' && <SidebarPerfProbeMonitorTab />}
|
||||
{tab === 'tuning' && <SidebarPerfProbeTuningTab />}
|
||||
{tab === 'toggles' && (
|
||||
<SidebarPerfProbeTogglesTab
|
||||
perfFlags={perfFlags}
|
||||
|
||||
@@ -10,7 +10,7 @@ const COVER_THREADS_MIN = 1;
|
||||
const COVER_THREADS_MAX = 16;
|
||||
|
||||
/**
|
||||
* Perf-probe-only knob for cover backfill concurrency (download + encode pools
|
||||
* PsyLab tuning knob for cover backfill concurrency (download + encode pools
|
||||
* move together). Deliberately not surfaced in app Settings — it is a live
|
||||
* diagnostics/experiment control. The value is process-local and resets to the
|
||||
* backend default on app restart.
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Pause, Play, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { Copy, Download, Pause, Play, Trash2 } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { getLoggingMode, tailRuntimeLogs, type RuntimeLogLine } from '../../../api/runtimeLogs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import type { LoggingMode } from '../../../store/authStoreTypes';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import { filterLogLines } from '../../../utils/perf/filterLogLines';
|
||||
import { sanitizeLogLine } from '../../../utils/perf/sanitizeLogLine';
|
||||
|
||||
function formatLogLinesText(lines: RuntimeLogLine[]): string {
|
||||
return lines.map(line => line.text).join('\n');
|
||||
}
|
||||
|
||||
const POLL_MS = 750;
|
||||
const BOTTOM_EPSILON = 24;
|
||||
@@ -40,6 +56,10 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
const [lineCap, setLineCap] = useState(1000);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [overflowed, setOverflowed] = useState(false);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'ok' | 'error'>('idle');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const lastSeqRef = useRef<number | null>(null);
|
||||
const pausedRef = useRef(paused);
|
||||
@@ -76,7 +96,13 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
if (!cancelled && tail.lines.length > 0) {
|
||||
lastSeqRef.current = tail.lastSeq;
|
||||
setLines(prev => {
|
||||
const next = [...prev, ...tail.lines];
|
||||
const next = [
|
||||
...prev,
|
||||
...tail.lines.map(line => ({
|
||||
...line,
|
||||
text: sanitizeLogLine(line.text),
|
||||
})),
|
||||
];
|
||||
// Only trim from the top while following; otherwise keep history
|
||||
// under the reader's viewport up to the hard ceiling.
|
||||
const cap = followRef.current ? lineCapRef.current : MAX_BUFFER;
|
||||
@@ -156,6 +182,80 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
setOverflowed(false);
|
||||
};
|
||||
|
||||
const selectedLogText = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
const sel = window.getSelection();
|
||||
if (!el || !sel || sel.isCollapsed || sel.rangeCount === 0) return '';
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.commonAncestorContainer)) return '';
|
||||
return sel.toString();
|
||||
}, []);
|
||||
|
||||
const copyText = async (text: string, feedback = true) => {
|
||||
if (!text) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (feedback) setCopyState('ok');
|
||||
return true;
|
||||
} catch {
|
||||
if (feedback) setCopyState('error');
|
||||
return false;
|
||||
} finally {
|
||||
if (feedback) window.setTimeout(() => setCopyState('idle'), 1600);
|
||||
}
|
||||
};
|
||||
|
||||
const copyAllShown = () => copyText(formatLogLinesText(visible));
|
||||
|
||||
const copySelection = () => copyText(selectedLogText(), false);
|
||||
|
||||
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current?.contains(e.target as Node)) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeContextMenu();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
const onLogContextMenu = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selected = selectedLogText().trim();
|
||||
if (!selected) return;
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const exportVisible = async () => {
|
||||
if (visible.length === 0 || exporting) return;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const selected = await saveDialog({
|
||||
defaultPath: `psysonic-psylab-logs-${stamp}.log`,
|
||||
filters: [{ name: 'Log files', extensions: ['log', 'txt'] }],
|
||||
title: 'Export shown log lines',
|
||||
});
|
||||
if (!selected || Array.isArray(selected)) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(`${formatLogLinesText(visible)}\n`);
|
||||
await writeFile(selected, bytes);
|
||||
} catch {
|
||||
/* user cancelled or write failed */
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="perf-logs">
|
||||
<div className="perf-logs__controls">
|
||||
@@ -189,6 +289,26 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
<Trash2 size={14} />
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="perf-logs__btn"
|
||||
onClick={() => void copyAllShown()}
|
||||
disabled={visible.length === 0}
|
||||
title="Copy all lines currently shown in the log view"
|
||||
>
|
||||
<Copy size={14} />
|
||||
{copyState === 'ok' ? 'Copied' : copyState === 'error' ? 'Copy failed' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="perf-logs__btn"
|
||||
onClick={() => void exportVisible()}
|
||||
disabled={visible.length === 0 || exporting}
|
||||
title="Export shown lines to a file"
|
||||
>
|
||||
<Download size={14} />
|
||||
{exporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -204,8 +324,10 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
className="perf-logs__view"
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
onContextMenu={onLogContextMenu}
|
||||
role="log"
|
||||
aria-live="off"
|
||||
data-selectable
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<div className="perf-logs__empty">
|
||||
@@ -239,6 +361,28 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu && createPortal(
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="context-menu perf-logs__context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="context-menu-item perf-logs__context-item"
|
||||
onClick={() => {
|
||||
void copySelection();
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
Copy
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<PerfOverlayModeControls />
|
||||
<PerfOverlayAppearanceControls />
|
||||
<PerfLivePollControls />
|
||||
<PerfCoverThreadsControl />
|
||||
<PerfProbeMetricSection title="Pipeline overlays" hint="Rust / UI queues">
|
||||
<PerfProbeMetricCard
|
||||
label="FPS"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import PerfCoverThreadsControl from './PerfCoverThreadsControl';
|
||||
|
||||
export default function SidebarPerfProbeTuningTab() {
|
||||
return (
|
||||
<div className="perf-tuning">
|
||||
<p className="sidebar-perf-modal__hint perf-tuning__hint">
|
||||
Live runtime knobs for experiments — not persisted in Settings. Values reset on restart unless noted.
|
||||
</p>
|
||||
<PerfCoverThreadsControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<str
|
||||
);
|
||||
}
|
||||
|
||||
/** Wires Ctrl+Shift+D probe modal and shared live metric polling. */
|
||||
/** Wires Ctrl+Shift+D PsyLab modal and shared live metric polling. */
|
||||
export function useSidebarPerfProbe(): Result {
|
||||
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
|
||||
const livePins = usePerfLiveOverlayPins();
|
||||
|
||||
@@ -219,12 +219,27 @@
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.perf-logs__line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.perf-tuning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.perf-tuning__hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.perf-logs__line--marker {
|
||||
@@ -265,6 +280,21 @@
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.perf-logs__context-menu {
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.perf-logs__context-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.perf-monitor-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeLogLine } from './sanitizeLogLine';
|
||||
|
||||
describe('sanitizeLogLine', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user