// 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 } // - (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