Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions apps/buddy/electron/main/updates/DesktopUpdatePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Response>
signal: AbortSignal
progress: (percent: number, received: number) => void
}): Promise<string> {
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<Uint8Array> | 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()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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' })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/electron/shared/productLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down