Skip to content

Commit 31b6e33

Browse files
committed
feat(core): migrate admin updater to R2 latest.json manifest
- Replace GitHub Releases path with ADMIN_UPDATE.s3BaseUrl + latest.json (default https://admin-r2.innei.dev). - UpdateService.fetchAdminManifest resolves {version,url,sha256?}; leader streams via UpdateDownloadService.downloadDirect with sha256 verification. - Restrict GitHub auth header to api.github.com hosts so the R2 fetch stays anonymous. - Relax update.controller cross-version gate to block only major jumps; minor/patch upgrade freely. - Drop dead ADMIN_DASHBOARD_REPO constant and pageproxy GH-release helper. - release.yml latest.json now includes sha256 to match admin-release.yml.
1 parent 4e1244f commit 31b6e33

11 files changed

Lines changed: 306 additions & 167 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,13 @@ jobs:
114114
ADMIN_ZIP="admin-${ADMIN_VERSION}.zip"
115115
# Package the locally-built admin (apps/admin/dist) with a top-level dist/ wrapper.
116116
( cd apps/admin && zip -r "../../${ADMIN_ZIP}" dist )
117+
SHA="$(sha256sum "${ADMIN_ZIP}" | awk '{print $1}')"
117118
cat > latest.json <<EOF
118119
{
119120
"version": "${ADMIN_VERSION}",
120121
"file": "${ADMIN_ZIP}",
121122
"url": "${ADMIN_PUBLIC_BASE%/}/${ADMIN_ZIP}",
123+
"sha256": "${SHA}",
122124
"tag": "${{ github.ref_name }}"
123125
}
124126
EOF

.husky/pre-commit

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/bin/sh
2+
3+
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then
4+
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook."
5+
exit 0
6+
fi
7+
8+
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
9+
. "$SIMPLE_GIT_HOOKS_RC"
10+
fi
11+
12+
pnpm lint-staged

apps/core/src/app.config.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,10 @@ const commander = program
199199
.option('--pg_max_pool_size <number>', 'PostgreSQL pool size')
200200
.option('--pg_ssl', 'enable PostgreSQL TLS')
201201

202-
// admin asset update (reserved)
202+
// admin asset update — R2 manifest base URL (default points at the public bucket)
203203
.option(
204204
'--admin_update_s3_base_url <string>',
205-
'reserved: S3 base URL for admin asset updates (empty = disabled, falls back to GitHub releases)',
205+
'S3/R2 base URL hosting latest.json + admin-<version>.zip',
206206
)
207207

208208
commander.parse()
@@ -388,12 +388,12 @@ export const POSTGRES = {
388388
: false,
389389
}
390390

391-
// Reserved for the future electron-style admin asset update source.
392-
// When `s3BaseUrl` is non-empty, the admin updater will fetch a `latest.json`
393-
// manifest + admin zip from S3 instead of GitHub releases. Empty string is the
394-
// "disabled" sentinel, keeping the current GitHub upgrade path as the default.
391+
// Admin asset update source. `latest.json` and `admin-<version>.zip` are read
392+
// from this base URL. Default points at the public R2 bucket that release.yml
393+
// and admin-release.yml publish to; override with --admin_update_s3_base_url or
394+
// ADMIN_UPDATE_S3_BASE_URL when self-hosting the bucket.
395395
export const ADMIN_UPDATE = {
396396
s3BaseUrl: (argv.admin_update_s3_base_url ||
397397
process.env.ADMIN_UPDATE_S3_BASE_URL ||
398-
'') as string,
398+
'https://admin-r2.innei.dev') as string,
399399
}

apps/core/src/constants/admin.constant.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

