diff --git a/apps/buddy/electron/main/updates/DesktopUpdatePackage.ts b/apps/buddy/electron/main/updates/DesktopUpdatePackage.ts index 8b4013834..75e06c896 100644 --- a/apps/buddy/electron/main/updates/DesktopUpdatePackage.ts +++ b/apps/buddy/electron/main/updates/DesktopUpdatePackage.ts @@ -7,7 +7,7 @@ import { join } from 'node:path' import { z } from 'zod' import { desktopUpdateArtifactName } from '../../shared/desktopDistribution' import { desktopVersionSchema } from '../../shared/desktopUpdates' -import { isLexoraReleaseUrl } from '../../shared/productLinks' +import { isLexoraReleaseUrl, RELEASE_DOWNLOAD_MIRRORS } from '../../shared/productLinks' const assetSchema = z.object({ name: z.string(), @@ -82,16 +82,55 @@ export async function downloadDesktopUpdatePackage(options: { signal.throwIfAborted() return destination } + // Mirrors first, GitHub direct last; any failure other than cancellation moves on to the next route. + const routes = [...RELEASE_DOWNLOAD_MIRRORS.map(prefix => `${prefix}${update.url}`), update.url] + let failure: unknown + for (const [index, route] of routes.entries()) { + signal.throwIfAborted() + try { + return await downloadFromRoute({ destination, route, update, get, signal, progress, direct: index === routes.length - 1 }) + } + catch (error) { + if (signal.aborted) + throw error + failure = error + } + } + throw failure +} + +// A mirror that does not answer within this time is skipped. +const MIRROR_RESPONSE_TIMEOUT = 15_000 + +async function downloadFromRoute(options: { + destination: string + route: string + direct: boolean + update: DesktopUpdatePackage + get: (url: string, init: { signal: AbortSignal }) => Promise + signal: AbortSignal + progress: (percent: number, received: number) => void +}): Promise { + const { destination, route, direct, update, get, signal, progress } = options const temporary = `${destination}.download` await rm(temporary, { force: true }) const file = await open(temporary, 'wx', 0o600) let reader: ReadableStreamDefaultReader | undefined try { - let url = update.url + let url = route + const allowedHosts = ['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', new URL(route).hostname] let response: Response | undefined for (let redirects = 0; redirects <= 5; redirects++) { signal.throwIfAborted() - response = await get(url, { signal }) + const requestController = new AbortController() + const requestSignal = AbortSignal.any([signal, requestController.signal]) + const timer = direct ? undefined : setTimeout(() => requestController.abort(), MIRROR_RESPONSE_TIMEOUT) + try { + response = await get(url, { signal: requestSignal }) + } + finally { + clearTimeout(timer) + } if (![301, 302, 303, 307, 308].includes(response.status)) break await response.body?.cancel() @@ -100,7 +139,7 @@ export async function downloadDesktopUpdatePackage(options: { throw new Error('UPDATE_DOWNLOAD_FAILED') const target = new URL(location, url) if (target.protocol !== 'https:' || target.username || target.password || target.port - || !['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com'].includes(target.hostname)) { + || !allowedHosts.includes(target.hostname)) { throw new Error('UPDATE_DOWNLOAD_URL_INVALID') } url = target.href diff --git a/apps/buddy/electron/main/updates/__tests__/DesktopUpdateDelivery.spec.ts b/apps/buddy/electron/main/updates/__tests__/DesktopUpdateDelivery.spec.ts index 4883b69b2..3b42421e2 100644 --- a/apps/buddy/electron/main/updates/__tests__/DesktopUpdateDelivery.spec.ts +++ b/apps/buddy/electron/main/updates/__tests__/DesktopUpdateDelivery.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { deferred } from '@buddy-tests/deferred' import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' import { describe, expect, it } from 'vitest' +import { RELEASE_DOWNLOAD_MIRRORS } from '../../../shared/productLinks' import { DesktopUpdateDelivery } from '../DesktopUpdateDelivery' const body = Buffer.from('delivery fixture') @@ -201,7 +202,7 @@ describe('update delivery', () => { delivery.discover({ mode: 'release', reason: 'asset-unavailable', update: null, fallback: null }) finish.resolve() await queued - expect(requests).toEqual([update.url]) + expect(requests).toEqual([`${RELEASE_DOWNLOAD_MIRRORS[0]}${update.url}`]) expect(delivery.state.status).toBe(decision === 'changed' ? 'idle' : 'available') await expect(readFile(join(directory, 'pending', newer.name))).rejects.toMatchObject({ code: 'ENOENT' }) } diff --git a/apps/buddy/electron/main/updates/__tests__/DesktopUpdatePackage.spec.ts b/apps/buddy/electron/main/updates/__tests__/DesktopUpdatePackage.spec.ts index b119bde8e..018273027 100644 --- a/apps/buddy/electron/main/updates/__tests__/DesktopUpdatePackage.spec.ts +++ b/apps/buddy/electron/main/updates/__tests__/DesktopUpdatePackage.spec.ts @@ -5,6 +5,7 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' import { describe, expect, it } from 'vitest' +import { RELEASE_DOWNLOAD_MIRRORS } from '../../../shared/productLinks' import { downloadDesktopUpdatePackage, selectDesktopUpdatePackage, verifyDesktopUpdatePackage } from '../DesktopUpdatePackage' const body = Buffer.from('isolated installer fixture') @@ -52,6 +53,19 @@ describe('desktop update packages', () => { expect(await readdir(join(root, 'pending'))).toEqual([]) }) + it('falls back through mirrors to GitHub direct and verifies whichever route answers', async () => { + const root = await createTemporaryDirectory('lexora-update-') + const requests: string[] = [] + const file = await downloadDesktopUpdatePackage({ directory: root, update, signal: new AbortController().signal, progress: () => {}, get: async (url) => { + requests.push(url) + if (url === update.url) + return new Response(body) + return url.startsWith(RELEASE_DOWNLOAD_MIRRORS[0]) ? new Response(Buffer.alloc(body.length)) : new Response(null, { status: 503 }) + } }) + expect(requests).toEqual([...RELEASE_DOWNLOAD_MIRRORS.map(prefix => `${prefix}${update.url}`), update.url]) + expect(await readFile(file)).toEqual(body) + }) + it('follows only trusted HTTPS asset redirects', async () => { const root = await createTemporaryDirectory('lexora-update-') await expect(downloadDesktopUpdatePackage({ directory: root, update, signal: new AbortController().signal, progress: () => {}, get: async () => new Response(null, { status: 302, headers: { location: 'https://untrusted.invalid/update.exe' } }) })).rejects.toThrow('UPDATE_DOWNLOAD_URL_INVALID') diff --git a/apps/buddy/electron/shared/productLinks.ts b/apps/buddy/electron/shared/productLinks.ts index 2428c140f..763728a2c 100644 --- a/apps/buddy/electron/shared/productLinks.ts +++ b/apps/buddy/electron/shared/productLinks.ts @@ -3,6 +3,8 @@ import buddyPackage from '../../package.json' const repositoryUrl = new URL(buddyPackage.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')) export const REPOSITORY_URL = repositoryUrl.href +// Public GitHub acceleration prefixes; the update package is verified by size and sha256, so a mirror is not trusted. +export const RELEASE_DOWNLOAD_MIRRORS = ['https://ghfast.top/', 'https://gh-proxy.com/'] as const export const RELEASES_API_URL = `https://api.github.com/repos${repositoryUrl.pathname}/releases?per_page=100` export const FEEDBACK_ISSUE_URL = `${repositoryUrl.href}/issues/new` export const DOCUMENTATION_URL = new URL('guide/quick-start', buddyPackage.homepage).href