diff --git a/scripts/e2e-test-environment.js b/scripts/e2e-test-environment.js index d08e4dd..18e6c54 100644 --- a/scripts/e2e-test-environment.js +++ b/scripts/e2e-test-environment.js @@ -7,7 +7,6 @@ const OFFLINE_PROXY = 'http://127.0.0.1:1'; function buildSafeConfig(downloadsDir, overrides = {}) { return { client_id: '', - client_secret: '', download_path: downloadsDir, streamers: [], theme: 'twitch', @@ -32,7 +31,6 @@ function buildSafeConfig(downloadsDir, overrides = {}) { auto_record_poll_seconds: 90, download_chat_replay: false, capture_live_chat: false, - discord_webhook_url: '', discord_notify_live_start: false, discord_notify_live_end: false, discord_notify_vod_complete: false, @@ -50,14 +48,12 @@ function buildSafeConfig(downloadsDir, overrides = {}) { delete_parts_after_merge: false, ...overrides, client_id: '', - client_secret: '', download_path: downloadsDir, streamers: [], auto_resume_queue_on_startup: false, auto_record_streamers: [], download_chat_replay: false, capture_live_chat: false, - discord_webhook_url: '', discord_notify_live_start: false, discord_notify_live_end: false, discord_notify_vod_complete: false, @@ -112,6 +108,17 @@ function writeE2eConfig(environment, overrides = {}) { } function readE2eConfig(environment) { + const databasePath = path.join(environment.appDataDir, 'app.db'); + if (fs.existsSync(databasePath)) { + const Database = require('better-sqlite3'); + const database = new Database(databasePath, { readonly: true }); + try { + const rows = database.prepare('SELECT key, value FROM config_kv').all(); + return Object.fromEntries(rows.map((row) => [row.key, JSON.parse(row.value)])); + } finally { + database.close(); + } + } return JSON.parse(fs.readFileSync(environment.configFile, 'utf8')); } diff --git a/scripts/smoke-test-e2e-isolation-contract.js b/scripts/smoke-test-e2e-isolation-contract.js index 69e94e7..77ac28c 100644 --- a/scripts/smoke-test-e2e-isolation-contract.js +++ b/scripts/smoke-test-e2e-isolation-contract.js @@ -70,6 +70,7 @@ function inspectHelper() { const { createE2eEnvironment, getElectronLaunchOptions, + readE2eConfig, cleanupE2eEnvironment } = require(HELPER_FILE); const environment = createE2eEnvironment('isolation-contract'); @@ -111,8 +112,16 @@ function inspectHelper() { if (config.auto_cleanup_enabled !== false) { failures.push('Seed config enables automatic cleanup'); } - if (config.discord_webhook_url !== '') { - failures.push('Seed config contains a webhook'); + if ('client_secret' in config || 'discord_webhook_url' in config) { + failures.push('Seed config contains secret fields'); + } + const Database = require('better-sqlite3'); + const database = new Database(path.join(environment.appDataDir, 'app.db')); + database.exec('CREATE TABLE config_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)'); + database.prepare('INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, ?)').run('language', JSON.stringify('sqlite'), 1); + database.close(); + if (readE2eConfig(environment).language !== 'sqlite') { + failures.push('Helper did not read authoritative SQLite config'); } if (!Array.isArray(queue) || queue.length !== 0) { failures.push('Seed queue is not empty'); diff --git a/scripts/smoke-test-full.js b/scripts/smoke-test-full.js index bcea234..3519b2c 100644 --- a/scripts/smoke-test-full.js +++ b/scripts/smoke-test-full.js @@ -214,7 +214,7 @@ async function run() { assert(deState.deActive, 'German language button did not activate'); assert(enState.enActive, 'English language button did not activate'); - await window.api.saveConfig({ client_id: '', client_secret: '' }); + await window.api.saveConfig({ client_id: '' }); window.showTab('vods'); await window.selectStreamer('fixture_streamer'); diff --git a/scripts/smoke-test-settings-autosave.js b/scripts/smoke-test-settings-autosave.js index bee179b..d52a984 100644 --- a/scripts/smoke-test-settings-autosave.js +++ b/scripts/smoke-test-settings-autosave.js @@ -1,4 +1,6 @@ const { _electron: electron } = require('playwright'); +const fs = require('fs'); +const path = require('path'); const { createE2eEnvironment, writeE2eConfig, @@ -86,6 +88,9 @@ async function run() { await app.close(); app = null; + for (const filename of ['app.db', 'app.db-wal', 'app.db-shm']) { + fs.rmSync(path.join(environment.appDataDir, filename), { force: true }); + } writeE2eConfig(environment, { download_mode: 'full', part_minutes: 120 diff --git a/src/main.ts b/src/main.ts index c13e740..1dc4708 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,6 +50,11 @@ import { } from './main/domain/file-capability'; import { registerTrustedIpcHandler } from './main/domain/privileged-ipc'; import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input'; +import { createAppStateStore, type AppStateStore } from './main/domain/app-state-store'; +import { createExportableConfig } from './main/domain/config-export'; +import { resolveSecretInputUpdate } from './main/domain/secret-input'; +import { createSecretStore, type SecretStore } from './main/domain/secret-store'; +import { createElectronSecureStorage } from './main/infra/secure-storage'; import { setDebugLogFn, initToolDirs, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, @@ -74,8 +79,6 @@ const GITHUB_RELEASES_DOWNLOAD_BASE_URL = 'https://github.com/Sucukdeluxe/Twitch // Paths const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); -const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); -const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log'); const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json'); const TOOLS_DIR = path.join(APPDATA_DIR, 'tools'); @@ -140,7 +143,6 @@ const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FI // ========================================== interface Config { client_id: string; - client_secret: string; download_path: string; streamers: string[]; streamer_display_names: Record; @@ -167,7 +169,6 @@ interface Config { auto_record_poll_seconds: number; download_chat_replay: boolean; capture_live_chat: boolean; - discord_webhook_url: string; discord_notify_live_start: boolean; discord_notify_live_end: boolean; discord_notify_vod_complete: boolean; @@ -330,7 +331,6 @@ interface ReleaseUpdateInfo { // ========================================== const defaultConfig: Config = { client_id: '', - client_secret: '', download_path: DEFAULT_DOWNLOAD_PATH, streamers: [], streamer_display_names: {}, @@ -357,7 +357,6 @@ const defaultConfig: Config = { auto_record_poll_seconds: 90, download_chat_replay: false, capture_live_chat: false, - discord_webhook_url: '', discord_notify_live_start: false, discord_notify_live_end: false, discord_notify_vod_complete: false, @@ -425,10 +424,6 @@ function normalizeConfigTemplates(input: Config): Config { auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds), download_chat_replay: input.download_chat_replay === true, capture_live_chat: input.capture_live_chat === true, - // Webhook URL is stored but never validated server-side — invalid - // URLs just cause the post to fail (logged, non-fatal). Users with - // accidental whitespace are saved by the .trim(). - discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '', discord_notify_live_start: input.discord_notify_live_start === true, discord_notify_live_end: input.discord_notify_live_end === true, discord_notify_vod_complete: input.discord_notify_vod_complete === true, @@ -476,14 +471,9 @@ function recordDownloadedVodId(vodId: string): void { function loadConfig(): Config { try { - if (fs.existsSync(CONFIG_FILE)) { - const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); - const parsed = JSON.parse(data); - if (!isPlainObject(parsed)) { - console.error('Config file is not a JSON object — using defaults'); - return normalizeConfigTemplates(defaultConfig); - } - return normalizeConfigTemplates({ ...defaultConfig, ...parsed }); + const persisted = appStateStore?.loadConfig(); + if (persisted && isPlainObject(persisted)) { + return normalizeConfigTemplates({ ...defaultConfig, ...persisted } as Config); } } catch (e) { console.error('Error loading config:', e); @@ -493,7 +483,8 @@ function loadConfig(): Config { function saveConfig(config: Config): void { try { - writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + if (!appStateStore) throw new Error('Application state store is unavailable'); + appStateStore.saveConfig(config); } catch (e) { console.error('Error saving config:', e); } @@ -623,26 +614,18 @@ function loadQueue(): QueueItem[] { } try { - if (fs.existsSync(QUEUE_FILE)) { - const data = fs.readFileSync(QUEUE_FILE, 'utf-8'); - const parsed = JSON.parse(data); - if (!Array.isArray(parsed)) { - console.error('Queue file is not a JSON array — ignoring'); - return []; - } - - const items: QueueItem[] = []; - let droppedCount = 0; - for (const raw of parsed) { - const sanitized = sanitizeQueueItem(raw); - if (sanitized) items.push(sanitized); - else droppedCount++; - } - if (droppedCount > 0) { - console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`); - } - return items; + const parsed = appStateStore?.loadQueue() ?? []; + const items: QueueItem[] = []; + let droppedCount = 0; + for (const raw of parsed) { + const sanitized = sanitizeQueueItem(raw); + if (sanitized) items.push(sanitized); + else droppedCount++; } + if (droppedCount > 0) { + console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`); + } + return items; } catch (e) { console.error('Error loading queue:', e); } @@ -654,9 +637,7 @@ let pendingQueueSnapshot: QueueItem[] | null = null; function clearQueueFileFromDisk(): void { try { - if (fs.existsSync(QUEUE_FILE)) { - fs.unlinkSync(QUEUE_FILE); - } + appStateStore?.saveQueue([]); } catch (e) { console.error('Error clearing queue file:', e); } @@ -669,7 +650,8 @@ function writeQueueToDisk(queue: QueueItem[]): void { } try { - writeFileAtomicSync(QUEUE_FILE, JSON.stringify(queue, null, 2)); + if (!appStateStore) throw new Error('Application state store is unavailable'); + appStateStore.saveQueue(queue); } catch (e) { console.error('Error saving queue:', e); } @@ -746,9 +728,13 @@ function startDevelopmentReload(): void { stopDevelopmentReload = null; }); } -let config = loadConfig(); +let appStateStore: AppStateStore | null = null; +let appSecretStore: SecretStore | null = null; +let config = normalizeConfigTemplates(defaultConfig); +let twitchClientSecret = ''; +let discordWebhookUrl = ''; let accessToken: string | null = null; -let downloadQueue: QueueItem[] = loadQueue(); +let downloadQueue: QueueItem[] = []; let queueIdCounter = 0; let lastQueueBroadcastFingerprint = ''; let isDownloading = false; @@ -1807,7 +1793,7 @@ function validateDownloadedFileIntegrity(filePath: string, expectedDurationSecon // TWITCH API // ========================================== async function twitchLogin(): Promise { - if (!config.client_id || !config.client_secret) { + if (!config.client_id || !twitchClientSecret) { return false; } @@ -1815,7 +1801,7 @@ async function twitchLogin(): Promise { const response = await axios.post('https://id.twitch.tv/oauth2/token', null, { params: { client_id: config.client_id, - client_secret: config.client_secret, + client_secret: twitchClientSecret, grant_type: 'client_credentials' }, timeout: API_TIMEOUT @@ -1844,7 +1830,7 @@ function requestTwitchLogin(): Promise { } async function ensureTwitchAuth(forceRefresh = false): Promise { - if (!config.client_id || !config.client_secret) { + if (!config.client_id || !twitchClientSecret) { accessToken = null; return false; } @@ -5281,7 +5267,7 @@ async function sendDiscordWebhook(payload: { color: DiscordEmbedColor; fields?: Array<{ name: string; value: string; inline?: boolean }>; }): Promise { - const url = (config.discord_webhook_url || '').trim(); + const url = discordWebhookUrl.trim(); if (!isAcceptableDiscordWebhook(url)) return; const body = { @@ -7208,6 +7194,49 @@ function setupAutoUpdater() { // ========================================== ipcMain.handle('get-config', () => config); +ipcMain.handle('get-secret-status', (event) => { + if (!isTrustedRendererEvent(event) || !appSecretStore) { + return { encryptionAvailable: false, clientSecretConfigured: false, discordWebhookConfigured: false }; + } + return appSecretStore.status(); +}); + +ipcMain.handle('set-client-secret', (event, value: string) => { + if (!isTrustedRendererEvent(event) || !appSecretStore || !appSecretStore.status().encryptionAvailable) return appSecretStore?.status() ?? null; + const update = resolveSecretInputUpdate(typeof value === 'string' ? value : '', false); + if (update.action !== 'set') return appSecretStore.status(); + appSecretStore.set('twitch_client_secret', update.value); + twitchClientSecret = update.value; + accessToken = null; + twitchLoginInFlight = null; + return appSecretStore.status(); +}); + +ipcMain.handle('clear-client-secret', (event) => { + if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null; + appSecretStore.clear('twitch_client_secret'); + twitchClientSecret = ''; + accessToken = null; + twitchLoginInFlight = null; + return appSecretStore.status(); +}); + +ipcMain.handle('set-discord-webhook', (event, value: string) => { + if (!isTrustedRendererEvent(event) || !appSecretStore || !appSecretStore.status().encryptionAvailable) return appSecretStore?.status() ?? null; + const update = resolveSecretInputUpdate(typeof value === 'string' ? value : '', false); + if (update.action !== 'set') return appSecretStore.status(); + appSecretStore.set('discord_webhook_url', update.value); + discordWebhookUrl = update.value; + return appSecretStore.status(); +}); + +ipcMain.handle('clear-discord-webhook', (event) => { + if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null; + appSecretStore.clear('discord_webhook_url'); + discordWebhookUrl = ''; + return appSecretStore.status(); +}); + ipcMain.handle('get-automation-status', () => ({ autoRecord: { watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0, @@ -7240,7 +7269,6 @@ ipcMain.handle('trigger-auto-vod-scan', async (event) => { ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability?: string) => { if (!isTrustedRendererEvent(event)) return config; const previousClientId = config.client_id; - const previousClientSecret = config.client_secret; const previousCacheMinutes = config.metadata_cache_minutes; const previousPersistQueueOnRestart = config.persist_queue_on_restart; const previousTheme = config.theme; @@ -7251,6 +7279,8 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability const previousStreamerList = JSON.stringify(config.streamers || []); const acceptedConfig = { ...newConfig }; + delete (acceptedConfig as Record).client_secret; + delete (acceptedConfig as Record).discord_webhook_url; if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) { const selectedPath = typeof fileCapability === 'string' ? resolveFileCapability(event, fileCapability, 'selected-folder') @@ -7261,7 +7291,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability } config = normalizeConfigTemplates({ ...config, ...acceptedConfig }); - if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) { + if (config.client_id !== previousClientId) { accessToken = null; twitchLoginInFlight = null; } @@ -8185,12 +8215,7 @@ ipcMain.handle('export-config', async (event) => { const outputCapability = issueFileCapability(event, 'config-export', dialogResult.filePath, 'output-file', ['json']); const outputFile = resolveFileCapability(event, outputCapability.token, 'config-export', true); if (!outputFile) return { success: false, error: 'File access denied' }; - const exportable = { - ...config, - client_secret: '', - __exportVersion: 1, - __exportedAt: new Date().toISOString() - }; + const exportable = createExportableConfig(config as unknown as Record); writeFileAtomicSync(outputFile, JSON.stringify(exportable, null, 2)); return { success: true, filePath: outputFile }; } catch (e) { @@ -8222,13 +8247,12 @@ ipcMain.handle('import-config', async (event) => { // Merge over current config so unknown / missing keys keep their // existing values. Then run normalizeConfigTemplates so any // out-of-range field falls back to defaults. - const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config); - - // Preserve the existing client_secret if the import stripped it - // (export does this on purpose) — the user shouldn't lose creds. - if (!merged.client_secret && config.client_secret) { - merged.client_secret = config.client_secret; - } + const imported = { ...parsed } as Record; + delete imported.client_secret; + delete imported.discord_webhook_url; + delete imported.__exportVersion; + delete imported.__exportedAt; + const merged = normalizeConfigTemplates({ ...config, ...imported } as Config); config = merged; saveConfig(config); @@ -8439,20 +8463,40 @@ app.whenReady().then(() => { startMetadataCacheCleanup(); startDebugLogFlushTimer(); - // SQLite-Open + Shadow-Migration. Long-lived handle in appDb (siehe oben). - // Lazy require, damit Native-Build-Fehler den App-Start nicht verhindern. try { const { openDatabase } = require('./main/infra/db'); const { migrateJsonToSqlite } = require('./main/domain/migrator'); const dbPath = path.join(APPDATA_DIR, 'app.db'); - appDb = openDatabase(dbPath); - const result = migrateJsonToSqlite({ db: appDb, appDataDir: APPDATA_DIR }); + const database: DbHandle = openDatabase(dbPath); + appDb = database; + const secureStorage = createElectronSecureStorage(); + appSecretStore = createSecretStore(database, secureStorage); + const result = migrateJsonToSqlite({ + db: database, + appDataDir: APPDATA_DIR, + secrets: appSecretStore, + requireEncryption: true, + }); appendDebugLog('sqlite-migrator', result); + if (result.errors.length > 0) throw new Error(result.errors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; ')); + appStateStore = createAppStateStore(database); + config = loadConfig(); + downloadQueue = config.persist_queue_on_restart === false ? [] : loadQueue(); + if (config.persist_queue_on_restart === false) appStateStore.saveQueue([]); + twitchClientSecret = appSecretStore.get('twitch_client_secret') ?? ''; + discordWebhookUrl = appSecretStore.get('discord_webhook_url') ?? ''; } catch (e) { appendDebugLog('sqlite-open-failed', { error: e instanceof Error ? e.message : String(e), }); + try { appDb?.close(); } catch { } appDb = null; + appStateStore = null; + appSecretStore = null; + config = normalizeConfigTemplates(defaultConfig); + downloadQueue = []; + twitchClientSecret = ''; + discordWebhookUrl = ''; } restartAutoRecordPoller(); diff --git a/src/main/domain/app-state-store.test.ts b/src/main/domain/app-state-store.test.ts new file mode 100644 index 0000000..40e730e --- /dev/null +++ b/src/main/domain/app-state-store.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { openDatabase, type DbHandle } from '../infra/db'; +import { createAppStateStore } from './app-state-store'; + +let directory: string; +let databasePath: string; +let db: DbHandle; + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'app-state-')); + databasePath = path.join(directory, 'app.db'); + db = openDatabase(databasePath); +}); + +afterEach(() => { + db.close(); + fs.rmSync(directory, { recursive: true, force: true }); +}); + +describe('createAppStateStore', () => { + it('recovers configuration from SQLite after restart without secret fields', () => { + const store = createAppStateStore(db); + store.saveConfig({ + language: 'de', + client_id: 'client-id', + client_secret: 'plain-client-secret', + discord_webhook_url: 'https://discord.com/api/webhooks/plain', + downloaded_vod_ids: ['v2', 'v1'], + auto_record_streamers: ['Alice'], + auto_vod_download_streamers: ['Bob'], + }); + + db.close(); + db = openDatabase(databasePath); + const recovered = createAppStateStore(db).loadConfig(); + + expect(recovered).toMatchObject({ + language: 'de', + client_id: 'client-id', + downloaded_vod_ids: ['v2', 'v1'], + auto_record_streamers: ['alice'], + auto_vod_download_streamers: ['bob'], + }); + expect(recovered).not.toHaveProperty('client_secret'); + expect(recovered).not.toHaveProperty('discord_webhook_url'); + const persisted = JSON.stringify(db.all('SELECT key, value FROM config_kv')); + expect(persisted).not.toContain('plain-client-secret'); + expect(persisted).not.toContain('/webhooks/plain'); + }); + + it('persists queue snapshots atomically and preserves order across restart', () => { + const first = { id: 'q2', status: 'pending', title: 'second', streamer: 'Beta' }; + const second = { id: 'q1', status: 'completed', title: 'first', streamer: 'Alpha' }; + createAppStateStore(db).saveQueue([first, second]); + + db.close(); + db = openDatabase(databasePath); + + expect(createAppStateStore(db).loadQueue()).toEqual([first, second]); + expect(db.all<{ id: string; queue_position: number }>('SELECT id, queue_position FROM queue_items ORDER BY queue_position')).toEqual([ + { id: 'q2', queue_position: 0 }, + { id: 'q1', queue_position: 1 }, + ]); + }); +}); diff --git a/src/main/domain/app-state-store.ts b/src/main/domain/app-state-store.ts new file mode 100644 index 0000000..f3faa26 --- /dev/null +++ b/src/main/domain/app-state-store.ts @@ -0,0 +1,112 @@ +import type { DbHandle } from '../infra/db'; +import { normalizeLogin } from './config-normalize'; + +export interface AppStateStore { + loadConfig(): Record; + saveConfig(config: T): void; + loadQueue>(): T[]; + saveQueue(queue: T[]): void; +} + +const SECRET_CONFIG_KEYS = new Set(['client_secret', 'discord_webhook_url']); + +function normalizedLogins(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value + .filter((entry): entry is string => typeof entry === 'string') + .map(normalizeLogin) + .filter(Boolean))]; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))]; +} + +function normalizeConfig(config: object): Record { + const source = config as Record; + const normalized = Object.fromEntries( + Object.entries(source).filter(([key]) => !SECRET_CONFIG_KEYS.has(key)) + ); + normalized.downloaded_vod_ids = stringArray(source.downloaded_vod_ids); + normalized.auto_record_streamers = normalizedLogins(source.auto_record_streamers); + normalized.auto_vod_download_streamers = normalizedLogins(source.auto_vod_download_streamers); + return normalized; +} + +export function createAppStateStore(db: DbHandle): AppStateStore { + return { + loadConfig() { + return Object.fromEntries( + db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv') + .map((row) => [row.key, JSON.parse(row.value)]) + ); + }, + saveConfig(config) { + const normalized = normalizeConfig(config); + db.transaction(() => { + db.run('DELETE FROM config_kv'); + for (const [key, value] of Object.entries(normalized)) { + db.run( + `INSERT INTO config_kv(key, value, updated_at) + VALUES (?, ?, strftime('%s','now'))`, + [key, JSON.stringify(value)] + ); + } + db.run('DELETE FROM downloaded_vods'); + for (const vodId of normalized.downloaded_vod_ids as string[]) { + db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]); + } + db.run('DELETE FROM streamers'); + for (const login of normalized.auto_record_streamers as string[]) { + db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]); + } + for (const login of normalized.auto_vod_download_streamers as string[]) { + db.run( + `INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) + ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`, + [login] + ); + } + }); + }, + loadQueue() { + return db.all<{ payload_json: string }>( + 'SELECT payload_json FROM queue_items ORDER BY queue_position, created_at, id' + ).map((row) => JSON.parse(row.payload_json) as T); + }, + saveQueue(queue: T[]) { + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + db.run('DELETE FROM queue_items'); + queue.forEach((rawItem, index) => { + const item = rawItem as Record; + const id = typeof item.id === 'string' && item.id ? item.id : null; + if (!id) throw new Error('Queue item id must not be empty'); + db.run( + `INSERT OR REPLACE INTO queue_items + (id, queue_position, streamer_login, vod_id, clip_id, title, output_path, status, + progress_pct, error_message, created_at, updated_at, completed_at, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + index, + typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null, + typeof item.vod_id === 'string' ? item.vod_id : null, + typeof item.clip_id === 'string' ? item.clip_id : null, + typeof item.title === 'string' ? item.title : null, + typeof item.output_path === 'string' ? item.output_path : null, + typeof item.status === 'string' ? item.status : 'pending', + typeof item.progress_pct === 'number' ? item.progress_pct : null, + typeof item.error_message === 'string' ? item.error_message : null, + typeof item.created_at === 'number' ? item.created_at : now, + typeof item.updated_at === 'number' ? item.updated_at : now, + typeof item.completed_at === 'number' ? item.completed_at : null, + JSON.stringify(item), + ] + ); + }); + }); + }, + }; +} diff --git a/src/main/domain/config-export.test.ts b/src/main/domain/config-export.test.ts new file mode 100644 index 0000000..92bd83d --- /dev/null +++ b/src/main/domain/config-export.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { createExportableConfig } from './config-export'; + +describe('createExportableConfig', () => { + it('recursively removes every secret-bearing field and value', () => { + const exported = createExportableConfig({ + language: 'de', + client_secret: 'client-secret-value', + discord_webhook_url: 'https://discord.com/api/webhooks/value', + access_token: 'access-token-value', + nested: { + refresh_token: 'refresh-token-value', + password: 'password-value', + cookie: 'cookie-value', + }, + }, new Date('2026-08-11T20:00:00.000Z')); + const serialized = JSON.stringify(exported); + + expect(exported).toMatchObject({ language: 'de', __exportVersion: 2, __exportedAt: '2026-08-11T20:00:00.000Z' }); + for (const forbidden of ['client_secret', 'discord_webhook_url', 'access_token', 'refresh_token', 'password', 'cookie', 'client-secret-value', '/webhooks/value']) { + expect(serialized).not.toContain(forbidden); + } + }); +}); diff --git a/src/main/domain/config-export.ts b/src/main/domain/config-export.ts new file mode 100644 index 0000000..d108eec --- /dev/null +++ b/src/main/domain/config-export.ts @@ -0,0 +1,20 @@ +const SECRET_KEYS = /(^|_)(authorization|cookie|password|secret|token)($|_)/i; + +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (!value || typeof value !== 'object') return value; + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (SECRET_KEYS.test(key) || key.toLowerCase() === 'discord_webhook_url') continue; + result[key] = redact(entry); + } + return result; +} + +export function createExportableConfig(config: Record, exportedAt = new Date()): Record { + return { + ...(redact(config) as Record), + __exportVersion: 2, + __exportedAt: exportedAt.toISOString(), + }; +} diff --git a/src/main/domain/migrator.test.ts b/src/main/domain/migrator.test.ts index b214ff2..16166a2 100644 --- a/src/main/domain/migrator.test.ts +++ b/src/main/domain/migrator.test.ts @@ -4,6 +4,8 @@ import * as os from 'os'; import * as path from 'path'; import { openDatabase, type DbHandle } from '../infra/db'; import { migrateJsonToSqlite } from './migrator'; +import { MemorySecureStorage } from '../infra/secure-storage'; +import { createSecretStore } from './secret-store'; let tmpDir: string; let appDataDir: string; @@ -33,8 +35,8 @@ describe('migrateJsonToSqlite', () => { expect(result.downloadedVodsCount).toBe(0); expect(result.streamersCount).toBe(0); - const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']); - expect(marker?.name).toBe('v4-to-v5-jsons'); + const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1']); + expect(marker?.name).toBe('authoritative-state-v1'); }); test('migrates config.json keys into config_kv', () => { @@ -118,5 +120,121 @@ describe('migrateJsonToSqlite', () => { expect(result.configMigrated).toBe(false); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0].source).toBe('config.json'); + expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); + }); + + test('rolls back all records and marker when migration is interrupted', () => { + writeJson('config.json', { language: 'de', metadata_cache_minutes: 30 }); + writeJson('download_queue.json', [{ id: 'q1', status: 'pending', title: 'Queue item' }]); + let queueInsertReached = false; + const interruptedDb: DbHandle = { + ...db, + run(sql, params) { + if (sql.includes('INSERT OR REPLACE INTO queue_items')) { + queueInsertReached = true; + throw new Error('simulated interruption'); + } + db.run(sql, params); + }, + }; + + const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir }); + + expect(queueInsertReached).toBe(true); + expect(result.errors).toEqual([{ source: 'migration', message: 'simulated interruption' }]); + expect(db.all('SELECT * FROM config_kv')).toEqual([]); + expect(db.all('SELECT * FROM queue_items')).toEqual([]); + expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); + }); + + test('rolls back config queue and secrets when the migration marker cannot be written', () => { + const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' }); + writeJson('download_queue.json', [{ id: 'q1', status: 'pending' }]); + const interruptedDb: DbHandle = { + ...db, + run(sql, params) { + if (sql.includes('INSERT INTO migrations_applied')) throw new Error('marker unavailable'); + db.run(sql, params); + }, + }; + const secrets = createSecretStore(interruptedDb, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir, secrets }); + + expect(result.errors).toEqual([{ source: 'migration', message: 'marker unavailable' }]); + expect(db.all('SELECT * FROM config_kv')).toEqual([]); + expect(db.all('SELECT * FROM queue_items')).toEqual([]); + expect(db.all('SELECT * FROM app_secrets')).toEqual([]); + expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive'); + }); + + test('imports legacy JSON once and ignores later JSON changes after restart', () => { + const configPath = writeJson('config.json', { language: 'de' }); + migrateJsonToSqlite({ db, appDataDir }); + fs.writeFileSync(configPath, JSON.stringify({ language: 'en' }), 'utf-8'); + + const second = migrateJsonToSqlite({ db, appDataDir }); + const language = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']); + + expect(second.alreadyApplied).toBe(true); + expect(JSON.parse(language!.value)).toBe('de'); + }); + + test('does not let the former shadow-migration marker skip authoritative secret import', () => { + const configPath = writeJson('config.json', { language: 'de', client_secret: 'legacy-secret' }); + db.run('INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', ['v4-to-v5-jsons', '{}']); + const secrets = createSecretStore(db, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db, appDataDir, secrets }); + + expect(result.alreadyApplied).toBe(false); + expect(result.errors).toEqual([]); + expect(secrets.get('twitch_client_secret')).toBe('legacy-secret'); + expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-secret'); + }); + + test('migrates plaintext secrets into encrypted versioned records and scrubs JSON', () => { + const configPath = writeJson('config.json', { + client_secret: 'legacy-client-secret', + discord_webhook_url: 'https://discord.com/api/webhooks/legacy', + language: 'de', + }); + const secrets = createSecretStore(db, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db, appDataDir, secrets }); + + expect(result.errors).toEqual([]); + expect(secrets.get('twitch_client_secret')).toBe('legacy-client-secret'); + expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/legacy'); + expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-client-secret'); + expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).not.toContain('/webhooks/legacy'); + expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined(); + }); + + test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => { + const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' }); + const secrets = createSecretStore(db, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db, appDataDir, secrets, requireEncryption: true }); + + expect(result.errors).toEqual([{ source: 'migration', message: 'OS secret encryption is unavailable' }]); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive'); + expect(secrets.get('twitch_client_secret')).toBeNull(); + expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); + }); + + test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => { + const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' }); + fs.mkdirSync(`${configPath}.v4-backup`); + const secrets = createSecretStore(db, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db, appDataDir, secrets }); + + expect(result.errors).toHaveLength(1); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive'); + expect(db.all('SELECT * FROM config_kv')).toEqual([]); + expect(db.all('SELECT * FROM app_secrets')).toEqual([]); + expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); }); }); diff --git a/src/main/domain/migrator.ts b/src/main/domain/migrator.ts index 4d523bc..fcc4eff 100644 --- a/src/main/domain/migrator.ts +++ b/src/main/domain/migrator.ts @@ -1,11 +1,14 @@ import * as fs from 'fs'; import * as path from 'path'; import type { DbHandle } from '../infra/db'; -import { normalizeLogin } from './config-normalize'; +import { createAppStateStore } from './app-state-store'; +import type { SecretStore } from './secret-store'; export interface MigratorOptions { db: DbHandle; appDataDir: string; + secrets?: SecretStore; + requireEncryption?: boolean; } export interface MigrationError { @@ -22,180 +25,135 @@ export interface MigrationResult { errors: MigrationError[]; } -const MIGRATION_NAME = 'v4-to-v5-jsons'; +const MIGRATION_NAME = 'authoritative-state-v1'; +const SECRET_KEYS = new Set(['client_secret', 'discord_webhook_url']); -const CONFIG_KV_KEYS = [ - 'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality', - 'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat', - 'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end', - 'discord_notify_vod_complete', 'discord_notify_vod_auto_queued', - 'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target', - 'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes', - 'auto_vod_max_age_hours', 'auto_resume_live_recording', - 'auto_merge_resumed_parts', 'delete_parts_after_merge', - 'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts', - 'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads', - 'persist_queue_on_restart', 'auto_resume_queue_on_startup', - 'notify_on_each_completion', 'sidebar_split_view', -] as const; - -function backupOnce(srcPath: string): void { - const backupPath = srcPath + '.v4-backup'; - if (!fs.existsSync(backupPath)) { - fs.copyFileSync(srcPath, backupPath); - } -} - -function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } { +function readJson(filePath: string, source: string, errors: MigrationError[]): T | undefined { + if (!fs.existsSync(filePath)) return undefined; try { - const raw = fs.readFileSync(configPath, 'utf-8'); - const config = JSON.parse(raw) as Record; - - let vodCount = 0; - db.transaction(() => { - for (const key of CONFIG_KV_KEYS) { - if (key in config) { - db.run( - "INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", - [key, JSON.stringify(config[key])] - ); - } - } - - const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []; - for (const id of vodIds) { - if (typeof id !== 'string' || !id) continue; - db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]); - vodCount += 1; - } - - const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : []; - for (const s of autoRec) { - if (typeof s !== 'string' || !s) continue; - const login = normalizeLogin(s); - if (!login) continue; - db.run( - 'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1', - [login] - ); - } - - const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : []; - for (const s of autoDl) { - if (typeof s !== 'string' || !s) continue; - const login = normalizeLogin(s); - if (!login) continue; - db.run( - 'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1', - [login] - ); - } - }); - - backupOnce(configPath); - return { ok: true, vodCount }; - } catch (e) { - errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) }); - return { ok: false, vodCount: 0 }; + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T; + } catch (error) { + errors.push({ source, message: error instanceof Error ? error.message : String(error) }); + return undefined; } } -function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean { - try { - const raw = fs.readFileSync(queuePath, 'utf-8'); - const queue = JSON.parse(raw); - if (!Array.isArray(queue)) return false; - - const now = Math.floor(Date.now() / 1000); - db.transaction(() => { - for (const rawItem of queue) { - if (!rawItem || typeof rawItem !== 'object') continue; - const item = rawItem as Record; - const id = typeof item.id === 'string' ? item.id : null; - if (!id) continue; - db.run( - `INSERT OR REPLACE INTO queue_items - (id, streamer_login, vod_id, clip_id, title, output_path, status, - progress_pct, error_message, created_at, updated_at, completed_at, payload_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - id, - typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null, - typeof item.vod_id === 'string' ? item.vod_id : null, - typeof item.clip_id === 'string' ? item.clip_id : null, - typeof item.title === 'string' ? item.title : null, - typeof item.output_path === 'string' ? item.output_path : null, - typeof item.status === 'string' ? item.status : 'pending', - typeof item.progress_pct === 'number' ? item.progress_pct : null, - typeof item.error_message === 'string' ? item.error_message : null, - typeof item.created_at === 'number' ? item.created_at : now, - typeof item.updated_at === 'number' ? item.updated_at : now, - typeof item.completed_at === 'number' ? item.completed_at : null, - JSON.stringify(item), - ] - ); - } - }); - - backupOnce(queuePath); - return true; - } catch (e) { - errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) }); - return false; - } +function withoutSecrets(config: Record): Record { + return Object.fromEntries(Object.entries(config).filter(([key]) => !SECRET_KEYS.has(key))); } -export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { - const { db, appDataDir } = opts; - const errors: MigrationError[] = []; +function writeJsonAtomic(filePath: string, value: unknown): void { + const temporaryPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf-8'); + fs.renameSync(temporaryPath, filePath); +} - const existing = db.get<{ name: string }>( - 'SELECT name FROM migrations_applied WHERE name = ?', - [MIGRATION_NAME] - ); - if (existing) { - return { - alreadyApplied: true, - configMigrated: false, - queueMigrated: false, - downloadedVodsCount: 0, - streamersCount: 0, - errors: [], - }; - } +function backupJson(filePath: string, value: unknown): void { + const backupPath = `${filePath}.v4-backup`; + if (!fs.existsSync(backupPath)) writeJsonAtomic(backupPath, value); +} - let configMigrated = false; - let queueMigrated = false; - let downloadedVodsCount = 0; +function scrubConfigFiles(configPath: string, config: Record): void { + const sanitized = withoutSecrets(config); + writeJsonAtomic(`${configPath}.v4-backup`, sanitized); + writeJsonAtomic(configPath, sanitized); +} - const configPath = path.join(appDataDir, 'config.json'); - if (fs.existsSync(configPath)) { - const r = migrateConfig(db, configPath, errors); - configMigrated = r.ok; - downloadedVodsCount = r.vodCount; - } - - const queuePath = path.join(appDataDir, 'download_queue.json'); - if (fs.existsSync(queuePath)) { - queueMigrated = migrateQueue(db, queuePath, errors); - } - - const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0; - - db.run( - 'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', - [ - MIGRATION_NAME, - JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }), - ] - ); +function scrubExistingConfig(configPath: string): void { + if (!fs.existsSync(configPath)) return; + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; + scrubConfigFiles(configPath, config); +} +function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult { return { - alreadyApplied: false, - configMigrated, - queueMigrated, - downloadedVodsCount, - streamersCount, + alreadyApplied, + configMigrated: false, + queueMigrated: false, + downloadedVodsCount: 0, + streamersCount: 0, errors, }; } + +export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { + const { db, appDataDir, secrets, requireEncryption = false } = opts; + const configPath = path.join(appDataDir, 'config.json'); + const queuePath = path.join(appDataDir, 'download_queue.json'); + const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]); + if (existing) { + try { + scrubExistingConfig(configPath); + } catch (error) { + return emptyResult(true, [{ source: 'config.json', message: error instanceof Error ? error.message : String(error) }]); + } + return emptyResult(true); + } + + const errors: MigrationError[] = []; + const configExists = fs.existsSync(configPath); + const queueExists = fs.existsSync(queuePath); + const config = readJson>(configPath, 'config.json', errors); + const queue = readJson(queuePath, 'download_queue.json', errors); + if (queueExists && !Array.isArray(queue)) { + errors.push({ source: 'download_queue.json', message: 'Queue JSON must be an array' }); + } + if (errors.length > 0) return emptyResult(false, errors); + if (config && (typeof config !== 'object' || Array.isArray(config))) { + return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]); + } + if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) { + return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]); + } + if (config && requireEncryption && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key]) && !secrets?.status().encryptionAvailable) { + return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]); + } + + const state = createAppStateStore(db); + let downloadedVodsCount = 0; + let streamersCount = 0; + let configScrubbed = false; + try { + db.transaction(() => { + if (configExists && config) { + state.saveConfig(config); + downloadedVodsCount = Array.isArray(config.downloaded_vod_ids) + ? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length + : 0; + if (typeof config.client_secret === 'string' && config.client_secret) { + secrets!.set('twitch_client_secret', config.client_secret); + } + if (typeof config.discord_webhook_url === 'string' && config.discord_webhook_url) { + secrets!.set('discord_webhook_url', config.discord_webhook_url); + } + } + if (queueExists) state.saveQueue(queue as Array>); + streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0; + db.run( + 'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', + [MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })] + ); + if (queueExists) backupJson(queuePath, queue); + if (configExists && config) { + scrubConfigFiles(configPath, config); + configScrubbed = true; + } + }); + } catch (error) { + if (configScrubbed && config) { + try { + writeJsonAtomic(configPath, config); + } catch { } + } + return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]); + } + + return { + alreadyApplied: false, + configMigrated: configExists, + queueMigrated: queueExists, + downloadedVodsCount, + streamersCount, + errors: [], + }; +} diff --git a/src/main/domain/secret-input.test.ts b/src/main/domain/secret-input.test.ts new file mode 100644 index 0000000..ce4d04d --- /dev/null +++ b/src/main/domain/secret-input.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSecretInputUpdate } from './secret-input'; + +describe('resolveSecretInputUpdate', () => { + it('keeps a configured secret when the masked value is unchanged', () => { + expect(resolveSecretInputUpdate('••••••••', true)).toEqual({ action: 'unchanged' }); + }); + + it('clears a configured secret when the field is emptied', () => { + expect(resolveSecretInputUpdate(' ', true)).toEqual({ action: 'clear' }); + }); + + it('sets a trimmed replacement without exposing the stored value', () => { + expect(resolveSecretInputUpdate(' replacement ', true)).toEqual({ action: 'set', value: 'replacement' }); + }); + + it('ignores an empty field when no secret is configured', () => { + expect(resolveSecretInputUpdate('', false)).toEqual({ action: 'unchanged' }); + }); +}); diff --git a/src/main/domain/secret-input.ts b/src/main/domain/secret-input.ts new file mode 100644 index 0000000..d3bef88 --- /dev/null +++ b/src/main/domain/secret-input.ts @@ -0,0 +1,13 @@ +export type SecretInputUpdate = + | { action: 'unchanged' } + | { action: 'clear' } + | { action: 'set'; value: string }; + +export const SECRET_INPUT_MASK = '••••••••'; + +export function resolveSecretInputUpdate(value: string, configured: boolean): SecretInputUpdate { + if (configured && value === SECRET_INPUT_MASK) return { action: 'unchanged' }; + const normalized = value.trim(); + if (normalized) return { action: 'set', value: normalized }; + return configured ? { action: 'clear' } : { action: 'unchanged' }; +} diff --git a/src/main/domain/secret-store.test.ts b/src/main/domain/secret-store.test.ts new file mode 100644 index 0000000..537e79d --- /dev/null +++ b/src/main/domain/secret-store.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { openDatabase, type DbHandle } from '../infra/db'; +import { MemorySecureStorage } from '../infra/secure-storage'; +import { createSecretStore } from './secret-store'; + +let directory: string; +let db: DbHandle; + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'app-secrets-')); + db = openDatabase(path.join(directory, 'app.db')); +}); + +afterEach(() => { + db.close(); + fs.rmSync(directory, { recursive: true, force: true }); +}); + +describe('createSecretStore', () => { + it('stores versioned ciphertext and exposes only configured status', () => { + const store = createSecretStore(db, new MemorySecureStorage()); + store.set('twitch_client_secret', 'client-secret-value'); + store.set('discord_webhook_url', 'https://discord.com/api/webhooks/value'); + + expect(store.get('twitch_client_secret')).toBe('client-secret-value'); + expect(store.status()).toEqual({ + encryptionAvailable: false, + clientSecretConfigured: true, + discordWebhookConfigured: true, + }); + const rows = db.all<{ key: string; version: number; encrypted_value: string }>('SELECT key, version, encrypted_value FROM app_secrets ORDER BY key'); + expect(rows.map((row) => row.version)).toEqual([1, 1]); + expect(JSON.stringify(rows)).not.toContain('client-secret-value'); + expect(JSON.stringify(rows)).not.toContain('/webhooks/value'); + }); + + it('clears one secret without changing the other', () => { + const store = createSecretStore(db, new MemorySecureStorage()); + store.set('twitch_client_secret', 'client'); + store.set('discord_webhook_url', 'webhook'); + store.clear('twitch_client_secret'); + + expect(store.get('twitch_client_secret')).toBeNull(); + expect(store.get('discord_webhook_url')).toBe('webhook'); + }); +}); diff --git a/src/main/domain/secret-store.ts b/src/main/domain/secret-store.ts new file mode 100644 index 0000000..e2428b2 --- /dev/null +++ b/src/main/domain/secret-store.ts @@ -0,0 +1,58 @@ +import type { DbHandle } from '../infra/db'; +import type { SecureStorage } from '../infra/secure-storage'; + +export type AppSecretKey = 'twitch_client_secret' | 'discord_webhook_url'; + +export interface SecretStatus { + encryptionAvailable: boolean; + clientSecretConfigured: boolean; + discordWebhookConfigured: boolean; +} + +export interface SecretStore { + get(key: AppSecretKey): string | null; + set(key: AppSecretKey, value: string): void; + clear(key: AppSecretKey): void; + status(): SecretStatus; +} + +const SECRET_VERSION = 1; + +export function createSecretStore(db: DbHandle, storage: SecureStorage): SecretStore { + return { + get(key) { + const row = db.get<{ version: number; encrypted_value: string }>( + 'SELECT version, encrypted_value FROM app_secrets WHERE key = ?', + [key] + ); + if (!row) return null; + if (row.version !== SECRET_VERSION) { + throw new Error(`Unsupported secret record version: ${row.version}`); + } + return storage.decrypt(row.encrypted_value); + }, + set(key, value) { + if (!value) throw new Error('Secret value must not be empty'); + db.run( + `INSERT INTO app_secrets(key, version, encrypted_value, updated_at) + VALUES (?, ?, ?, strftime('%s','now')) + ON CONFLICT(key) DO UPDATE SET + version = excluded.version, + encrypted_value = excluded.encrypted_value, + updated_at = excluded.updated_at`, + [key, SECRET_VERSION, storage.encrypt(value)] + ); + }, + clear(key) { + db.run('DELETE FROM app_secrets WHERE key = ?', [key]); + }, + status() { + const keys = new Set(db.all<{ key: AppSecretKey }>('SELECT key FROM app_secrets').map((row) => row.key)); + return { + encryptionAvailable: storage.isEncryptionAvailable(), + clientSecretConfigured: keys.has('twitch_client_secret'), + discordWebhookConfigured: keys.has('discord_webhook_url'), + }; + }, + }; +} diff --git a/src/main/infra/db.ts b/src/main/infra/db.ts index 4b75a49..0ed25f2 100644 --- a/src/main/infra/db.ts +++ b/src/main/infra/db.ts @@ -34,6 +34,10 @@ export function openDatabase(filePath: string): DbHandle { db.pragma('foreign_keys = ON'); runMultiStatement(db, SCHEMA_V5_SQL); + const queueColumns = db.prepare('PRAGMA table_info(queue_items)').all() as Array<{ name: string }>; + if (!queueColumns.some((column) => column.name === 'queue_position')) { + db.prepare('ALTER TABLE queue_items ADD COLUMN queue_position INTEGER NOT NULL DEFAULT 0').run(); + } const handle: DbHandle = { run(sql, params) { diff --git a/src/main/infra/schema-v5.ts b/src/main/infra/schema-v5.ts index 72c1a61..3dcc28f 100644 --- a/src/main/infra/schema-v5.ts +++ b/src/main/infra/schema-v5.ts @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS config_kv ( CREATE TABLE IF NOT EXISTS queue_items ( id TEXT PRIMARY KEY, + queue_position INTEGER NOT NULL DEFAULT 0, streamer_login TEXT, vod_id TEXT, clip_id TEXT, @@ -38,6 +39,13 @@ CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status); CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login); CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at); +CREATE TABLE IF NOT EXISTS app_secrets ( + key TEXT PRIMARY KEY, + version INTEGER NOT NULL, + encrypted_value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + CREATE TABLE IF NOT EXISTS downloaded_vods ( vod_id TEXT PRIMARY KEY, downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) diff --git a/src/preload.ts b/src/preload.ts index 7b92639..c28ae41 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -102,6 +102,11 @@ contextBridge.exposeInMainWorld('api', { // Config getConfig: () => ipcRenderer.invoke('get-config'), saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability), + getSecretStatus: () => ipcRenderer.invoke('get-secret-status'), + setClientSecret: (value: string) => ipcRenderer.invoke('set-client-secret', value), + clearClientSecret: () => ipcRenderer.invoke('clear-client-secret'), + setDiscordWebhook: (value: string) => ipcRenderer.invoke('set-discord-webhook', value), + clearDiscordWebhook: () => ipcRenderer.invoke('clear-discord-webhook'), // Auth login: () => ipcRenderer.invoke('login'), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 0086009..d1f364f 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -1,6 +1,5 @@ interface AppConfig { client_id?: string; - client_secret?: string; download_path?: string; streamers?: string[]; streamer_display_names?: Record; @@ -27,7 +26,6 @@ interface AppConfig { auto_record_poll_seconds?: number; download_chat_replay?: boolean; capture_live_chat?: boolean; - discord_webhook_url?: string; discord_notify_live_start?: boolean; discord_notify_live_end?: boolean; discord_notify_vod_complete?: boolean; @@ -172,6 +170,12 @@ interface VideoInfo { variableFrameRate: boolean; } +interface SecretStatus { + encryptionAvailable: boolean; + clientSecretConfigured: boolean; + discordWebhookConfigured: boolean; +} + interface VideoEditorMedia { sourceUrl: string; info: VideoInfo; @@ -370,6 +374,11 @@ interface ArchiveStats { interface ApiBridge { getConfig(): Promise; saveConfig(config: Partial, fileCapability?: string): Promise; + getSecretStatus(): Promise; + setClientSecret(value: string): Promise; + clearClientSecret(): Promise; + setDiscordWebhook(value: string): Promise; + clearDiscordWebhook(): Promise; login(): Promise; getUserId(username: string): Promise; getVODs(userId: string, forceRefresh?: boolean): Promise; diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index d9cef3d..f7f1aac 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -6,6 +6,12 @@ let pendingSettingsAutoSave = false; let settingsAutoSaveTimer: number | null = null; let pendingCredentialsReconnect = false; let lastPersistedSettingsFingerprint = ''; +const SECRET_INPUT_MASK = '••••••••'; +let secretStatus: SecretStatus = { + encryptionAvailable: false, + clientSecretConfigured: false, + discordWebhookConfigured: false +}; function canRunSettingsAutoRefresh(): boolean { if (document.hidden) { @@ -16,7 +22,7 @@ function canRunSettingsAutoRefresh(): boolean { } async function connect(): Promise { - const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim()); + const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && secretStatus.clientSecretConfigured); if (!hasCredentials) { isConnected = false; updateStatus(UI_TEXT.status.noLogin, false, 'public'); @@ -576,11 +582,36 @@ function toggleDebugAutoRefresh(enabled: boolean): void { function collectCredentialsPayload(): Partial { return { - client_id: byId('clientId').value.trim(), - client_secret: byId('clientSecret').value.trim() + client_id: byId('clientId').value.trim() }; } +function syncSecretFields(): void { + byId('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : ''; + byId('discordWebhookUrl').value = secretStatus.discordWebhookConfigured ? SECRET_INPUT_MASK : ''; +} + +async function persistSecretInputs(): Promise { + const clientValue = byId('clientSecret').value; + if (clientValue !== SECRET_INPUT_MASK) { + secretStatus = clientValue.trim() + ? await window.api.setClientSecret(clientValue.trim()) + : secretStatus.clientSecretConfigured + ? await window.api.clearClientSecret() + : secretStatus; + } + + const webhookValue = byId('discordWebhookUrl').value; + if (webhookValue !== SECRET_INPUT_MASK) { + secretStatus = webhookValue.trim() + ? await window.api.setDiscordWebhook(webhookValue.trim()) + : secretStatus.discordWebhookConfigured + ? await window.api.clearDiscordWebhook() + : secretStatus; + } + syncSecretFields(); +} + function syncPartMinutesFieldState(): void { const downloadMode = byId('downloadMode').value; const partMinutes = byId('partMinutes'); @@ -611,7 +642,6 @@ function collectDownloadSettingsPayload(): Partial { auto_resume_live_recording: byId('autoResumeLiveRecordingToggle').checked, auto_merge_resumed_parts: byId('autoMergeResumedPartsToggle').checked, delete_parts_after_merge: byId('deletePartsAfterMergeToggle').checked, - discord_webhook_url: byId('discordWebhookUrl').value.trim(), discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, discord_notify_vod_complete: byId('discordNotifyVodCompleteToggle').checked, @@ -658,7 +688,7 @@ function getSettingsFingerprint(payload: Partial): string { const effective = { ...config, ...payload }; return JSON.stringify([ effective.client_id ?? '', - effective.client_secret ?? '', + byId('clientSecret').value, effective.sidebar_split_view !== false, effective.download_mode ?? 'full', effective.part_minutes ?? 120, @@ -676,7 +706,7 @@ function getSettingsFingerprint(payload: Partial): string { effective.auto_resume_live_recording !== false, effective.auto_merge_resumed_parts === true, effective.delete_parts_after_merge === true, - effective.discord_webhook_url ?? '', + byId('discordWebhookUrl').value, effective.discord_notify_live_start === true, effective.discord_notify_live_end === true, effective.discord_notify_vod_complete === true, @@ -697,7 +727,7 @@ function getSettingsFingerprint(payload: Partial): string { function syncSettingsFormFromConfig(): void { byId('clientId').value = config.client_id ?? ''; - byId('clientSecret').value = config.client_secret ?? ''; + syncSecretFields(); byId('sidebarSplitViewToggle').checked = config.sidebar_split_view !== false; applySidebarLayoutPreference(config.sidebar_split_view !== false); byId('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full'; @@ -716,7 +746,6 @@ function syncSettingsFormFromConfig(): void { byId('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false; byId('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true; byId('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true; - byId('discordWebhookUrl').value = (config.discord_webhook_url as string) || ''; byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; byId('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true; @@ -759,6 +788,7 @@ async function persistSettings(options: { Object.assign(payload, templatePayload); } + await persistSecretInputs(); config = await window.api.saveConfig(payload); syncSettingsFormFromConfig(); pendingCredentialsReconnect = false; @@ -798,7 +828,9 @@ async function flushSettingsAutoSave(reconnectAfterSave = false): Promise settingsAutoSaveInFlight = true; try { + await persistSecretInputs(); config = await window.api.saveConfig(payload); + syncSecretFields(); lastPersistedSettingsFingerprint = getSettingsFingerprint({}); if (reconnectAfterSave && pendingCredentialsReconnect) { pendingCredentialsReconnect = false; @@ -904,6 +936,13 @@ function initSettingsAutoSave(): void { }); } + for (const id of ['clientSecret', 'discordWebhookUrl'] as const) { + byId(id).addEventListener('focus', (event) => { + const input = event.currentTarget as HTMLInputElement; + if (input.value === SECRET_INPUT_MASK) input.select(); + }); + } + window.addEventListener('blur', () => { if (settingsAutoSaveTimer || pendingCredentialsReconnect) { void flushSettingsAutoSave(pendingCredentialsReconnect); diff --git a/src/renderer.ts b/src/renderer.ts index fc9340c..be38b55 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -5,13 +5,15 @@ const QUEUE_SYNC_HIDDEN_MS = 9000; const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000; async function init(): Promise { - const [loadedConfig, initialQueue, isDown, version] = await Promise.all([ + const [loadedConfig, loadedSecretStatus, initialQueue, isDown, version] = await Promise.all([ window.api.getConfig(), + window.api.getSecretStatus(), window.api.getQueue(), window.api.isDownloading(), window.api.getVersion() ]); config = loadedConfig; + secretStatus = loadedSecretStatus; const language = setLanguage((config.language as string) || 'en'); config.language = language; queue = Array.isArray(initialQueue) ? initialQueue : []; @@ -24,7 +26,7 @@ async function init(): Promise { document.title = `${UI_TEXT.appName} v${version}`; byId('clientId').value = config.client_id ?? ''; - byId('clientSecret').value = config.client_secret ?? ''; + byId('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : ''; byId('downloadPath').value = config.download_path ?? ''; byId('themeSelect').value = config.theme ?? 'twitch'; byId('languageSelect').value = config.language ?? 'en'; @@ -182,7 +184,7 @@ async function init(): Promise { else startStatsBarPolling(); }); - if (config.client_id && config.client_secret) { + if (config.client_id && secretStatus.clientSecretConfigured) { await connect(); } else { updateStatus(UI_TEXT.status.noLogin, false);