apps/core/src/modules/pageproxy/admin-download.manager.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,6 @@ export class AdminDownloadManager {
8989
private startDownload(): void {
9090
this.initialize()
9191

92-
// TODO(admin-update S3): future electron-style update source.
93-
// When `ADMIN_UPDATE.s3BaseUrl` (env ADMIN_UPDATE_S3_BASE_URL) is set, fetch a
94-
// `latest.json` manifest from S3 to resolve the newest admin version + zip URL,
95-
// download that zip, and install it via the existing extract/install path —
96-
// instead of querying GitHub releases. This is only RESERVED for now: the
97-
// built-in admin (built locally at core build time) is the default served asset,
98-
// and the GitHub upgrade path below remains the unchanged active behavior.
9992
this.updateService
10093
.getLatestAdminVersion()
10194
.then((version) => {

apps/core/src/modules/pageproxy/pageproxy.service.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'
55
import { parseHTML } from 'linkedom'
66

77
import { API_VERSION } from '~/app.config'
8-
import { ADMIN_DASHBOARD_REPO } from '~/constants/admin.constant'
98

109
import { ConfigsService } from '../configs/configs.service'
1110
import { OwnerService } from '../owner/owner.service'
@@ -22,26 +21,6 @@ export class PageProxyService {
2221
return adminExtra.enableAdminProxy || isDev
2322
}
2423

25-
/**
26-
* @returns {Promise<string>} version `x.y.z` , not startwith `v`
27-
* @throws {Error}
28-
*/
29-
async getAdminLastestVersionFromGHRelease(): Promise<string> {
30-
const thirdParty = await this.configs.get(
31-
'thirdPartyServiceIntegration',
32-
)
33-
const githubToken = thirdParty.github?.token
34-
const response = await fetch(
35-
`https://api.github.com/repos/${ADMIN_DASHBOARD_REPO}/releases/latest`,
36-
{
37-
headers: githubToken ? { Authorization: `Bearer ${githubToken}` } : {},
38-
},
39-
)
40-
const { tag_name } = await response.json()
41-
42-
return tag_name.replace(/^v/, '')
43-
}
44-
4524
async injectAdminEnv(
4625
htmlEntry: string,
4726
env: {

apps/core/src/modules/update/update-download.service.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from 'node:crypto'
2+
13
import { Injectable } from '@nestjs/common'
24
import axios, { AxiosRequestConfig } from 'axios'
35
import pc from 'picocolors'
@@ -65,14 +67,15 @@ export class UpdateDownloadService {
6567
config: AxiosRequestConfig = {},
6668
retries = this.MAX_RETRIES,
6769
): Promise<any> {
70+
const isGitHubApiRequest = this.shouldAttachGitHubAuth(url)
6871
const thirdParty = await this.configService.get(
6972
'thirdPartyServiceIntegration',
7073
)
71-
const githubToken = thirdParty.github?.token
74+
const githubToken = thirdParty?.github?.token
7275
const token = githubToken || process.env.GITHUB_TOKEN
7376
const headers = {
7477
...config.headers,
75-
...(token && { Authorization: `Bearer ${token}` }),
78+
...(token && isGitHubApiRequest && { Authorization: `Bearer ${token}` }),
7679
}
7780

7881
for (let attempt = 1; attempt <= retries; attempt++) {
@@ -88,7 +91,11 @@ export class UpdateDownloadService {
8891

8992
if (attempt === retries) {
9093
let finalMsg = `Failed after ${retries} attempts: ${errorMsg}`
91-
if (axios.isAxiosError(error) && error.response?.status === 403) {
94+
if (
95+
isGitHubApiRequest &&
96+
axios.isAxiosError(error) &&
97+
error.response?.status === 403
98+
) {
9299
finalMsg +=
93100
' (API Rate Limit Exceeded. Please configure GitHub Token in settings or env GITHUB_TOKEN)'
94101
}
@@ -101,6 +108,14 @@ export class UpdateDownloadService {
101108
}
102109
}
103110

111+
private shouldAttachGitHubAuth(url: string) {
112+
try {
113+
return new URL(url).hostname === 'api.github.com'
114+
} catch {
115+
return false
116+
}
117+
}
118+
104119
async downloadWithMirrors(
105120
originalUrl: string,
106121
expectedSize: number,
@@ -139,6 +154,27 @@ export class UpdateDownloadService {
139154
return null
140155
}
141156

157+
async downloadDirect(
158+
url: string,
159+
pushProgress: (msg: string) => Promise<void>,
160+
opts: { sha256?: string } = {},
161+
): Promise<ArrayBuffer> {
162+
await pushProgress(`Downloading ${url}\n`)
163+
const buffer = await this.downloadWithProgress(url, pushProgress)
164+
if (opts.sha256) {
165+
const actual = createHash('sha256')
166+
.update(Buffer.from(buffer))
167+
.digest('hex')
168+
if (actual !== opts.sha256.toLowerCase()) {
169+
throw new Error(
170+
`Checksum mismatch: expected sha256 ${opts.sha256}, got ${actual}`,
171+
)
172+
}
173+
await pushProgress(pc.green('Checksum verified.\n'))
174+
}
175+
return buffer
176+
}
177+
142178
private async downloadWithProgress(
143179
url: string,
144180
pushProgress: (msg: string) => Promise<void>,

apps/core/src/modules/update/update.controller.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from 'node:path'
55
import { Query, Sse } from '@nestjs/common'
66
import pc from 'picocolors'
77
import { catchError, Observable } from 'rxjs'
8-
import { lt, major, minor } from 'semver'
8+
import { lt, major } from 'semver'
99

1010
import { ApiController } from '~/common/decorators/api-controller.decorator'
1111
import { Auth } from '~/common/decorators/auth.decorator'
@@ -48,9 +48,20 @@ export class UpdateController {
4848
path.join(adminAssetRoot, 'index.html'),
4949
)
5050

51+
let latestVersion: string
52+
try {
53+
latestVersion = await this.service.getLatestAdminVersion()
54+
} catch (error: any) {
55+
observer.next(
56+
pc.red(`Fetching latest admin version error: ${error.message}\n`),
57+
)
58+
observer.complete()
59+
return
60+
}
61+
5162
if (!isExistLocalAdmin) {
5263
await pipeStream(
53-
this.service.startClusterAdminAssetUpdate(currentVersion),
64+
this.service.startClusterAdminAssetUpdate(latestVersion),
5465
)
5566
observer.complete()
5667
return
@@ -70,29 +81,16 @@ export class UpdateController {
7081
}
7182
}
7283

73-
let latestVersion: string
74-
try {
75-
latestVersion = await this.service.getLatestAdminVersion()
76-
} catch (error: any) {
77-
observer.next(
78-
pc.red(`Fetching latest admin version error: ${error.message}\n`),
79-
)
80-
observer.complete()
81-
return
82-
}
83-
8484
if (!lt(currentVersion, latestVersion)) {
8585
observer.next(pc.green(`Admin dashboard is up to date.\n`))
8686
observer.complete()
8787
return
8888
}
89-
const isCrossVersion =
90-
minor(currentVersion) !== minor(latestVersion) ||
91-
major(currentVersion) !== major(latestVersion)
92-
if (!force && !isDev && isCrossVersion) {
89+
const isMajorJump = major(currentVersion) !== major(latestVersion)
90+
if (!force && !isDev && isMajorJump) {
9391
observer.next(
9492
pc.red(
95-
`The latest version is ${latestVersion}, current version is ${currentVersion}, can not cross-version upgrade.\n`,
93+
`Major version jump ${currentVersion} -> ${latestVersion} requires force=true.\n`,
9694
),
9795
)
9896
observer.complete()

apps/core/src/modules/update/update.service.ts

Lines changed: 37 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { rm } from 'node:fs/promises'
21
import { hostname } from 'node:os'
32

43
import {
@@ -10,16 +9,22 @@ import {
109
import pc from 'picocolors'
1110
import { Observable, ReplaySubject } from 'rxjs'
1211

13-
import { ADMIN_DASHBOARD_REPO } from '~/constants/admin.constant'
12+
import { ADMIN_UPDATE } from '~/app.config'
1413
import { RedisService } from '~/processors/redis/redis.service'
1514

1615
import { UpdateDownloadService } from './update-download.service'
1716
import { UpdateInstallService } from './update-install.service'
1817

19-
const repo = ADMIN_DASHBOARD_REPO
20-
2118
const REDIS_KEY_PREFIX = 'update:admin'
2219

20+
interface AdminUpdateManifest {
21+
version: string
22+
file: string
23+
url: string
24+
sha256?: string
25+
tag?: string
26+
}
27+
2328
const LUA_RELEASE_LOCK = `
2429
if redis.call("get", KEYS[1]) == ARGV[1] then
2530
return redis.call("del", KEYS[1])
@@ -196,47 +201,22 @@ export class UpdateService implements OnModuleInit, OnModuleDestroy {
196201
.catch(() => {})
197202
}
198203

199-
const endpoint = `https://api.github.com/repos/${repo}/releases/tags/v${version}`
200-
await pushProgress(`Getting release info from ${endpoint}.\n`)
201-
202-
const releaseInfo = await this.downloadService.fetchWithRetry(endpoint, {
203-
timeout: 30000,
204-
headers: {
205-
'User-Agent': 'Mix-Space-Admin-Updater',
206-
Accept: 'application/vnd.github.v3+json',
207-
},
208-
})
209-
210-
if (!releaseInfo?.assets) {
211-
throw new Error('Release assets not found')
212-
}
213-
214-
const asset = releaseInfo.assets.find(
215-
(asset: any) => asset.name === 'release.zip',
216-
)
217-
218-
if (!asset) {
219-
const msg = `release.zip not found. Available: ${releaseInfo.assets.map((a: any) => a.name).join(', ')}`
220-
throw new Error(msg)
204+
const manifest = await this.fetchAdminManifest()
205+
if (manifest.version !== version) {
206+
throw new Error(
207+
`Admin manifest version mismatch: requested ${version}, got ${manifest.version}`,
208+
)
221209
}
222-
223-
const downloadUrl = asset.browser_download_url
224-
const fileSize = asset.size
225-
226210
await pushProgress(
227-
`Found release.zip (${this.downloadService.formatBytes(fileSize)})\n`,
211+
`Resolved admin v${manifest.version}${manifest.url}\n`,
228212
)
229213

230-
const buffer = await this.downloadService.downloadWithMirrors(
231-
downloadUrl,
232-
fileSize,
214+
const buffer = await this.downloadService.downloadDirect(
215+
manifest.url,
233216
async (msg: string) => pushProgress(msg),
217+
{ sha256: manifest.sha256 },
234218
)
235219

236-
if (!buffer) {
237-
throw new Error('All download sources failed')
238-
}
239-
240220
await pushProgress('Storing buffer to Redis for other instances...\n')
241221
await this.redis.setex(
242222
keys.bufferKey,
@@ -300,7 +280,6 @@ export class UpdateService implements OnModuleInit, OnModuleDestroy {
300280
.catch(() => {})
301281

302282
subscriber.next(pc.red(`Download failed: ${errorMsg}\n`))
303-
await rm('admin-release.zip', { force: true }).catch(() => {})
304283
} finally {
305284
clearInterval(heartbeat)
306285
await this.redis
@@ -548,24 +527,31 @@ export class UpdateService implements OnModuleInit, OnModuleDestroy {
548527
}
549528

550529
async getLatestAdminVersion() {
551-
const endpoint = `https://api.github.com/repos/${repo}/releases/latest`
530+
const manifest = await this.fetchAdminManifest()
531+
return manifest.version
532+
}
552533

534+
private async fetchAdminManifest(): Promise<AdminUpdateManifest> {
535+
const baseUrl = ADMIN_UPDATE.s3BaseUrl?.replace(/\/+$/, '')
536+
if (!baseUrl) {
537+
throw new Error(
538+
'Admin update source is not configured (ADMIN_UPDATE_S3_BASE_URL)',
539+
)
540+
}
541+
const endpoint = `${baseUrl}/latest.json`
553542
try {
554-
const data = await this.downloadService.fetchWithRetry(endpoint, {
555-
headers: {
556-
'User-Agent': 'Mix-Space-Admin-Updater',
557-
Accept: 'application/vnd.github.v3+json',
558-
},
559-
})
543+
const data = (await this.downloadService.fetchWithRetry(endpoint, {
544+
timeout: 30000,
545+
headers: { Accept: 'application/json' },
546+
})) as Partial<AdminUpdateManifest> | undefined
560547

561-
const tag = data?.tag_name
562-
if (!tag) {
563-
throw new Error('tag_name not found in release info')
548+
if (!data?.version || !data.url) {
549+
throw new Error('latest.json missing required fields (version, url)')
564550
}
565-
return tag.replace(/^v/, '')
551+
return data as AdminUpdateManifest
566552
} catch (error) {
567553
const errorMsg = error instanceof Error ? error.message : String(error)
568-
throw new Error(`Failed to get latest version: ${errorMsg}`, {
554+
throw new Error(`Failed to fetch admin manifest: ${errorMsg}`, {
569555
cause: error,
570556
})
571557
}

0 commit comments

Comments
 (0)