From 037ea2da32a4d213f66aecc4aac1984a1b8127c5 Mon Sep 17 00:00:00 2001 From: debont80 Date: Fri, 3 Jul 2026 08:20:21 -0400 Subject: [PATCH] feat(updater): bake a read-only update token into the build The release repo is private, so the auto-updater previously required each user to paste a Gitea token into Settings before it could see or download updates. Inject a read-only token at build time (AEROFETCH_UPDATE_TOKEN env var or a gitignored .update-token file) via electron.vite's `define`, and fall back to it in authHeader() when the user hasn't set their own. The token value lives only in the built bundle, never in source or git. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + electron.vite.config.ts | 18 ++++++++++++++++++ src/main/config.ts | 27 ++++++++++++++++++++++++--- src/main/updater.ts | 12 +++++++----- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e899c91..4753ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist # Secrets — never commit .gitea-token +.update-token # VS Code user settings (workspace settings/.vscode/launch.json are committed) .vscode/settings.json diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c94899d..d2ac481 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,10 +1,28 @@ import { resolve } from 'path' +import { existsSync, readFileSync } from 'fs' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' +// Read-only Gitea token compiled into the main bundle so the auto-updater can +// read the PRIVATE release repo without user setup (see config.ts BAKED_UPDATE_TOKEN). +// Source order: AEROFETCH_UPDATE_TOKEN env var (CI/secret), else a gitignored +// .update-token file (local builds). Absent → empty string → no token baked in. +// Keep this a dedicated read-only service-account token; the shipped bundle is public. +function bakedUpdateToken(): string { + const fromEnv = process.env.AEROFETCH_UPDATE_TOKEN?.trim() + if (fromEnv) return fromEnv + const file = resolve('.update-token') + return existsSync(file) ? readFileSync(file, 'utf8').trim() : '' +} + export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], + // Textually replaces the __AEROFETCH_UPDATE_TOKEN__ identifier in main-process + // code with the token literal at build time — value never lands in source/git. + define: { + __AEROFETCH_UPDATE_TOKEN__: JSON.stringify(bakedUpdateToken()) + }, resolve: { alias: { '@shared': resolve('src/shared') diff --git a/src/main/config.ts b/src/main/config.ts index 2c868bd..ac3ec8f 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -4,12 +4,33 @@ * timeouts, caps, tickers) and from user settings (settings.ts). Retarget a * fork's update source by editing these three values. * - * IMPORTANT: the update repo's 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 update + * The update repo is PRIVATE, so the release API + downloads require a token. + * A build-time read-only token (BAKED_UPDATE_TOKEN below) lets a shipped client + * reach it without the user configuring anything; a user-set updateToken in + * Settings still takes precedence. On a build with no token baked in, the update * check simply reports that it couldn't reach the server. */ export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938' export const UPDATE_OWNER = 'debont80' export const UPDATE_REPO = 'AeroFetch' export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` + +/** + * Read-only Gitea token baked in at build time so the auto-updater can read the + * PRIVATE release repo without each user pasting a token into Settings. Injected + * by electron.vite.config's `define` from the AEROFETCH_UPDATE_TOKEN env var (or a + * gitignored .update-token file) — so the real value lives only in the built + * bundle, never in source or git. + * + * SECURITY: a shipped client is decompilable, so treat this as PUBLIC. It MUST be + * a token from a dedicated service account with read-only access to ONLY this repo + * — never a personal/push-capable token. The user's own updateToken overrides it + * (see authHeader in updater.ts), so a private fork can point elsewhere. + * + * `__AEROFETCH_UPDATE_TOKEN__` is a `define`-replaced literal in built code; under + * plain vitest (no define) the identifier is undeclared, so the `typeof` guard + * keeps this from throwing and falls back to empty (no token). + */ +declare const __AEROFETCH_UPDATE_TOKEN__: string +export const BAKED_UPDATE_TOKEN: string = + typeof __AEROFETCH_UPDATE_TOKEN__ === 'string' ? __AEROFETCH_UPDATE_TOKEN__ : '' diff --git a/src/main/updater.ts b/src/main/updater.ts index 956e28d..824c0d4 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -4,7 +4,7 @@ import { stat, unlink } from 'fs/promises' import { join, normalize, dirname } from 'path' import { createHash } from 'crypto' import { getSettings } from './settings' -import { UPDATE_HOST, RELEASE_API } from './config' +import { UPDATE_HOST, RELEASE_API, BAKED_UPDATE_TOKEN } from './config' import { IpcChannels, type AppUpdateInfo, @@ -13,9 +13,11 @@ import { } 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). + * Authorization header for the update host. The user's updateToken from Settings + * wins if set (lets a private fork target its own repo); otherwise it falls back + * to BAKED_UPDATE_TOKEN — the read-only token compiled in at build time so a + * stock client can read the private release repo with no setup. Empty (no header) + * only when neither is present. * * 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 @@ -24,7 +26,7 @@ import { * a redirect can't bounce the header to another host on any path. */ function authHeader(): Record { - const tok = getSettings().updateToken?.trim() + const tok = getSettings().updateToken?.trim() || BAKED_UPDATE_TOKEN return tok ? { Authorization: `token ${tok}` } : {} }