From 5f2e85e4554d2e9f844778b81404e8c63fd96b94 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sat, 21 Mar 2026 15:33:31 +0100 Subject: [PATCH] fix: clip time validation, cutter 0-byte check, pagination guard, atomic config write H1: Add NaN/negative/zero-duration validation to clip dialog before IPC call H2: Reject cut video output <= 256 bytes as effectively empty H3: Add paginated VOD fetching with MAX_VOD_PAGES=50 safety guard H4: Atomic write (tmp+rename) for config and queue persistence Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.ts | 89 ++++++++++++++++++++++++++++++++++++------------- src/renderer.ts | 16 +++++++-- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/main.ts b/src/main.ts index f8253ee..9c66833 100644 --- a/src/main.ts +++ b/src/main.ts @@ -269,8 +269,16 @@ function loadConfig(): Config { } function saveConfig(config: Config): void { + const tmpPath = CONFIG_FILE + '.tmp'; try { - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2)); + try { + fs.renameSync(tmpPath, CONFIG_FILE); + } catch { + // On Windows, rename can fail if target exists in some edge cases + fs.copyFileSync(tmpPath, CONFIG_FILE); + try { fs.unlinkSync(tmpPath); } catch { } + } } catch (e) { console.error('Error saving config:', e); } @@ -321,8 +329,16 @@ function writeQueueToDisk(queue: QueueItem[]): void { return; } + const tmpPath = QUEUE_FILE + '.tmp'; try { - fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2)); + fs.writeFileSync(tmpPath, JSON.stringify(queue, null, 2)); + try { + fs.renameSync(tmpPath, QUEUE_FILE); + } catch { + // On Windows, rename can fail if target exists in some edge cases + fs.copyFileSync(tmpPath, QUEUE_FILE); + try { fs.unlinkSync(tmpPath); } catch { } + } } catch (e) { console.error('Error saving queue:', e); } @@ -1633,13 +1649,18 @@ async function getVODs(userId: string, forceRefresh = false): Promise { if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi(); - const fetchVods = async () => { + const MAX_VOD_PAGES = 50; // 50 pages x 100 per page = 5000 VODs max + + const fetchVodsPage = async (cursor?: string) => { + const params: Record = { + user_id: userId, + type: 'archive', + first: 100 + }; + if (cursor) params.after = cursor; + return await axios.get('https://api.twitch.tv/helix/videos', { - params: { - user_id: userId, - type: 'archive', - first: 100 - }, + params, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` @@ -1648,26 +1669,38 @@ async function getVODs(userId: string, forceRefresh = false): Promise { }); }; - try { - const response = await fetchVods(); - const vods = response.data.data || []; - const login = vods[0]?.user_login; - if (login) { - userIdLoginCache.set(userId, normalizeLogin(login)); - } + const fetchAllVodPages = async (): Promise => { + const allVods: VOD[] = []; + let cursor: string | undefined; + let pageCount = 0; + do { + const response = await fetchVodsPage(cursor); + const pageVods = response.data.data || []; + allVods.push(...pageVods); + + if (pageCount === 0) { + const login = pageVods[0]?.user_login; + if (login) { + userIdLoginCache.set(userId, normalizeLogin(login)); + } + } + + cursor = response.data.pagination?.cursor; + pageCount++; + } while (cursor && pageCount < MAX_VOD_PAGES); + + return allVods; + }; + + try { + const vods = await fetchAllVodPages(); setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); return vods; } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { try { - const retryResponse = await fetchVods(); - const vods = retryResponse.data.data || []; - const login = vods[0]?.user_login; - if (login) { - userIdLoginCache.set(userId, normalizeLogin(login)); - } - + const vods = await fetchAllVodPages(); setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); return vods; } catch (retryError) { @@ -1906,7 +1939,17 @@ async function cutVideo( proc.on('close', (code) => { currentProcess = null; - resolve(code === 0 && fs.existsSync(outputFile)); + if (code === 0 && fs.existsSync(outputFile)) { + const stats = fs.statSync(outputFile); + if (stats.size <= 256) { + appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size }); + resolve(false); + return; + } + resolve(true); + } else { + resolve(false); + } }); proc.on('error', () => { diff --git a/src/renderer.ts b/src/renderer.ts index eb3df48..74f9588 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -805,17 +805,28 @@ async function confirmClipDialog(): Promise { const startSec = parseTimeToSeconds(byId('clipStartTime').value); const endSec = parseTimeToSeconds(byId('clipEndTime').value); + const durationSec = endSec - startSec; const startPartStr = byId('clipStartPart').value.trim(); const startPart = startPartStr ? parseInt(startPartStr, 10) : 1; const filenameFormat = getSelectedFilenameFormat(); const filenameTemplate = byId('clipFilenameTemplate').value.trim(); - if (endSec <= startSec) { + if (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) { + alert('Invalid time values'); + return; + } + + if (startSec < 0) { + alert(UI_TEXT.clips.outOfRange); + return; + } + + if (durationSec <= 0) { alert(UI_TEXT.clips.endBeforeStart); return; } - if (startSec < 0 || endSec > clipTotalSeconds) { + if (endSec > clipTotalSeconds) { alert(UI_TEXT.clips.outOfRange); return; } @@ -833,7 +844,6 @@ async function confirmClipDialog(): Promise { } } - const durationSec = endSec - startSec; const customClip: CustomClip = { startSec, durationSec,