mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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