import { app, net, shell, type WebContents } from 'electron' import { createWriteStream, type WriteStream } from 'fs' import { stat, unlink } from 'fs/promises' import { join, normalize, dirname } from 'path' import { createHash } from 'crypto' import { getSettings } from './settings' import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload, type AppUpdateProgress } from '@shared/ipc' /** * Authorization header for the update host. Empty unless the user has set an * updateToken in Settings — needed when the release repo is private or the Gitea * instance requires sign-in for anonymous access (the default on this instance). * * The token must never reach an origin other than the host-pinned UPDATE_HOST. * Every request that carries it guards against that on its own terms: the REST * check refuses to follow redirects (`redirect: 'error'`), and the download paths * use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So * a redirect can't bounce the header to another host on any path. */ function authHeader(): Record { const tok = getSettings().updateToken?.trim() return tok ? { Authorization: `token ${tok}` } : {} } // --- Update source ----------------------------------------------------------- // The Gitea repo whose Releases host the AeroFetch installers. The updater reads // the repo's latest release over the public REST API and downloads the installer // asset attached to it. // // IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable — // i.e. a public repo on a Gitea instance that permits anonymous API + downloads. // AeroFetch never ships a token; on a sign-in-required instance the check simply // reports that it couldn't reach the server. Change OWNER/REPO to retarget. const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938' const UPDATE_OWNER = 'debont80' const UPDATE_REPO = 'AeroFetch' const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` // Security: only ever download or execute a file served by the trusted update // host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects // release downloads to its attachment store — so this is re-checked on EVERY // redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd // response from bouncing us off the trusted host. export function isTrustedDownloadUrl(url: string): boolean { try { const u = new URL(url) return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host } catch { return false } } /** Where downloaded installers are written (and the only dir we'll run one from). */ function updateDir(): string { return app.getPath('temp') } /** * Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the * release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping * the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final * release (0.5.0): at most it compares equal, so we won't offer a prerelease as * an "upgrade" over the same shipped version. */ function parseVersion(v: string): number[] { // split() always yields at least one element; `?? ''` only satisfies the type // checker (noUncheckedIndexedAccess) for the [0] access. const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? '' return core .split('.') .map((p) => parseInt(p, 10)) .filter((n) => !Number.isNaN(n)) } /** Component-wise numeric compare: -1 if ab. */ export function compareVersions(a: string, b: string): number { const pa = parseVersion(a) const pb = parseVersion(b) const len = Math.max(pa.length, pb.length) for (let i = 0; i < len; i++) { const x = pa[i] ?? 0 const y = pb[i] ?? 0 if (x !== y) return x < y ? -1 : 1 } return 0 } interface GiteaAsset { name: string browser_download_url: string } interface GiteaRelease { tag_name: string body?: string html_url?: string assets?: GiteaAsset[] } /** * Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum` * output (` file`), or PowerShell Get-FileHash (uppercase). Returns the * lowercase digest, or null if there's no standalone 64-char hex token (so a * longer run like a sha512 digest is ignored rather than sliced). * * When `fileName` is given (the installer asset's name), a multi-line / combined * checksum file is matched line-by-line and the hash on the line naming THIS * asset wins — so a `.sha256` that happens to list several files can't * verify the installer against the wrong line's hash (B3). Falls back to the * first standalone digest for bare single-hash files (no filename present). */ export function extractSha256(text: string, fileName?: string): string | null { const hashRe = /\b[a-f0-9]{64}\b/i if (fileName) { const base = fileName.toLowerCase() for (const line of text.split(/\r?\n/)) { const m = line.match(hashRe) if (!m) continue // The filename is the last whitespace-delimited token on a sha256sum / // Get-FileHash line (optionally with a '*' binary-mode marker). Match it // exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line. const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '') if (last.toLowerCase() === base) return m[0].toLowerCase() } } const m = text.match(hashRe) return m ? m[0].toLowerCase() : null } /** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */ function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined { const exes = assets.filter((a) => /\.exe$/i.test(a.name)) return exes.find((a) => /setup/i.test(a.name)) ?? exes[0] } /** Query the configured repo's latest release and compare it to the running app. */ export async function checkForAppUpdate(): Promise { const currentVersion = app.getVersion() // Bound the check so a stalled/unreachable server can't hang the UI spinner // indefinitely — mirrors the timeouts on the yt-dlp calls. const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 15_000) try { const res = await fetch(RELEASE_API, { headers: { Accept: 'application/json', ...authHeader() }, // The API lives at an exact pinned URL and answers 200 directly. Refusing // redirects keeps an authenticated check from ever forwarding the token to // another origin; a stray redirect fails safe into the catch below. redirect: 'error', signal: controller.signal }) if (!res.ok) { const auth = res.status === 401 || res.status === 403 || res.status === 404 return { ok: false, available: false, currentVersion, error: auth ? 'Could not reach the update server (it may require sign-in for anonymous access).' : `Update check failed (HTTP ${res.status}).` } } const rel = (await res.json()) as GiteaRelease const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '') const asset = pickInstaller(rel.assets ?? []) const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0 return { ok: true, available, currentVersion, latestVersion, notes: rel.body?.trim() || undefined, // Only ever hand the OS browser a release page on our own trusted host. htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined, downloadUrl: asset?.browser_download_url, assetName: asset?.name } } catch (e) { const timedOut = e instanceof Error && e.name === 'AbortError' return { ok: false, available: false, currentVersion, error: timedOut ? 'Update check timed out — the server took too long to respond.' : e instanceof Error ? e.message : String(e) } } finally { clearTimeout(timer) } } /** How long the download may stall (no bytes received) before we give up. */ const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000 // Integrity: every release MUST attach a checksum asset named `.sha256` // (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's // lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse // to run an installer we can't verify against it. Note this is INTEGRITY + // defence-in-depth (corruption, truncation, tampering-after-publish, a redirect // bounced to other storage), NOT protection against a fully compromised host — // that host serves both files, so only Authenticode signing defends against it. const REQUIRE_CHECKSUM: boolean = true /** * GET a small text resource from the trusted host, re-validating the host on * every redirect hop (same discipline as the installer download) and capping the * body so a hostile/huge response can't exhaust memory. Used for the .sha256 file. */ function fetchTrustedText( url: string, maxBytes = 64 * 1024, timeoutMs = 15_000 ): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> { return new Promise((resolve) => { let settled = false const done = ( r: { ok: true; text: string } | { ok: false; status?: number; error: string } ): void => { if (settled) return settled = true clearTimeout(timer) if (!r.ok) request.abort() resolve(r) } const request = net.request({ url, redirect: 'manual' }) for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v) const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs) request.on('redirect', (_s, _m, redirectUrl) => { if (!isTrustedDownloadUrl(redirectUrl)) { done({ ok: false, error: 'redirect to an untrusted location' }) return } request.followRedirect() }) request.on('response', (response) => { const status = response.statusCode if (status < 200 || status >= 300) { done({ ok: false, status, error: `HTTP ${status}` }) return } const chunks: Buffer[] = [] let size = 0 response.on('data', (c: Buffer) => { size += c.length if (size > maxBytes) done({ ok: false, error: 'checksum file too large' }) else chunks.push(c) }) response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') })) response.on('aborted', () => done({ ok: false, error: 'interrupted' })) response.on('error', (e: Error) => done({ ok: false, error: e.message })) }) request.on('error', (e: Error) => done({ ok: false, error: e.message })) request.end() }) } /** Stream the installer to a temp file, pushing progress events to the renderer. */ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { if (!isTrustedDownloadUrl(url)) { return { ok: false, error: 'Refused to download from an untrusted location.' } } // Derive a safe .exe filename from the URL; fall back to a fixed name. const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe' const filePath = join(updateDir(), safeName) // Resolve the published checksum up front (tiny, fails fast) so we never pull a // ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset, // so its URL is the installer URL + '.sha256' — still on the host-pinned origin. const checksumUrl = (() => { const u = new URL(url) u.pathname += '.sha256' return u.toString() })() const sum = await fetchTrustedText(checksumUrl) let expectedSha: string | null = null if (sum.ok) { expectedSha = extractSha256(sum.text, safeName) if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." } } else if (sum.status === 404) { if (REQUIRE_CHECKSUM) { return { ok: false, error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.` } } } else { return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` } } return new Promise((resolve) => { let settled = false let fileStream: WriteStream | null = null let idle: NodeJS.Timeout | null = null // Single teardown point. On failure it also aborts the request, so a write // error (disk full, etc.) can't leave Electron pulling bytes into a dead // stream. abort() is idempotent, so the call sites below don't repeat it. const finish = (result: AppUpdateDownload): void => { if (settled) return settled = true if (idle) clearTimeout(idle) if (!result.ok) { request.abort() // Close the handle before unlinking (Windows won't delete an open file) // and never leave a partial/aborted installer lying around in temp. if (fileStream && !fileStream.destroyed) fileStream.destroy() unlink(filePath).catch(() => {}) } resolve(result) } // net.request (not fetch) so we can re-validate the host on EVERY redirect // hop: Gitea 302s a release download to its attachment store, and undici's // fetch hides the Location header under redirect:'manual', so it can't gate // hops. 'manual' here means a hop only proceeds if we call followRedirect(). const request = net.request({ url, redirect: 'manual' }) for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v) const armIdle = (): void => { if (idle) clearTimeout(idle) idle = setTimeout(() => { finish({ ok: false, error: 'Download stalled — please try again.' }) }, DOWNLOAD_IDLE_TIMEOUT_MS) } request.on('redirect', (_status, _method, redirectUrl) => { if (!isTrustedDownloadUrl(redirectUrl)) { finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' }) return } request.followRedirect() }) request.on('response', (response) => { const status = response.statusCode if (status < 200 || status >= 300) { finish({ ok: false, error: `Download failed (HTTP ${status}).` }) return } const lenHeader = response.headers['content-length'] const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined const stream = createWriteStream(filePath) fileStream = stream // Hash the bytes as they stream by, so verification needs no second pass. const hash = createHash('sha256') // Electron's IncomingMessage is event-based (no .pipe). pause()/resume() // exist at runtime but aren't in its type, so feature-detect them to apply // backpressure — without it a ~300 MB installer can buffer in memory. const flow = response as unknown as { pause?(): void; resume?(): void } let received = 0 armIdle() response.on('data', (chunk: Buffer) => { armIdle() received += chunk.length hash.update(chunk) if (!stream.write(chunk) && flow.pause && flow.resume) { flow.pause() stream.once('drain', () => flow.resume?.()) } if (!wc.isDestroyed()) { const progress: AppUpdateProgress = { received, total, fraction: total ? received / total : undefined } wc.send(IpcChannels.appUpdateProgress, progress) } }) response.on('end', () => { // Body fully received — "stalled" is no longer meaningful past this point. if (idle) clearTimeout(idle) stream.end(() => { // A truncated download (connection dropped mid-stream) must never be run. if (total !== undefined && received !== total) { finish({ ok: false, error: 'The download was incomplete — please try again.' }) return } // Verify the bytes we wrote match the release's published SHA-256. if (expectedSha && hash.digest('hex') !== expectedSha) { finish({ ok: false, error: 'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.' }) return } finish({ ok: true, filePath }) }) }) // A mid-stream abort emits neither 'end' nor always 'error'; catch it so the // promise can't hang. response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' })) response.on('error', (e: Error) => finish({ ok: false, error: e.message })) stream.on('error', (e: Error) => finish({ ok: false, error: e.message })) }) request.on('error', (e: Error) => finish({ ok: false, error: e.message })) request.end() }) } /** Launch a freshly-downloaded installer, then quit so it can replace the app. */ export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> { try { // Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir, // never an arbitrary path handed over IPC. Compare the parent directory by // equality — startsWith() is defeated by a sibling like `_evil\x.exe`. const expectedDir = normalize(updateDir()).toLowerCase() const target = normalize(filePath) if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) { return { ok: false, error: 'Refused to run an unexpected file.' } } await stat(target) // throws if the file is missing // Hand the installer to the OS via ShellExecute, which (unlike a raw // CreateProcess/spawn) honors the NSIS elevation manifest — needed if the // build ever flips to perMachine. openPath resolves once the launch is // initiated, so the installer process already exists when we quit. const err = await shell.openPath(target) if (err) return { ok: false, error: err } // Quit on the next tick so this IPC response reaches the renderer first; the // launched installer is independent of our process and survives the quit. setImmediate(() => app.quit()) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } } }