/** * Fail-closed guards for the release screenshot capture * (capture-release-screenshots.ts). Published screenshots must never contain * a real playlist, credential, stream, or third-party artwork — these * helpers make that a property of the run, not a hope. * * Pure (or fs-only) so the policy is unit-tested; the Playwright driver just * wires them in. */ import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, } from 'node:fs'; import path from 'node:path'; /* ------------------------------------------------------------------ */ /* Manifest */ /* ------------------------------------------------------------------ */ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/; /** The capture and the blog scaffold both assume exactly these themes. */ export const SUPPORTED_THEMES = ['dark', 'light']; /** * Chromium switch applied at process start, so it has no install-timing * window at all: every hostname fails to resolve except the local mock. It * covers Chromium-stack traffic (renderer plus the main process `net` * module); requests made through Node's own stack are not affected, which is * why the session hook and the recorded-URL verdict still exist. */ export const HOST_RESOLVER_RULES = 'MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1'; /** Setup actions the driver implements. Manifest steps must match. */ export const KNOWN_ACTIONS = [ 'open-update-channel', 'open-backup', 'open-library-watched', 'open-fullscreen-channels', 'open-player-subtitles', 'open-stream-info', 'open-dashboard', 'open-settings', 'open-xtream-vod', 'open-xtream-series', 'open-m3u-groups', 'open-add-playlist-xtream', 'open-add-playlist-auto', 'open-xtream-live', 'open-add-playlist-stalker', 'open-stalker-live', 'open-add-playlist-m3u-url', 'open-settings-epg', 'open-downloads-manager', 'open-downloads-offline-movie', 'open-xtream-vod-sources', 'open-xtream-vod-sources-menu', 'open-xtream-live-channel', 'open-settings-remote-control', 'enable-remote-control', 'open-settings-epg-offset', 'load-demo-epg', 'open-m3u-channel-menu', 'open-epg-mapping-dialog', 'open-settings-tmdb', 'open-settings-search', ]; /** * Shots without a `group` belong to the release post and land in * `blog//screenshots/`. Any other group (for example `guides`) is * captured only when asked for with `--group` and lands in its own * `blog//screenshots/` directory, so a release run can never publish * guide frames into a release folder or the other way round. */ export const DEFAULT_SHOT_GROUP = 'release'; /** @param {{ group?: unknown }} shot */ export function shotGroup(shot) { return typeof shot?.group === 'string' ? shot.group : DEFAULT_SHOT_GROUP; } /** * @param {{ blogRoot: string, group: string, release: string }} input * @returns {string} the directory a run of `group` publishes into */ export function outputDirectoryFor({ blogRoot, group, release }) { const folder = group === DEFAULT_SHOT_GROUP ? release : group; return path.join(blogRoot, folder, 'screenshots'); } /** @param {string} step e.g. `open-xtream-vod=Hero Premieres` */ export function parseSetupStep(step) { const separator = step.indexOf('='); const action = separator === -1 ? step : step.slice(0, separator); const param = separator === -1 ? null : step.slice(separator + 1); return { action, param }; } /** * @param {object} manifest parsed screenshots.manifest.json * @returns {string[]} problems; empty when valid */ export function validateManifest(manifest) { const errors = []; if (manifest?.version !== 1) { errors.push('manifest `version` must be 1'); } // The blog scaffold always references both `-dark.png` and `-light.png`, // and a full run publishes in replace mode — a manifest missing a theme // would silently delete the previous set and leave the post with broken // images. const themes = manifest?.themes; if ( !Array.isArray(themes) || themes.length !== SUPPORTED_THEMES.length || SUPPORTED_THEMES.some((theme) => !themes.includes(theme)) ) { errors.push( `manifest \`themes\` must be exactly ${SUPPORTED_THEMES.join(' and ')}, got ${JSON.stringify(themes)}` ); } const shots = Array.isArray(manifest?.shots) ? manifest.shots : null; if (!shots || shots.length === 0) { return [...errors, 'manifest `shots` must be a non-empty array']; } const seen = new Set(); for (const shot of shots) { const label = shot?.slug ?? ''; if (!shot?.slug || !SLUG_PATTERN.test(shot.slug)) { errors.push(`shot "${label}": slug must be a lowercase slug`); } if (seen.has(shot?.slug)) { errors.push(`shot "${label}": duplicate slug`); } seen.add(shot?.slug); if (shot?.group !== undefined && !SLUG_PATTERN.test(String(shot.group))) { errors.push(`shot "${label}": group must be a lowercase slug`); } if (!Array.isArray(shot?.setup) || shot.setup.length === 0) { errors.push(`shot "${label}": setup must be a non-empty array`); continue; } for (const step of shot.setup) { const { action } = parseSetupStep(String(step)); if (!KNOWN_ACTIONS.includes(action)) { errors.push( `shot "${label}": unknown setup action "${action}" (expected ${KNOWN_ACTIONS.join(', ')})` ); } } if (shot.browser !== undefined) { errors.push(...validateBrowserShot(shot.browser, label)); } } return errors; } /** * A `browser` shot is framed in a separate Chromium page instead of the * Electron window — the phone view of the remote control. The page may only * open something the app itself serves on loopback: any other origin would * turn the capture into a screenshot of the internet. * * @param {unknown} browser the shot's `browser` field * @param {string} label shot slug for messages * @returns {string[]} problems; empty when valid */ function validateBrowserShot(browser, label) { const errors = []; const url = typeof browser?.url === 'string' ? browser.url : ''; if (!/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(url)) { errors.push( `shot "${label}": browser.url must be a loopback http URL, got ${JSON.stringify(browser?.url)}` ); } const viewport = browser?.viewport; const isSize = (value) => Number.isInteger(value) && value >= 200 && value <= 4000; if (!viewport || !isSize(viewport.width) || !isSize(viewport.height)) { errors.push( `shot "${label}": browser.viewport must carry integer width and height between 200 and 4000` ); } return errors; } /** * A release slug names a directory under apps/website/public/blog, which a * full run replaces — including a recursive delete of what was there. Reject * anything that could escape that tree or resolve somewhere unexpected. * * @param {string} release * @returns {string | null} error message, or null when the slug is safe */ export function validateReleaseSlug(release) { if (!release) { return 'release slug is empty'; } if (!/^[a-z0-9][a-z0-9.-]*$/.test(release) || release.includes('..')) { return `release slug "${release}" must be a lowercase slug without path separators (for example v0-24)`; } return null; } /** @returns {Set} all slugs, for `.changes/` screenshot validation */ export function manifestSlugs(manifest) { return new Set((manifest?.shots ?? []).map((shot) => shot.slug)); } /* ------------------------------------------------------------------ */ /* G2 — environment allowlist */ /* ------------------------------------------------------------------ */ const ENV_ALLOWLIST = [ 'PATH', 'HOME', 'SHELL', 'TMPDIR', 'TZ', 'LANG', 'DISPLAY', 'WAYLAND_DISPLAY', // X11 sessions authenticate through an xauth cookie file; without it the // Electron child cannot open the display and the run dies before a window // exists. It grants display access, not application credentials. 'XAUTHORITY', ]; const ENV_ALLOWED_PREFIXES = ['LC_', 'XDG_']; /** * The capture app gets a constructed environment, never `...process.env`: * ambient variables are how TMDB keys, proxies, and experiment flags leak * into a run that must be hermetic. * * @param {Record} baseEnv * @param {Record} overrides explicit run configuration * @returns {Record} */ export function buildCaptureEnv(baseEnv, overrides) { const env = {}; for (const [key, value] of Object.entries(baseEnv)) { if (value === undefined) { continue; } if ( ENV_ALLOWLIST.includes(key) || ENV_ALLOWED_PREFIXES.some((prefix) => key.startsWith(prefix)) ) { env[key] = value; } } return { ...env, ...overrides }; } /* ------------------------------------------------------------------ */ /* G3 — network gate */ /* ------------------------------------------------------------------ */ /** Single source of truth for what may load during a capture run. */ export const ALLOWED_SCHEMES = [ 'file:', 'data:', 'blob:', 'about:', 'chrome:', 'devtools:', ]; export const ALLOWED_HOSTS = ['localhost', '127.0.0.1']; export const ALLOWED_NETWORK_PROTOCOLS = ['http:', 'ws:']; /** * Deny by default. Only local mock traffic and the app's own bundle may load; * anything else (TMDB, logo CDNs, real streams) is blocked and — because the * caller fails on a non-empty list — fatal, since a silently blocked request * produces a frame that merely looks broken instead of unsafe. * * @param {string} url * @returns {boolean} */ export function isAllowedRequestUrl(url) { try { const { hostname, protocol } = new URL(url); if (ALLOWED_SCHEMES.includes(protocol)) { return true; } return ( ALLOWED_NETWORK_PROTOCOLS.includes(protocol) && ALLOWED_HOSTS.includes(hostname) ); } catch { // `blob:file:///…` and similar opaque forms do not parse as URLs. return ALLOWED_SCHEMES.some((scheme) => url.startsWith(scheme)); } } /** * App-level requests that are legitimate in production but must not leave * the machine during a capture run. The driver fulfills them locally with * an empty payload instead of letting them hit the network — a deterministic * stub, not an allowlist hole. Keep this list minimal and exact. * * @param {string} url * @returns {{ body: string, contentType: string } | null} stub response */ export const STUB_URL_PREFIXES = [ // Settings "release notes" / update check. 'https://api.github.com/repos/4gray/iptvnator/releases', ]; export function stubbedResponseFor(url) { if (STUB_URL_PREFIXES.some((prefix) => url.startsWith(prefix))) { return { body: '[]', contentType: 'application/json' }; } return null; } /** * Data handed to the main-process request hook, which cannot import this * module. Keeping the values here means the two predicates cannot disagree * about what is local even though the check itself is written twice. * * @returns {{ schemes: string[], hosts: string[], protocols: string[], stubPrefixes: string[] }} */ export function networkPolicy() { return { schemes: ALLOWED_SCHEMES, hosts: ALLOWED_HOSTS, protocols: ALLOWED_NETWORK_PROTOCOLS, stubPrefixes: STUB_URL_PREFIXES, }; } /** * Publishes a staged frame set over the release directory as one swap. * * Copy-then-rename rather than moving file by file: staging lives in the * system temp directory, which may be a different filesystem (rename would * fail with EXDEV), and a per-file loop that fails midway would leave the * release holding a mix of new and old screenshots. Files are copied into a * sibling of the target — same filesystem, so the directory swap is atomic — * and the previous set is only deleted once the new one is in place. * * `mode` decides what happens to shots the staging set does not contain: * a full run replaces the directory so slugs dropped from the manifest do not * linger, while a filtered run (`--only`, `--theme`) must overlay its frames * onto the existing set — otherwise refreshing one shot would delete every * other screenshot of the release. * * @param {string} stagingDir * @param {string} outputRoot * @param {string} token unique suffix for the scratch directories * @param {{ mode?: 'replace' | 'merge', rename?: (from: string, to: string) => void }} [options] * `rename` is a seam for exercising the rollback path, which the filesystem * will not fail on demand * @returns {number} number of published files */ export function publishDirectory( stagingDir, outputRoot, token, { mode = 'replace', rename = renameSync } = {} ) { const incoming = `${outputRoot}.incoming-${token}`; const retired = `${outputRoot}.retired-${token}`; mkdirSync(path.dirname(outputRoot), { recursive: true }); rmSync(incoming, { recursive: true, force: true }); mkdirSync(incoming, { recursive: true }); if (mode === 'merge' && existsSync(outputRoot)) { for (const name of readdirSync(outputRoot)) { const source = path.join(outputRoot, name); if (statSync(source).isFile()) { copyFileSync(source, path.join(incoming, name)); } } } const names = readdirSync(stagingDir); for (const name of names) { copyFileSync(path.join(stagingDir, name), path.join(incoming, name)); } const hadPrevious = existsSync(outputRoot); if (hadPrevious) { rename(outputRoot, retired); } try { rename(incoming, outputRoot); } catch (error) { if (hadPrevious) { rename(retired, outputRoot); } rmSync(incoming, { recursive: true, force: true }); throw error; } rmSync(retired, { recursive: true, force: true }); return names.length; } /** * Verdict over every request URL observed during a run — both the ones the * page-level route blocked and the ones the main-process recorder saw during * startup, before page interception exists. * * @param {string[]} urls * @returns {string[]} unique offending URLs; empty when the run stayed local */ export function externalRequestViolations(urls) { return [...new Set(urls)].filter( (url) => !isAllowedRequestUrl(url) && !stubbedResponseFor(url) ); } /* ------------------------------------------------------------------ */ /* G4 — frame content assertions */ /* ------------------------------------------------------------------ */ const CREDENTIAL_TEXT_PATTERNS = [ /https?:\/\/[^\s"']*(?:username|password)=/i, /\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b/i, // MAC address (Stalker identity) /https?:\/\/(?!localhost|127\.0\.0\.1)[^\s"']+\.m3u8?\b/i, ]; /** * The one MAC address a published frame may show: the stalker-mock-server's * `marketing-demo` scenario. It exists only in the mock's scenario table, so * it identifies no real subscriber. Any other MAC-shaped text still fails the * shot, which is what keeps the Stalker guide screenshots from ever carrying * a real box identity. */ export const FICTIONAL_STALKER_MAC = '00:1A:79:00:00:07'; /** * Evaluated against a DOM report collected right before each screenshot: * every image/background URL and the visible text. External resources or * credential-shaped text fail the shot. * * @param {{ resourceUrls: string[], bodyText: string }} report * @returns {string[]} violations; empty when the frame is safe */ export function evaluateFrameReport(report) { const violations = []; for (const url of report.resourceUrls) { if (!isAllowedRequestUrl(url)) { violations.push(`external resource in frame: ${url}`); } } const bodyText = report.bodyText .split(FICTIONAL_STALKER_MAC) .join('[fictional-mac]'); for (const pattern of CREDENTIAL_TEXT_PATTERNS) { const match = bodyText.match(pattern); if (match) { violations.push( `credential-shaped text visible in frame: "${match[0].slice(0, 80)}"` ); } } return violations; } /* ------------------------------------------------------------------ */ /* G1 — real database untouched */ /* ------------------------------------------------------------------ */ /** * Snapshots every file in the real database directory — `iptvnator.db` plus * its SQLite WAL sidecars (`-wal`, `-shm`), where writes live until a * checkpoint, so a mutation cannot hide in them. * * Identity is size + mtime + inode, deliberately not a content hash: the * production database is multi-gigabyte, and the previous `readFileSync` + * sha256 approach threw on it and was swallowed into "file does not exist" — * which silently disabled this guard entirely. Only ENOENT means absent; any * other error propagates. * * @param {string} directory * @returns {{ exists: boolean, entries: Record }} */ export function snapshotDatabaseState(directory) { let names; try { names = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') { return { exists: false, entries: {} }; } throw error; } const entries = {}; for (const name of names.sort()) { try { const stats = statSync(path.join(directory, name)); if (stats.isFile()) { entries[name] = { size: stats.size, mtimeMs: stats.mtimeMs, ino: stats.ino, }; } } catch (error) { if (error.code !== 'ENOENT') { throw error; } } } return { exists: true, entries }; } /** * @returns {string | null} violation message, or null when unchanged */ export function compareDatabaseStates(before, after) { if (before.exists !== after.exists) { return before.exists ? 'the real database directory disappeared during the capture run' : 'the real database directory was CREATED during the capture run'; } if (!before.exists) { return null; } const names = new Set([ ...Object.keys(before.entries), ...Object.keys(after.entries), ]); for (const name of [...names].sort()) { const from = before.entries[name]; const to = after.entries[name]; if (!from) { return `the real database gained ${name} during the capture run`; } if (!to) { return `the real database lost ${name} during the capture run`; } if ( from.size !== to.size || from.mtimeMs !== to.mtimeMs || from.ino !== to.ino ) { return `the real database file ${name} was modified during the capture run (close IPTVnator and any other process using it, then retry)`; } } return null; }