mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(settings): harden settings and diagnostic controls
This commit is contained in:
@@ -5868,6 +5868,7 @@ async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathB
|
||||
pub(crate) fn redact_sensitive_text(line: &str) -> String {
|
||||
use std::sync::OnceLock;
|
||||
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static QUOTED_SECRET: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static USERINFO: OnceLock<regex::Regex> = OnceLock::new();
|
||||
@@ -5878,6 +5879,12 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String {
|
||||
)
|
||||
.expect("valid secret redaction regex")
|
||||
});
|
||||
let quoted_secret = QUOTED_SECRET.get_or_init(|| {
|
||||
regex::Regex::new(
|
||||
r#"(?i)(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#,
|
||||
)
|
||||
.expect("valid quoted secret redaction regex")
|
||||
});
|
||||
let header = HEADER.get_or_init(|| {
|
||||
regex::Regex::new(
|
||||
r"(?i)(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+",
|
||||
@@ -5897,6 +5904,7 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String {
|
||||
.expect("valid URL fragment redaction regex")
|
||||
});
|
||||
let redacted = header.replace_all(line, "$1: [redacted]");
|
||||
let redacted = quoted_secret.replace_all(&redacted, "$1$2$3$4[redacted]");
|
||||
let redacted = secret.replace_all(&redacted, "$1=[redacted]");
|
||||
let redacted = userinfo.replace_all(&redacted, "$1[redacted]@");
|
||||
let redacted = query.replace_all(&redacted, "$1?[redacted]");
|
||||
@@ -7092,6 +7100,15 @@ mod tests {
|
||||
assert!(redacted.contains("http://[redacted]@example.com/file#[redacted]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_quoted_json_credentials() {
|
||||
let line = r#"{"api_key":"json-secret","cookie":"session=secret"}"#;
|
||||
let redacted = redact_log_line(line);
|
||||
assert!(!redacted.contains("json-secret"));
|
||||
assert!(!redacted.contains("session=secret"));
|
||||
assert!(redacted.contains("[redacted]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_primary_url_and_unique_mirrors_in_order() {
|
||||
let uris = collect_download_uris(
|
||||
|
||||
+15
-1
@@ -12,7 +12,7 @@ import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
@@ -27,6 +27,7 @@ import { getKeychainConsentVersion, getKeychainStartupDecision } from './utils/k
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
@@ -144,6 +145,14 @@ function App() {
|
||||
// briefly rendering underneath native or custom window controls.
|
||||
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
||||
|
||||
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
||||
addToast({
|
||||
message: 'Could not save settings. Check storage permissions and try again.',
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}), [addToast]);
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
console.error('Failed to acknowledge pairing token migration notice:', error);
|
||||
@@ -515,6 +524,9 @@ function App() {
|
||||
invoke('check_for_updates')
|
||||
.then(result => {
|
||||
if (result.type !== 'UpdateAvailable') return;
|
||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||
throw new Error('The update check returned an untrusted release URL.');
|
||||
}
|
||||
addToast({
|
||||
variant: 'info',
|
||||
isActionable: true,
|
||||
@@ -657,6 +669,7 @@ function App() {
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
if (!schedulerRunning) return;
|
||||
if (schedulerActiveDownloadIds.length === 0) return;
|
||||
clearPendingPostActionTimer();
|
||||
@@ -687,6 +700,7 @@ function App() {
|
||||
}, [
|
||||
addToast,
|
||||
clearPendingPostActionTimer,
|
||||
coreReady,
|
||||
downloads,
|
||||
schedulePostQueueAction,
|
||||
schedulerRunning,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
|
||||
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
@@ -24,6 +25,7 @@ export default function LogsView() {
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
||||
const homeDirectoryRef = useRef('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const liveBatchRef = useRef<LogEntry[]>([]);
|
||||
const liveFrameRef = useRef<number | null>(null);
|
||||
@@ -33,6 +35,18 @@ export default function LogsView() {
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void homeDir()
|
||||
.then(directory => {
|
||||
if (active) homeDirectoryRef.current = directory;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -80,7 +94,7 @@ export default function LogsView() {
|
||||
if (logsEnabled) {
|
||||
unlistenPromise = attachLogger((log) => {
|
||||
if (!active) return;
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message));
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message, new Date(), homeDirectoryRef.current));
|
||||
});
|
||||
await unlistenPromise;
|
||||
if (!active) return;
|
||||
@@ -93,7 +107,7 @@ export default function LogsView() {
|
||||
|
||||
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
||||
if (!active) return;
|
||||
const snapshot = lines.map(persistedLogEntry);
|
||||
const snapshot = lines.map(line => persistedLogEntry(line, homeDirectoryRef.current));
|
||||
initialized = true;
|
||||
if (initGeneration !== clearGenerationRef.current) {
|
||||
pendingLiveEntries = [];
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../utils/downloadLocations';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
@@ -76,6 +77,8 @@ type ManualUpdateStatus =
|
||||
| { type: 'update-available'; version: string; releaseUrl: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error';
|
||||
|
||||
const engineStatusCache = new Map<string, EngineStatusItem>();
|
||||
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
|
||||
|
||||
@@ -276,8 +279,9 @@ const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null
|
||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
||||
const engineRunId = useRef(0);
|
||||
const [appVersion, setAppVersion] = useState('Unknown');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [appVersion, setAppVersion] = useState('Unknown');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('idle');
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
@@ -320,6 +324,27 @@ useEffect(() => {
|
||||
};
|
||||
}, [settings.activeView, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.activeView !== 'settings' || activeTab !== 'network' || settings.proxyMode !== 'system') {
|
||||
setSystemProxyStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setSystemProxyStatus('checking');
|
||||
invoke('get_system_proxy')
|
||||
.then(proxy => {
|
||||
if (active) setSystemProxyStatus(typeof proxy === 'string' && proxy.trim() ? 'detected' : 'none');
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setSystemProxyStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [settings.activeView, settings.proxyMode, activeTab]);
|
||||
|
||||
const runEngineChecks = useCallback((force = false) => {
|
||||
const runId = ++engineRunId.current;
|
||||
const cached = engineChecks
|
||||
@@ -880,10 +905,20 @@ runEngineChecks(false);
|
||||
<p className="settings-group-footer">
|
||||
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
|
||||
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`}
|
||||
{settings.proxyMode === 'custom' && (settings.proxyHost
|
||||
{settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort)
|
||||
? 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.'
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
: settings.proxyHost
|
||||
? 'Enter a valid HTTP proxy host and port to enable the custom proxy.'
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
</p>
|
||||
{settings.proxyMode === 'system' && (
|
||||
<p className="settings-group-footer" role="status">
|
||||
{systemProxyStatus === 'checking' && 'Checking system proxy configuration…'}
|
||||
{systemProxyStatus === 'detected' && 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.'}
|
||||
{systemProxyStatus === 'none' && 'No usable system proxy was detected. Downloads will use no proxy.'}
|
||||
{systemProxyStatus === 'error' && 'System proxy configuration could not be read. Downloads will use no proxy until it is available.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Identity</h2>
|
||||
|
||||
@@ -200,6 +200,9 @@ describe('useDownloadStore', () => {
|
||||
expect(normalizeCustomProxy(' socks5://127.0.0.1 ', 1080)).toBeNull();
|
||||
expect(normalizeCustomProxy('https://proxy.local', 8443)).toBeNull();
|
||||
expect(normalizeCustomProxy('127.0.0.1', NaN)).toBeNull();
|
||||
expect(normalizeCustomProxy('127.0.0.1:9000', 8080)).toBeNull();
|
||||
expect(normalizeCustomProxy('127.0.0.1/path', 8080)).toBeNull();
|
||||
expect(normalizeCustomProxy('[::1]', 8080)).toBe('http://[::1]:8080');
|
||||
|
||||
expect(await getProxyArgs({
|
||||
proxyMode: 'none',
|
||||
|
||||
@@ -276,6 +276,7 @@ export const normalizeCustomProxy = (host: string, port: number): string | null
|
||||
try {
|
||||
const parsed = new URL(trimmedHost);
|
||||
if (parsed.protocol !== 'http:') return null;
|
||||
if (!parsed.hostname) return null;
|
||||
if (!parsed.port) parsed.port = String(normalizedPort);
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
@@ -283,7 +284,24 @@ export const normalizeCustomProxy = (host: string, port: number): string | null
|
||||
}
|
||||
}
|
||||
|
||||
return `http://${trimmedHost}:${normalizedPort}`;
|
||||
try {
|
||||
const parsed = new URL(`http://${trimmedHost}:${normalizedPort}`);
|
||||
if (
|
||||
!parsed.hostname
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.pathname !== '/'
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
|| (parsed.port && Number(parsed.port) !== normalizedPort)
|
||||
|| (!parsed.port && normalizedPort !== 80)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `http://${trimmedHost}:${normalizedPort}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
@@ -66,3 +66,26 @@ describe('useSettingsStore credential-store startup flow', () => {
|
||||
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore persistence failures', () => {
|
||||
it('reports a database save failure and retries the next settings update', async () => {
|
||||
vi.clearAllMocks();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const onPersistenceError = vi.fn();
|
||||
const unsubscribe = subscribeToSettingsPersistenceErrors(onPersistenceError);
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
useSettingsStore.setState({ theme: 'dark' });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(onPersistenceError).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(undefined);
|
||||
useSettingsStore.setState({ theme: 'light' });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(onPersistenceError).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,9 +20,29 @@ import {
|
||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
|
||||
let settingsSave = Promise.resolve();
|
||||
const settingsPersistenceErrorListeners = new Set<() => void>();
|
||||
let settingsPersistenceFailed = false;
|
||||
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
|
||||
|
||||
export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => {
|
||||
settingsPersistenceErrorListeners.add(listener);
|
||||
if (settingsPersistenceFailed) listener();
|
||||
return () => settingsPersistenceErrorListeners.delete(listener);
|
||||
};
|
||||
|
||||
const notifySettingsPersistenceError = () => {
|
||||
if (settingsPersistenceFailed) return;
|
||||
settingsPersistenceFailed = true;
|
||||
for (const listener of settingsPersistenceErrorListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.error('Settings persistence error listener failed', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
|
||||
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
|
||||
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
|
||||
@@ -83,9 +103,13 @@ const tauriStorage: StateStorage = {
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
settingsSave = settingsSave
|
||||
.catch(() => undefined)
|
||||
.then(() => invoke('db_save_settings', { data: value }))
|
||||
.catch(e => {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
.then(() => {
|
||||
settingsPersistenceFailed = false;
|
||||
}, () => {
|
||||
console.error('Failed to save settings to DB');
|
||||
notifySettingsPersistenceError();
|
||||
});
|
||||
await settingsSave;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
redactLogText,
|
||||
type LogEntry
|
||||
} from './logEntries';
|
||||
|
||||
@@ -30,6 +31,29 @@ describe('log entry streaming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts live secrets, signed URL components, and the home path', () => {
|
||||
const message = 'Cookie: session=secret; https://example.com/file?token=signed https://example.com/file#fragment /Users/nima/Downloads/file';
|
||||
const redacted = redactLogText(message, '/Users/nima');
|
||||
|
||||
expect(redacted).toBe('Cookie: [redacted]; https://example.com/file?[redacted] https://example.com/file#[redacted] <HOME>/Downloads/file');
|
||||
expect(redacted).not.toContain('secret');
|
||||
expect(redacted).not.toContain('signed');
|
||||
expect(redacted).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('redacts live content before it is formatted for display', () => {
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).toContain('Authorization: [redacted]');
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('redacts persisted content and quoted credential fields', () => {
|
||||
const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima');
|
||||
|
||||
expect(persisted.message).toContain('api_key');
|
||||
expect(persisted.message).not.toContain('json-secret');
|
||||
expect(persisted.message).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('merges only the ordered snapshot-to-stream overlap', () => {
|
||||
expect(mergeLogSnapshotAndLiveEntries(
|
||||
[entry('one'), entry('repeat'), entry('three')],
|
||||
|
||||
+43
-7
@@ -19,24 +19,60 @@ const LIVE_LEVELS: Record<number, LogLevel> = {
|
||||
const levelFromMessage = (message: string): LogLevel | undefined =>
|
||||
LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`));
|
||||
|
||||
export const persistedLogEntry = (message: string): LogEntry => ({
|
||||
export const persistedLogEntry = (message: string, homePath = ''): LogEntry => ({
|
||||
level: levelFromMessage(message) || 'Debug',
|
||||
message
|
||||
message: redactLogText(message, homePath)
|
||||
});
|
||||
|
||||
export const redactLogText = (message: string, homePath = ''): string => {
|
||||
const normalizedHome = homePath.trim();
|
||||
const normalizedHomeWithForwardSlashes = normalizedHome.replace(/\\/g, '/');
|
||||
let redacted = message;
|
||||
|
||||
if (normalizedHome) {
|
||||
redacted = redacted.split(normalizedHome).join('<HOME>');
|
||||
}
|
||||
if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) {
|
||||
redacted = redacted.split(normalizedHomeWithForwardSlashes).join('<HOME>');
|
||||
}
|
||||
|
||||
redacted = redacted.replace(
|
||||
/(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^@\s/?#]+@/g,
|
||||
'$1[redacted]@'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?]+)\?[^\s]+/g,
|
||||
'$1?[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#]+)#\S+/g,
|
||||
'$1#[redacted]'
|
||||
);
|
||||
return redacted.replace(
|
||||
/(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
};
|
||||
|
||||
export const liveLogEntry = (
|
||||
numericLevel: number,
|
||||
message: string,
|
||||
now: Date = new Date()
|
||||
now: Date = new Date(),
|
||||
homePath = ''
|
||||
): LogEntry => {
|
||||
const level = levelFromMessage(message) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(message);
|
||||
const redactedMessage = redactLogText(message, homePath);
|
||||
const level = levelFromMessage(redactedMessage) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(redactedMessage);
|
||||
|
||||
return {
|
||||
level,
|
||||
message: alreadyFormatted
|
||||
? message
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}`
|
||||
? redactedMessage
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${redactedMessage}`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user