Apply all CODE-AUDIT.md findings (S1-S5, P1-P3, M1-M3)
Security: - S1 (backup.ts): require explicit confirmation before enabling custom-command templates from a backup file; import templates in a disabled state if declined - S2 (cookies.ts): deny non-http/https popups in cookie login window, matching the main window's setWindowOpenHandler defence - S3 (download.ts): main-process concurrency cap — startDownload now rejects spawns when active.size >= maxConcurrent (defence-in-depth; renderer already enforces this but should not be the only gate) - S4 (settings.ts, validation.ts): reject filenameTemplate values containing path traversal (.. segments or absolute paths); validate outputDir is absolute - S5 (history.ts, errorlog.ts, templates.ts, validation.ts): per-field validation on JSON reads — invalid entries dropped rather than blindly trusted Performance: - P1 (settings.ts): getSettings() now only writes to disk when sanitization actually changed a value; removes synchronous file I/O on every hot-path read - P2 (ipc.ts, download.ts, downloads.ts): renderer forwards its pre-probed metadata (title/channel/duration) via StartDownloadOptions.meta; main uses it directly instead of spawning a redundant second yt-dlp probe Maintainability: - M1 (log.ts, download.ts, probe.ts): extract duplicated cleanError() to src/main/log.ts; both callers import from the shared module - M2 (buildArgs.ts): document parseExtraArgs escape-sequence limitations - M3 (electron-builder.yml): clarify icon TODO as a pre-release action - P3 (downloads.ts): document that lowering maxConcurrent mid-flight does not pause active downloads (intended behaviour, now written down) Tests: - Add test/validation.test.ts covering isSafeFilenameTemplate, isSafeOutputDir, isValidHistoryEntry, isValidErrorLogEntry, isTemplateLike (76 tests pass) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+60
-2
@@ -51,7 +51,65 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
// hand-edited or partial backup file degrades gracefully rather than
|
||||
// corrupting the store.
|
||||
const file = parsed as Partial<BackupFile>
|
||||
if (file.settings && typeof file.settings === 'object') setSettings(file.settings)
|
||||
if (Array.isArray(file.templates)) replaceTemplates(file.templates)
|
||||
const incomingSettings =
|
||||
file.settings && typeof file.settings === 'object'
|
||||
? (file.settings as Partial<Settings>)
|
||||
: undefined
|
||||
const incomingTemplates = Array.isArray(file.templates)
|
||||
? (file.templates as CommandTemplate[])
|
||||
: []
|
||||
|
||||
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
|
||||
// the next download. A backup that arrives with customCommandEnabled + a default
|
||||
// template carrying args would silently enable code execution the moment a
|
||||
// download starts — so require an explicit, informed confirmation before
|
||||
// honouring that, and import the templates in a *disabled* state if declined.
|
||||
const commandTemplates = incomingTemplates.filter(
|
||||
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
|
||||
)
|
||||
const wouldEnableCustomCommands =
|
||||
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
|
||||
|
||||
let applyCustomCommands = true
|
||||
if (wouldEnableCustomCommands) {
|
||||
const preview = commandTemplates
|
||||
.slice(0, 10)
|
||||
.map((t) => {
|
||||
const name = (t.name ?? '').trim() || 'Untitled command'
|
||||
const args = t.args.length > 100 ? `${t.args.slice(0, 100)}…` : t.args
|
||||
return `• ${name}: ${args}`
|
||||
})
|
||||
.join('\n')
|
||||
const more =
|
||||
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
|
||||
const choice = await dialog.showMessageBox(win!, {
|
||||
type: 'warning',
|
||||
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 2,
|
||||
title: 'Backup contains custom commands',
|
||||
message: `This backup enables ${commandTemplates.length} custom-command template${
|
||||
commandTemplates.length === 1 ? '' : 's'
|
||||
}.`,
|
||||
detail:
|
||||
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
|
||||
preview +
|
||||
more
|
||||
})
|
||||
if (choice.response === 2) return { ok: false } // Cancel — change nothing
|
||||
applyCustomCommands = choice.response === 0
|
||||
}
|
||||
|
||||
if (incomingSettings) {
|
||||
// When the user declined to enable custom commands (or there were none to
|
||||
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
|
||||
const safeSettings = applyCustomCommands
|
||||
? incomingSettings
|
||||
: { ...incomingSettings, customCommandEnabled: false }
|
||||
setSettings(safeSettings)
|
||||
}
|
||||
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
||||
replaceTemplates(incomingTemplates)
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -106,8 +106,14 @@ export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
|
||||
/**
|
||||
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
|
||||
* templates) into argv tokens. Supports single- and double-quoted spans so a
|
||||
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one
|
||||
* token; no escape-character or nested-quote support beyond that.
|
||||
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one token.
|
||||
*
|
||||
* LIMITATIONS (audit M2): there is no escape-sequence support (`\"`, `\'`) and
|
||||
* quotes cannot nest — a literal quote inside a value can't be expressed, and an
|
||||
* unterminated quote falls through to the `\S+` branch and captures the quote
|
||||
* char itself. For the yt-dlp use cases this targets (proxy URLs, filename
|
||||
* patterns, ffmpeg recipes), that's sufficient. If a future template needs
|
||||
* nested or escaped quotes, redesign this to a proper shlex or JSON-based format.
|
||||
*/
|
||||
export function parseExtraArgs(raw: string): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
+17
-6
@@ -116,13 +116,24 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
||||
|
||||
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
|
||||
// sharing the same partition, rather than silently swallowing the click.
|
||||
win.webContents.setWindowOpenHandler(() => ({
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
|
||||
// Restrict popups to http(s) only — the same defence the main window applies
|
||||
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
|
||||
// to disk or use a custom-protocol popup as a pivot.
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
try {
|
||||
const { protocol } = new URL(details.url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
|
||||
} catch {
|
||||
return { action: 'deny' } // unparseable URL — never open it
|
||||
}
|
||||
}))
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
loginWindow = null
|
||||
|
||||
+23
-17
@@ -8,6 +8,7 @@ import { getCookiesFilePath } from './cookies'
|
||||
import { listTemplates } from './templates'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
|
||||
import { cleanError } from './log'
|
||||
import { addErrorLog } from './errorlog'
|
||||
import {
|
||||
IpcChannels,
|
||||
@@ -77,16 +78,6 @@ function parseProgress(rest: string): DownloadProgress | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */
|
||||
function cleanError(stderr: string): string {
|
||||
const lines = stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
|
||||
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
|
||||
}
|
||||
|
||||
function send(wc: WebContents, ev: DownloadEvent): void {
|
||||
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
|
||||
}
|
||||
@@ -231,6 +222,13 @@ export function startDownload(
|
||||
if (active.has(opts.id)) {
|
||||
return { ok: false, error: 'A download with this id is already running.' }
|
||||
}
|
||||
// Defence-in-depth: the renderer's pump() already caps concurrency, but the main
|
||||
// process shouldn't trust it — a buggy or compromised renderer could otherwise
|
||||
// spawn unbounded yt-dlp processes. Enforce the same cap here on active spawns.
|
||||
const maxConcurrent = getSettings().maxConcurrent
|
||||
if (active.size >= maxConcurrent) {
|
||||
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
|
||||
}
|
||||
|
||||
let child: ChildProcess
|
||||
try {
|
||||
@@ -242,14 +240,22 @@ export function startDownload(
|
||||
const rec: ActiveDownload = { child, canceled: false }
|
||||
active.set(opts.id, rec)
|
||||
|
||||
// Fetch title/channel/duration in parallel so the card fills in quickly.
|
||||
// Also kept locally so the completion notification/error log can show a
|
||||
// real title instead of just the raw URL.
|
||||
// Title/channel/duration are kept locally so the completion notification and
|
||||
// error log can show a real title instead of just the raw URL.
|
||||
let resolvedTitle: string | undefined
|
||||
probeMeta(ytdlp, opts.url).then((meta) => {
|
||||
if (meta?.title) resolvedTitle = meta.title
|
||||
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
|
||||
})
|
||||
if (opts.meta && (opts.meta.title || opts.meta.channel || opts.meta.durationLabel)) {
|
||||
// The renderer already probed this URL and passed the metadata along — reuse
|
||||
// it instead of spawning a redundant second yt-dlp probe (audit P2).
|
||||
resolvedTitle = opts.meta.title
|
||||
send(wc, { type: 'meta', id: opts.id, meta: opts.meta })
|
||||
} else {
|
||||
// No pre-probed metadata (e.g. a direct paste that skipped the probe) — fetch
|
||||
// it in parallel so the card fills in quickly.
|
||||
probeMeta(ytdlp, opts.url).then((meta) => {
|
||||
if (meta?.title) resolvedTitle = meta.title
|
||||
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
|
||||
})
|
||||
}
|
||||
|
||||
let stdoutBuf = ''
|
||||
let stderrTail = ''
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { ErrorLogEntry } from '@shared/ipc'
|
||||
import { isValidErrorLogEntry } from './validation'
|
||||
|
||||
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
||||
// report survives the queue item being cleared (Seal's "debug report").
|
||||
@@ -11,11 +12,14 @@ function errorLogFile(): string {
|
||||
return join(app.getPath('userData'), 'errorlog.json')
|
||||
}
|
||||
|
||||
// Per-entry validation (isValidErrorLogEntry, in validation.ts) so a hand-edited
|
||||
// or corrupted errorlog.json can't feed the UI entries with the wrong shape —
|
||||
// invalid rows are dropped. (audit S5)
|
||||
export function listErrorLog(): ErrorLogEntry[] {
|
||||
try {
|
||||
if (!existsSync(errorLogFile())) return []
|
||||
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
|
||||
return Array.isArray(data) ? (data as ErrorLogEntry[]) : []
|
||||
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { HistoryEntry } from '@shared/ipc'
|
||||
import { isValidHistoryEntry } from './validation'
|
||||
|
||||
// Plain JSON in userData (portable build redirects userData next to the exe).
|
||||
// Kept simple per the build plan; can migrate to better-sqlite3 later.
|
||||
@@ -11,11 +12,14 @@ function historyFile(): string {
|
||||
return join(app.getPath('userData'), 'history.json')
|
||||
}
|
||||
|
||||
// Per-entry validation (isValidHistoryEntry, in validation.ts) so a hand-edited
|
||||
// or corrupted history.json can't feed the UI (or openPath) entries with the
|
||||
// wrong shape — invalid rows are dropped rather than trusted. (audit S5)
|
||||
export function listHistory(): HistoryEntry[] {
|
||||
try {
|
||||
if (!existsSync(historyFile())) return []
|
||||
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
|
||||
return Array.isArray(data) ? (data as HistoryEntry[]) : []
|
||||
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shared yt-dlp stderr parsing. Used by both download.ts and probe.ts so the
|
||||
* error-extraction logic lives in one place (audit M1).
|
||||
*/
|
||||
|
||||
/** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */
|
||||
export function cleanError(stderr: string): string {
|
||||
const lines = stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
|
||||
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
|
||||
}
|
||||
+1
-9
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getYtdlpPath } from './binaries'
|
||||
import { fmtBytes } from './download'
|
||||
import { cleanError } from './log'
|
||||
import { assertHttpUrl } from './url'
|
||||
import {
|
||||
BEST_FORMAT_ID,
|
||||
@@ -136,15 +137,6 @@ function buildPlaylist(data: RawInfo): PlaylistInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanError(stderr: string): string {
|
||||
const lines = stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
|
||||
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a URL with `yt-dlp -J --flat-playlist`. Resolves to a single video (with
|
||||
* its format list) or, when the URL is a playlist, the flat list of its entries.
|
||||
|
||||
+43
-5
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import Store from 'electron-store'
|
||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||
import {
|
||||
AUDIO_FORMATS,
|
||||
VIDEO_CONTAINERS,
|
||||
@@ -90,13 +91,42 @@ function getStore(): Store<Settings> {
|
||||
|
||||
export function getSettings(): Settings {
|
||||
const s = getStore()
|
||||
// Fill in the real Downloads path the first time, and persist it.
|
||||
if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads'))
|
||||
// Migrate settings files that predate downloadOptions (or hold a partial one).
|
||||
s.set('downloadOptions', sanitizeOptions(s.get('downloadOptions')))
|
||||
// getSettings() is on hot paths (buildCommand, notification checks, the system-
|
||||
// theme bridge, several IPC handlers). electron-store writes to disk on every
|
||||
// `set`, so only write when something actually changed — otherwise this churns
|
||||
// the settings file on every read. (audit P1)
|
||||
const cur = s.store
|
||||
if (!cur.outputDir) {
|
||||
// Fill in the real Downloads path the first time, and persist it once.
|
||||
s.set('outputDir', app.getPath('downloads'))
|
||||
}
|
||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||
// but only persist when sanitizing actually altered the stored value.
|
||||
const sanitized = sanitizeOptions(cur.downloadOptions)
|
||||
if (!downloadOptionsEqual(sanitized, cur.downloadOptions)) {
|
||||
s.set('downloadOptions', sanitized)
|
||||
}
|
||||
return s.store
|
||||
}
|
||||
|
||||
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
|
||||
function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
|
||||
if (!b || typeof b !== 'object') return false
|
||||
const o = b as Partial<DownloadOptions>
|
||||
const keys = Object.keys(a) as (keyof DownloadOptions)[]
|
||||
for (const k of keys) {
|
||||
if (k === 'sponsorBlockCategories') {
|
||||
const av = a[k]
|
||||
const bv = o[k]
|
||||
if (!Array.isArray(bv) || av.length !== bv.length) return false
|
||||
for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false
|
||||
} else if (a[k] !== o[k]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Validate each key before persisting — the renderer is the only caller today,
|
||||
// but settings flow into process spawning (maxConcurrent) and the native window
|
||||
// background (theme), so an out-of-range or malformed value shouldn't get stored.
|
||||
@@ -148,9 +178,17 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
s.set('downloadOptions', sanitizeOptions(value))
|
||||
break
|
||||
case 'outputDir':
|
||||
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
|
||||
break
|
||||
case 'filenameTemplate':
|
||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
||||
s.set('filenameTemplate', value.trim())
|
||||
}
|
||||
break
|
||||
case 'defaultVideoQuality':
|
||||
case 'defaultAudioQuality':
|
||||
case 'filenameTemplate':
|
||||
// proxy may carry plaintext credentials (user:pass@host); they are stored
|
||||
// and exported by exportBackup as-is — documented, not masked.
|
||||
case 'proxy':
|
||||
case 'rateLimit':
|
||||
if (typeof value === 'string') s.set(key, value)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { CommandTemplate } from '@shared/ipc'
|
||||
import { isTemplateLike } from './validation'
|
||||
|
||||
// Plain JSON in userData, same shape as history.ts.
|
||||
const MAX_TEMPLATES = 100
|
||||
@@ -10,11 +11,17 @@ function templatesFile(): string {
|
||||
return join(app.getPath('userData'), 'templates.json')
|
||||
}
|
||||
|
||||
// A persisted template entry must at least be an object carrying an id (see
|
||||
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
||||
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
||||
// malformed entries. (audit S5)
|
||||
export function listTemplates(): CommandTemplate[] {
|
||||
try {
|
||||
if (!existsSync(templatesFile())) return []
|
||||
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
|
||||
return Array.isArray(data) ? (data as CommandTemplate[]) : []
|
||||
// Validate shape, then normalise each surviving entry through sanitize() so
|
||||
// name/args are always well-formed strings regardless of what was on disk.
|
||||
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pure, dependency-free validators for untrusted input that crosses a trust
|
||||
* boundary (renderer writes, backup files, hand-edited JSON on disk).
|
||||
*
|
||||
* Like buildArgs.ts, this module imports nothing from electron/node beyond the
|
||||
* `path` builtin, so it can be unit-tested without spinning up Electron.
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'path'
|
||||
import type { HistoryEntry, ErrorLogEntry, CommandTemplate } from '@shared/ipc'
|
||||
|
||||
// --- Path-traversal sanitization (audit S4) ---------------------------------
|
||||
|
||||
/**
|
||||
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
|
||||
* `-o`. Reject anything that could write outside that directory — an absolute
|
||||
* path, or any `..` path segment — so a malicious backup/settings write can't
|
||||
* traverse out of the chosen folder (e.g. '%(title)s\..\..\win32.exe'). The
|
||||
* template still legitimately contains yt-dlp `%(field)s` tokens and `/` or `\`
|
||||
* for sub-folders, which are fine.
|
||||
*/
|
||||
export function isSafeFilenameTemplate(template: string): boolean {
|
||||
if (isAbsolute(template)) return false
|
||||
return !template.split(/[\\/]/).some((segment) => segment === '..')
|
||||
}
|
||||
|
||||
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
|
||||
export function isSafeOutputDir(dir: string): boolean {
|
||||
return dir === '' || isAbsolute(dir)
|
||||
}
|
||||
|
||||
// --- Persisted-JSON entry validation (audit S5) -----------------------------
|
||||
|
||||
const isOptionalString = (v: unknown): boolean => v === undefined || typeof v === 'string'
|
||||
|
||||
/** A persisted history.json row must have the right shape or it's dropped on read. */
|
||||
export function isValidHistoryEntry(o: unknown): o is HistoryEntry {
|
||||
if (!o || typeof o !== 'object') return false
|
||||
const e = o as Record<string, unknown>
|
||||
return (
|
||||
typeof e.id === 'string' &&
|
||||
typeof e.title === 'string' &&
|
||||
typeof e.url === 'string' &&
|
||||
(e.kind === 'video' || e.kind === 'audio') &&
|
||||
typeof e.quality === 'string' &&
|
||||
typeof e.completedAt === 'number' &&
|
||||
isOptionalString(e.filePath) &&
|
||||
isOptionalString(e.channel) &&
|
||||
isOptionalString(e.sizeLabel) &&
|
||||
isOptionalString(e.thumbnail)
|
||||
)
|
||||
}
|
||||
|
||||
/** A persisted errorlog.json row must have the right shape or it's dropped on read. */
|
||||
export function isValidErrorLogEntry(o: unknown): o is ErrorLogEntry {
|
||||
if (!o || typeof o !== 'object') return false
|
||||
const e = o as Record<string, unknown>
|
||||
return (
|
||||
typeof e.id === 'string' &&
|
||||
typeof e.url === 'string' &&
|
||||
(e.kind === 'video' || e.kind === 'audio') &&
|
||||
typeof e.error === 'string' &&
|
||||
typeof e.occurredAt === 'number' &&
|
||||
isOptionalString(e.title)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted templates.json row must at least be an object carrying an id we
|
||||
* can stringify; name/args are then coerced by templates.ts's sanitize(), so a
|
||||
* weak shape check here plus normalisation there is enough.
|
||||
*/
|
||||
export function isTemplateLike(o: unknown): o is CommandTemplate {
|
||||
if (!o || typeof o !== 'object') return false
|
||||
const t = o as Record<string, unknown>
|
||||
return typeof t.id === 'string' || typeof t.id === 'number'
|
||||
}
|
||||
Reference in New Issue
Block a user