feat(security): encrypt secrets and centralize app state

Store Twitch and Discord secrets as versioned safeStorage ciphertext behind explicit trusted IPC. Migrate legacy JSON transactionally into authoritative SQLite config and ordered queue repositories with rollback-safe markers and sanitized backups. Redact config exports and update renderer and release-harness contracts for secret-free config responses.
This commit is contained in:
Sucukdeluxe
2026-08-11 23:44:20 +02:00
parent 6147c9b812
commit 47be523b41
21 changed files with 824 additions and 252 deletions
+11 -4
View File
@@ -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'));
}
+11 -2
View File
@@ -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');
+1 -1
View File
@@ -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');
+5
View File
@@ -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
+111 -67
View File
@@ -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<string, string>;
@@ -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<QueueItem>() ?? [];
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<boolean> {
if (!config.client_id || !config.client_secret) {
if (!config.client_id || !twitchClientSecret) {
return false;
}
@@ -1815,7 +1801,7 @@ async function twitchLogin(): Promise<boolean> {
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<boolean> {
}
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
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<void> {
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<Config>, 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<Config>, fileCapability
const previousStreamerList = JSON.stringify(config.streamers || []);
const acceptedConfig = { ...newConfig };
delete (acceptedConfig as Record<string, unknown>).client_secret;
delete (acceptedConfig as Record<string, unknown>).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<Config>, 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<string, unknown>);
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<string, unknown>;
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();
+68
View File
@@ -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 },
]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { DbHandle } from '../infra/db';
import { normalizeLogin } from './config-normalize';
export interface AppStateStore {
loadConfig(): Record<string, unknown>;
saveConfig<T extends object>(config: T): void;
loadQueue<T extends object = Record<string, unknown>>(): T[];
saveQueue<T extends object>(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<string, unknown> {
const source = config as Record<string, unknown>;
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<T extends object>() {
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<T extends object>(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<string, unknown>;
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),
]
);
});
});
},
};
}
+24
View File
@@ -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);
}
});
});
+20
View File
@@ -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<string, unknown> = {};
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<string, unknown>, exportedAt = new Date()): Record<string, unknown> {
return {
...(redact(config) as Record<string, unknown>),
__exportVersion: 2,
__exportedAt: exportedAt.toISOString(),
};
}
+120 -2
View File
@@ -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();
});
});
+121 -163
View File
@@ -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<T>(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<string, unknown>;
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<string, unknown>;
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<string, unknown>): Record<string, unknown> {
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<string, unknown>): 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<string, unknown>;
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<Record<string, unknown>>(configPath, 'config.json', errors);
const queue = readJson<unknown>(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<Record<string, unknown>>);
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: [],
};
}
+20
View File
@@ -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' });
});
});
+13
View File
@@ -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' };
}
+49
View File
@@ -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');
});
});
+58
View File
@@ -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'),
};
},
};
}
+4
View File
@@ -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) {
+8
View File
@@ -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'))
+5
View File
@@ -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'),
+11 -2
View File
@@ -1,6 +1,5 @@
interface AppConfig {
client_id?: string;
client_secret?: string;
download_path?: string;
streamers?: string[];
streamer_display_names?: Record<string, string>;
@@ -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<AppConfig>;
saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
getSecretStatus(): Promise<SecretStatus>;
setClientSecret(value: string): Promise<SecretStatus>;
clearClientSecret(): Promise<SecretStatus>;
setDiscordWebhook(value: string): Promise<SecretStatus>;
clearDiscordWebhook(): Promise<SecretStatus>;
login(): Promise<boolean>;
getUserId(username: string): Promise<string | null>;
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
+47 -8
View File
@@ -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<void> {
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<AppConfig> {
return {
client_id: byId<HTMLInputElement>('clientId').value.trim(),
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
client_id: byId<HTMLInputElement>('clientId').value.trim()
};
}
function syncSecretFields(): void {
byId<HTMLInputElement>('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : '';
byId<HTMLInputElement>('discordWebhookUrl').value = secretStatus.discordWebhookConfigured ? SECRET_INPUT_MASK : '';
}
async function persistSecretInputs(): Promise<void> {
const clientValue = byId<HTMLInputElement>('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<HTMLInputElement>('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<HTMLSelectElement>('downloadMode').value;
const partMinutes = byId<HTMLInputElement>('partMinutes');
@@ -611,7 +642,6 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
@@ -658,7 +688,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
const effective = { ...config, ...payload };
return JSON.stringify([
effective.client_id ?? '',
effective.client_secret ?? '',
byId<HTMLInputElement>('clientSecret').value,
effective.sidebar_split_view !== false,
effective.download_mode ?? 'full',
effective.part_minutes ?? 120,
@@ -676,7 +706,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.auto_resume_live_recording !== false,
effective.auto_merge_resumed_parts === true,
effective.delete_parts_after_merge === true,
effective.discord_webhook_url ?? '',
byId<HTMLInputElement>('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<AppConfig>): string {
function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
syncSecretFields();
byId<HTMLInputElement>('sidebarSplitViewToggle').checked = config.sidebar_split_view !== false;
applySidebarLayoutPreference(config.sidebar_split_view !== false);
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
@@ -716,7 +746,6 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
byId<HTMLInputElement>('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<void>
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<HTMLInputElement>(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);
+5 -3
View File
@@ -5,13 +5,15 @@ const QUEUE_SYNC_HIDDEN_MS = 9000;
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
async function init(): Promise<void> {
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<void> {
document.title = `${UI_TEXT.appName} v${version}`;
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLInputElement>('clientSecret').value = secretStatus.clientSecretConfigured ? SECRET_INPUT_MASK : '';
byId<HTMLInputElement>('downloadPath').value = config.download_path ?? '';
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en';
@@ -182,7 +184,7 @@ async function init(): Promise<void> {
else startStatsBarPolling();
});
if (config.client_id && config.client_secret) {
if (config.client_id && secretStatus.clientSecretConfigured) {
await connect();
} else {
updateStatus(UI_TEXT.status.noLogin, false);