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:
@@ -50,7 +50,7 @@ It ships as a **regular installer** *and* a **single portable `.exe`** (no admin
|
|||||||
|
|
||||||
## Download & install
|
## Download & 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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export const mockApi: Api = {
|
|||||||
latestVersion: '0.5.0',
|
latestVersion: '0.5.0',
|
||||||
notes:
|
notes:
|
||||||
'### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
|
'### What’s 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
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user