feat: retarget the auto-updater from Gitea to GitHub

The source repo moved to github.com/debont80/AeroFetch; the in-app
updater and release links still pointed at the old self-hosted Gitea
instance. Repoint RELEASE_API at the GitHub REST API, replace the
single-host download trust check with an allowlist (github.com plus
its release-asset CDN hosts, which GitHub 302s downloads through —
mirrors the existing FFMPEG_TRUSTED_HOSTS pattern), and add the
User-Agent header GitHub's API requires.

No bridge: installs on a Gitea-pointed build (<=0.7.3) won't
auto-discover releases published to GitHub and need one manual
download, matching the precedent set by the earlier releases-repo
migration.
This commit is contained in:
2026-07-16 12:47:09 -04:00
parent 79ab94248d
commit 44230c757e
8 changed files with 81 additions and 46 deletions
+2 -2
View File
@@ -50,7 +50,7 @@ It ships as a **regular installer** *and* a **single portable `.exe`** (no admin
## Download &amp; install ## Download &amp; install
Grab the latest build from the [**Releases**](https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases) page: Grab the latest build from the [**Releases**](https://github.com/debont80/AeroFetch/releases) page:
- **`AeroFetch Setup <version>.exe`** — standard installer. Installs per-user (no admin), adds a Start-menu shortcut, and auto-updates. - **`AeroFetch Setup <version>.exe`** — standard installer. Installs per-user (no admin), adds a Start-menu shortcut, and auto-updates.
- **`AeroFetch-<version>-portable.exe`** — single self-contained executable. No install, no admin; keeps its data in an `AeroFetch-data` folder beside the `.exe`. Ideal for locked-down or shared PCs. - **`AeroFetch-<version>-portable.exe`** — single self-contained executable. No install, no admin; keeps its data in an `AeroFetch-data` folder beside the `.exe`. Ideal for locked-down or shared PCs.
@@ -64,7 +64,7 @@ Each release also publishes a `.sha256` next to every executable so you can veri
Requires **Node.js 24.x**. Requires **Node.js 24.x**.
```bash ```bash
git clone https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git git clone https://github.com/debont80/AeroFetch.git
cd AeroFetch cd AeroFetch
npm install npm install
+14 -6
View File
@@ -2,19 +2,27 @@
* Build/host configuration (CC11): the deploy-specific constants that identify * Build/host configuration (CC11): the deploy-specific constants that identify
* WHERE this build talks to, separated from runtime tunables (constants.ts — * WHERE this build talks to, separated from runtime tunables (constants.ts —
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a * timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
* fork's update source by editing these three values. * fork's update source by editing these values.
* *
* The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous * The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous
* reads work and the updater sends no auth at all; there is no secret in the bundle * reads work and the updater sends no auth at all; there is no secret in the bundle
* to leak. (A prior design split releases into a separate releases-only repo so the * to leak. (Earlier designs split releases into a separate releases-only repo, then
* source could stay private; that's retired now that the source repo is public * moved the whole source repo off a self-hosted Gitea to GitHub — each retirement
* itself — one fewer moving part.) A private fork retargets its updater by editing * left one fewer moving part.) A private fork retargets its updater by editing
* these constants and rebuilding. * these constants and rebuilding.
*/ */
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
export const UPDATE_OWNER = 'debont80' export const UPDATE_OWNER = 'debont80'
export const UPDATE_REPO = 'AeroFetch' export const UPDATE_REPO = 'AeroFetch'
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` export const UPDATE_API_HOST = 'https://api.github.com'
export const RELEASE_API = `${UPDATE_API_HOST}/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
// Hosts a release asset download may touch, HTTPS-only, re-checked on EVERY redirect
// hop (mirrors FFMPEG_TRUSTED_HOSTS below): github.com serves browser_download_url,
// which GitHub 302s to one of these asset-storage CDN hosts.
export const UPDATE_TRUSTED_HOSTS = [
'github.com',
'release-assets.githubusercontent.com',
'objects.githubusercontent.com'
] as const
// --- ffmpeg dependency source ------------------------------------------------ // --- ffmpeg dependency source ------------------------------------------------
// ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size); // ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size);
+20 -15
View File
@@ -1,25 +1,25 @@
import { app, net, shell, type WebContents } from 'electron' import { app, net, shell, type WebContents } from 'electron'
import { stat } from 'fs/promises' import { stat } from 'fs/promises'
import { join, normalize, dirname } from 'path' import { join, normalize, dirname } from 'path'
import { UPDATE_HOST, RELEASE_API } from './config' import { UPDATE_TRUSTED_HOSTS, RELEASE_API } from './config'
import { streamVerifiedFile } from './lib/verifiedDownload' import { streamVerifiedFile } from './lib/verifiedDownload'
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc' import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'
// --- Update source ----------------------------------------------------------- // --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers lives in // The GitHub repo whose Releases host the AeroFetch installers lives in
// config.ts (CC11) with the other build/host constants. The updater reads the // config.ts (CC11) with the other build/host constants. The updater reads the
// repo's latest release over the public REST API and downloads the installer // repo's latest release over the public REST API and downloads the installer
// asset attached to it. // asset attached to it.
// Security: only ever download or execute a file served by the trusted update // Security: only ever download or execute a file served by an allowlisted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects // host over HTTPS. downloadUrl comes from the release JSON, and GitHub 302-redirects
// release downloads to its attachment store — so this is re-checked on EVERY // release downloads to an asset-storage CDN host — so this is re-checked on EVERY
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd // redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
// response from bouncing us off the trusted host. // response from bouncing us off the trusted hosts.
export function isTrustedDownloadUrl(url: string): boolean { export function isTrustedDownloadUrl(url: string): boolean {
try { try {
const u = new URL(url) const u = new URL(url)
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host return u.protocol === 'https:' && (UPDATE_TRUSTED_HOSTS as readonly string[]).includes(u.host)
} catch { } catch {
return false return false
} }
@@ -60,15 +60,15 @@ export function compareVersions(a: string, b: string): number {
return 0 return 0
} }
interface GiteaAsset { interface GithubAsset {
name: string name: string
browser_download_url: string browser_download_url: string
} }
interface GiteaRelease { interface GithubRelease {
tag_name: string tag_name: string
body?: string body?: string
html_url?: string html_url?: string
assets?: GiteaAsset[] assets?: GithubAsset[]
} }
/** /**
@@ -102,7 +102,7 @@ export function extractSha256(text: string, fileName?: string): string | null {
} }
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */ /** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined { function pickInstaller(assets: GithubAsset[]): GithubAsset | undefined {
const exes = assets.filter((a) => /\.exe$/i.test(a.name)) const exes = assets.filter((a) => /\.exe$/i.test(a.name))
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0] return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
} }
@@ -116,7 +116,12 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const timer = setTimeout(() => controller.abort(), 15_000) const timer = setTimeout(() => controller.abort(), 15_000)
try { try {
const res = await fetch(RELEASE_API, { const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json' }, headers: {
Accept: 'application/vnd.github+json',
// GitHub's REST API rejects requests with no User-Agent (403).
'User-Agent': 'AeroFetch-Updater',
'X-GitHub-Api-Version': '2022-11-28'
},
// The API lives at an exact pinned URL and answers 200 directly. Refusing // The API lives at an exact pinned URL and answers 200 directly. Refusing
// redirects keeps the check pinned to that origin; a stray redirect (e.g. to // redirects keeps the check pinned to that origin; a stray redirect (e.g. to
// a sign-in page) fails safe into the catch below rather than being followed. // a sign-in page) fails safe into the catch below rather than being followed.
@@ -130,11 +135,11 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
available: false, available: false,
currentVersion, currentVersion,
error: auth error: auth
? 'Could not reach the update server (it may require sign-in for anonymous access).' ? 'Could not reach the update server (rate-limited, or the repo/release is unavailable).'
: `Update check failed (HTTP ${res.status}).` : `Update check failed (HTTP ${res.status}).`
} }
} }
const rel = (await res.json()) as GiteaRelease const rel = (await res.json()) as GithubRelease
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '') const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
const asset = pickInstaller(rel.assets ?? []) const asset = pickInstaller(rel.assets ?? [])
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0 const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
@@ -302,7 +307,7 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise<A
url, url,
filePath, filePath,
expectedSha, expectedSha,
// Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store). // Re-check the trusted hosts on every redirect hop (GitHub 302s to its asset CDN).
isTrusted: isTrustedDownloadUrl, isTrusted: isTrustedDownloadUrl,
onProgress: (progress) => { onProgress: (progress) => {
if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress) if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress)
+1 -1
View File
@@ -44,7 +44,7 @@ const api = {
/** AeroFetch's own version string (e.g. '0.3.1'). */ /** AeroFetch's own version string (e.g. '0.3.1'). */
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion), getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
/** Check the configured Gitea repo for a newer AeroFetch release. */ /** Check the configured GitHub repo for a newer AeroFetch release. */
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck), checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
/** Download the latest release's installer to a temp file. */ /** Download the latest release's installer to a temp file. */
+2 -2
View File
@@ -67,8 +67,8 @@ export const mockApi: Api = {
latestVersion: '0.5.0', latestVersion: '0.5.0',
notes: notes:
'### Whats new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes', '### Whats new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases', htmlUrl: 'https://github.com/debont80/AeroFetch/releases',
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe', downloadUrl: 'https://github.com/debont80/AeroFetch/releases/download/v0.5.0/AeroFetch-Setup-0.5.0.exe',
assetName: 'AeroFetch-Setup-0.5.0.exe' assetName: 'AeroFetch-Setup-0.5.0.exe'
} }
}, },
+3 -3
View File
@@ -6,7 +6,7 @@
export const IpcChannels = { export const IpcChannels = {
/** the AeroFetch app version (package.json / app.getVersion) */ /** the AeroFetch app version (package.json / app.getVersion) */
appVersion: 'app:version', appVersion: 'app:version',
/** check the configured Gitea repo for a newer AeroFetch release */ /** check the configured GitHub repo for a newer AeroFetch release */
appUpdateCheck: 'app:update-check', appUpdateCheck: 'app:update-check',
/** download the latest release's installer to a temp file */ /** download the latest release's installer to a temp file */
appUpdateDownload: 'app:update-download', appUpdateDownload: 'app:update-download',
@@ -328,7 +328,7 @@ export interface FfmpegSetupResult {
error?: string error?: string
} }
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */ /** Result of checking the configured GitHub repo for a newer AeroFetch release. */
export interface AppUpdateInfo { export interface AppUpdateInfo {
ok: boolean ok: boolean
/** true when latestVersion is newer than the running app */ /** true when latestVersion is newer than the running app */
@@ -339,7 +339,7 @@ export interface AppUpdateInfo {
latestVersion?: string latestVersion?: string
/** the release notes / changelog body (Markdown), shown before updating */ /** the release notes / changelog body (Markdown), shown before updating */
notes?: string notes?: string
/** the release's web page on Gitea (for "View release") */ /** the release's web page on GitHub (for "View release") */
htmlUrl?: string htmlUrl?: string
/** direct download URL of the Windows installer asset, when the release has one */ /** direct download URL of the Windows installer asset, when the release has one */
downloadUrl?: string downloadUrl?: string
+22 -16
View File
@@ -69,35 +69,41 @@ describe('isAllowedLoginUrl (audit T4)', () => {
}) })
}) })
// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops ------- // --- app-updater: isTrustedDownloadUrl — host allowlist across redirect hops -
describe('isTrustedDownloadUrl (app-updater host pin)', () => { describe('isTrustedDownloadUrl (app-updater host allowlist)', () => {
const onHost = const onHost = 'https://github.com/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe' const onCdnHost =
'https://release-assets.githubusercontent.com/github-production-release-asset/123/abc'
it('accepts https URLs on the exact update host (incl. port)', () => { it('accepts https URLs on github.com and its asset CDN hosts', () => {
expect(isTrustedDownloadUrl(onHost)).toBe(true) expect(isTrustedDownloadUrl(onHost)).toBe(true)
expect(isTrustedDownloadUrl(onCdnHost)).toBe(true)
expect(
isTrustedDownloadUrl(
'https://objects.githubusercontent.com/github-production-release-asset/x'
)
).toBe(true)
}) })
it('rejects a different host — the redirect/MITM bounce vector', () => { it('rejects a different host — the redirect/MITM bounce vector', () => {
expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false) expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false)
}) })
it('rejects the right hostname on the wrong port', () => { it('rejects a lookalike subdomain', () => {
// host comparison includes the port, so :443 (default) is not the same origin expect(isTrustedDownloadUrl('https://github.com.evil.example/x.exe')).toBe(false)
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(
false
)
}) })
it('rejects plain http even on the update host', () => { it('rejects a trusted hostname on an unexpected port', () => {
expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false) expect(isTrustedDownloadUrl('https://github.com:1234/x.exe')).toBe(false)
}) })
it('is not fooled by the trusted host placed in the userinfo', () => { it('rejects plain http even on a trusted host', () => {
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe')).toBe( expect(isTrustedDownloadUrl('http://github.com/x.exe')).toBe(false)
false })
)
it('is not fooled by a trusted host placed in the userinfo', () => {
expect(isTrustedDownloadUrl('https://github.com@evil.example/x.exe')).toBe(false)
}) })
it('rejects unparseable input', () => { it('rejects unparseable input', () => {
+17 -1
View File
@@ -73,7 +73,8 @@ import type { WebContents } from 'electron'
// --- helpers ------------------------------------------------------------------ // --- helpers ------------------------------------------------------------------
const HOST = 'https://gitea.netbird.zimspace.uk:5938' const HOST = 'https://github.com'
const CDN_HOST = 'https://release-assets.githubusercontent.com'
const ASSET = `${HOST}/debont80/AeroFetch/releases/download/v9.9.9/AeroFetch-Setup-9.9.9.exe` const ASSET = `${HOST}/debont80/AeroFetch/releases/download/v9.9.9/AeroFetch-Setup-9.9.9.exe`
const INSTALLER = Buffer.from('FAKE-INSTALLER-BYTES-'.repeat(64)) const INSTALLER = Buffer.from('FAKE-INSTALLER-BYTES-'.repeat(64))
const GOOD_SHA = createHash('sha256').update(INSTALLER).digest('hex') const GOOD_SHA = createHash('sha256').update(INSTALLER).digest('hex')
@@ -213,6 +214,21 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
expect(madeRequests[1]!.redirectsFollowed).toBe(1) expect(madeRequests[1]!.redirectsFollowed).toBe(1)
}) })
it('follows a redirect to the allowlisted asset CDN host (github.com → githubusercontent.com)', async () => {
requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`))
requestScripts.push((req) => {
req.emit('redirect', 302, 'GET', `${CDN_HOST}/github-production-release-asset/abc123`)
const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) })
req.emit('response', res)
res.emit('data', INSTALLER)
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(true)
expect(madeRequests[1]!.redirectsFollowed).toBe(1)
})
it('refuses a checksum-fetch redirect off the trusted host', async () => { it('refuses a checksum-fetch redirect off the trusted host', async () => {
requestScripts.push((req) => { requestScripts.push((req) => {
req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256') req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256')