173 lines
7.0 KiB
JavaScript
173 lines
7.0 KiB
JavaScript
// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
|
|
// GET /api/ota/check/:current_sequence
|
|
// - 客户端传当前 sequence, 服务端判断是否有更新
|
|
// - 有: 200 + manifest JSON
|
|
// - 无: 204 No Content
|
|
// GET /api/ota/bundle/:filename
|
|
// - 下载前端 bundle (tar.gz), 供 manifest.url 引用
|
|
// GET /api/ota/manifest-web
|
|
// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
|
|
//
|
|
// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
|
|
// - latest.json (由 scripts/build-ota.mjs 生成)
|
|
// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
|
|
// - <filename> (tar.gz 升级包)
|
|
//
|
|
// 只更新 OTA 升级包 (不重建镜像/APK) 的通道:
|
|
// POST /api/ota/admin/bundle (mounted at /api)
|
|
// - Header x-ota-token 必须等于环境变量 OTA_ADMIN_TOKEN (CI 从配置中心注入)
|
|
// - body = tar.gz 原始字节 (Content-Type: application/gzip)
|
|
// - 元数据走 header: x-ota-seq(>当前), x-ota-sha256, x-ota-signature(非空),
|
|
// x-ota-version, x-ota-min-bin, x-ota-notes
|
|
// - 校验通过后覆盖写 OTA_PACKAGE_DIR/latest.json + wardrobe-ota.tar.gz
|
|
// - 进程内每次请求现读文件, 无需重启; 配合 -v wardrobe-ota:/app/ota-package 卷可持久化
|
|
import { Router, raw } from 'express'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import crypto from 'node:crypto'
|
|
|
|
const router = Router()
|
|
|
|
function resolveOtaDir() {
|
|
const dir = process.env.OTA_PACKAGE_DIR
|
|
? path.resolve(process.env.OTA_PACKAGE_DIR)
|
|
: path.resolve(process.cwd(), 'ota-package')
|
|
return dir
|
|
}
|
|
|
|
function readLatest() {
|
|
const p = path.join(resolveOtaDir(), 'latest.json')
|
|
if (!fs.existsSync(p)) return null
|
|
try {
|
|
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
} catch (e) {
|
|
console.error('[ota] latest.json 解析失败:', e.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// GET /api/ota/check/:current_sequence
|
|
// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
|
|
router.get('/ota/check/:current_sequence', (req, res) => {
|
|
const currentSeq = Number(req.params.current_sequence)
|
|
if (!Number.isFinite(currentSeq) || currentSeq < 0) {
|
|
return res.status(400).json({ error: '无效的 sequence' })
|
|
}
|
|
const latest = readLatest()
|
|
if (!latest) {
|
|
// 无可用包, 按约定返回 204
|
|
return res.status(204).end()
|
|
}
|
|
// 不新于当前则 204
|
|
if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
|
|
return res.status(204).end()
|
|
}
|
|
const dir = resolveOtaDir()
|
|
const base = process.env.OTA_PUBLIC_BASE || ''
|
|
res.json({
|
|
version: latest.version,
|
|
sequence: latest.sequence,
|
|
min_binary_version: latest.min_binary_version || '0.0.0',
|
|
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
|
|
signature: latest.signature,
|
|
notes: latest.notes || '',
|
|
pub_date: latest.pub_date || new Date().toISOString(),
|
|
mandatory: !!latest.mandatory,
|
|
bundle_size: latest.bundle_size || 0,
|
|
})
|
|
})
|
|
|
|
// GET /api/ota/bundle/:filename - 流式下载 bundle
|
|
router.get('/ota/bundle/:filename', (req, res) => {
|
|
const dir = resolveOtaDir()
|
|
// 防越界: 只取 basename
|
|
const name = path.basename(req.params.filename)
|
|
const p = path.join(dir, name)
|
|
if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
|
|
const stat = fs.statSync(p)
|
|
res.setHeader('Content-Type', 'application/gzip')
|
|
res.setHeader('Content-Length', stat.size)
|
|
res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
|
|
fs.createReadStream(p).pipe(res)
|
|
})
|
|
|
|
// GET /api/ota/manifest-web - Web 浏览器用的简化版
|
|
// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
|
|
// 比对依据: 当前部署的 dist/.build-id (vite 构建时写入), 与浏览器内置 BUILD_ID 同源;
|
|
// 当服务端换了新 dist, 老标签页拿到的 build_id 变化 → 提示刷新
|
|
router.get('/ota/manifest-web', (_req, res) => {
|
|
const latest = readLatest()
|
|
if (!latest) return res.json({ available: false })
|
|
// 优先用"当前托管 dist"的 build_id (部署时 dist/.build-id 与浏览器页面同一次构建)
|
|
let buildId = null
|
|
try {
|
|
buildId = fs
|
|
.readFileSync(path.join(path.resolve(process.cwd(), 'dist'), '.build-id'), 'utf8')
|
|
.trim()
|
|
} catch { /* dist 未构建或无该文件时忽略 */ }
|
|
res.json({
|
|
available: true,
|
|
version: latest.version,
|
|
build_id: buildId || latest.build_id || latest.version,
|
|
note: latest.notes || '',
|
|
size: latest.bundle_size || 0,
|
|
})
|
|
})
|
|
|
|
// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
|
|
|
|
// POST /api/ota/admin/bundle - 只更新 OTA 升级包, 不重建镜像/APK
|
|
// .gitea/workflows/ota-only.yml 在构建+签名后调用此接口, 直推运行中的容器
|
|
router.post(
|
|
'/ota/admin/bundle',
|
|
raw({ type: 'application/gzip', limit: '32mb' }),
|
|
(req, res) => {
|
|
const expected = process.env.OTA_ADMIN_TOKEN || ''
|
|
const token = String(req.headers['x-ota-token'] || '')
|
|
if (!expected || token !== expected) {
|
|
return res.status(401).json({ error: 'unauthorized' })
|
|
}
|
|
const seq = Number(req.headers['x-ota-seq'])
|
|
if (!Number.isFinite(seq) || seq <= 0) {
|
|
return res.status(400).json({ error: '无效 sequence' })
|
|
}
|
|
const body = req.body
|
|
if (!Buffer.isBuffer(body) || body.length === 0) {
|
|
return res.status(400).json({ error: '空 bundle' })
|
|
}
|
|
const sha = String(req.headers['x-ota-sha256'] || '').trim().toLowerCase()
|
|
const h = crypto.createHash('sha256').update(body).digest('hex')
|
|
if (!sha || sha !== h) {
|
|
return res.status(400).json({ error: sha ? 'sha256 不匹配' : '缺少 sha256' })
|
|
}
|
|
const signature = String(req.headers['x-ota-signature'] || '').trim()
|
|
if (!signature) {
|
|
return res.status(400).json({ error: '缺少签名' })
|
|
}
|
|
const dir = resolveOtaDir()
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
const latest = readLatest()
|
|
if (latest && typeof latest.sequence === 'number' && seq <= latest.sequence) {
|
|
return res.status(409).json({ error: `sequence 不高于当前`, current: latest.sequence })
|
|
}
|
|
const manifest = {
|
|
version: String(req.headers['x-ota-version'] || '').trim() || '0.1.0-ota',
|
|
sequence: seq,
|
|
min_binary_version: String(req.headers['x-ota-min-bin'] || '').trim() || '0.0.0',
|
|
bundle: 'wardrobe-ota.tar.gz',
|
|
bundle_size: body.length,
|
|
bundle_sha256: h,
|
|
signature,
|
|
notes: String(req.headers['x-ota-notes'] || '').trim(),
|
|
pub_date: new Date().toISOString(),
|
|
mandatory: false,
|
|
build_id: 'seq-' + seq,
|
|
}
|
|
fs.writeFileSync(path.join(dir, 'wardrobe-ota.tar.gz'), body)
|
|
fs.writeFileSync(path.join(dir, 'latest.json'), JSON.stringify(manifest, null, 2))
|
|
console.log(`[ota] admin 上传 bundle: seq=${seq} size=${body.length} sha256=${h}`)
|
|
res.json({ ok: true, sequence: seq, size: body.length })
|
|
}
|
|
)
|
|
|
|
export default router |