diff --git a/.gitignore b/.gitignore index e606f1c..90fff34 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,5 @@ backend/*.backup.* /work/ /desktop-client/design-qa.md -# 桌面端历史安装包仅保留在发布服务器,仓库只跟踪当前版本 +# 桌面安装包通过 Gitea Release 发布,Git 仅保存校验元数据 /frontend/downloads/*.exe -!/frontend/downloads/wanwan-cloud-desktop_v0.1.38_x64-setup.exe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb990c4..7edff0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,8 @@ npm audit 4. `npm ci` 依赖严格同步的 lock 文件;禁止在部署脚本中删除 `package-lock.json`。 5. Express 中间件顺序是安全边界:requestId/安全头/CORS/HTTPS/静态资源/API 限流/请求体/CSRF,不得无验证调整。 6. 桌面安装包目录 `frontend/downloads/` 是运行时发布目录,更新前必须备份且不得放进 `frontend/dist/` 的清理生命周期。 +7. 桌面安装包必须作为 Gitea Release 附件发布;Git 只提交 `frontend/desktop-release.json`,禁止提交 `.exe`。 +8. 更新 Release 元数据时必须核对标签、文件名、字节数和 SHA256,并运行 `npm run check:desktop-release`。 ## 改动原则 diff --git a/README.md b/README.md index 60d7c5d..5e1d63b 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,16 @@ SMTP密码: 你的授权码 1. 在登录页点击"下载桌面客户端",或访问后端更新接口获取安装包信息 2. 安装后使用网页端账号登录 3. 在客户端中浏览文件、上传/下载、创建分享或直链 -4. 如需发布新版本,将安装包放到 `frontend/downloads/` 并更新后台桌面端版本配置 +4. 桌面安装包由 Gitea Release 发布,部署脚本会按 `frontend/desktop-release.json` 下载并校验 SHA256 + +发布新桌面版本时,先创建对应的 `vX.Y.Z` Release 并上传安装包,再更新 +`frontend/desktop-release.json` 的版本、文件名、大小和 SHA256。不要把 `.exe` +提交到 Git。可用以下命令同步当前版本: + +```bash +cd frontend +npm run sync:desktop-release +``` ## 📁 项目结构 @@ -362,7 +371,9 @@ cd ../frontend && npm ci --include=dev && npm run build sudo systemctl restart wanwanyun ``` -`install.sh --update` 会在替换线上文件前构建前端,并保留 `frontend/downloads/` 中由管理端发布的桌面安装包。完整工程约束见 [CONTRIBUTING.md](./CONTRIBUTING.md)。 +`install.sh --update` 会在替换线上文件前同步并校验 Gitea Release、构建前端, +同时保留 `frontend/downloads/` 中已验证的运行时安装包。完整工程约束见 +[CONTRIBUTING.md](./CONTRIBUTING.md)。 ## 📊 性能优化建议 diff --git a/backend/tests/architecture-tests.js b/backend/tests/architecture-tests.js index c2aef78..e5941f1 100644 --- a/backend/tests/architecture-tests.js +++ b/backend/tests/architecture-tests.js @@ -91,6 +91,25 @@ test('生产部署统一使用 Vite dist 且不会删除 lock 文件', () => { } }); +test('桌面安装包由 Gitea Release 发布且不会再次提交到 Git', () => { + const gitignore = fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8'); + const release = JSON.parse(fs.readFileSync( + path.join(projectRoot, 'frontend', 'desktop-release.json'), + 'utf8' + )); + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + const compose = fs.readFileSync(path.join(projectRoot, 'docker-compose.yml'), 'utf8'); + + assert.ok(gitignore.split(/\r?\n/).includes('/frontend/downloads/*.exe')); + assert.strictEqual(gitignore.includes('!/frontend/downloads/'), false); + assert.match(release.sha256, /^[a-f0-9]{64}$/); + assert.ok(Number.isSafeInteger(release.size) && release.size > 0); + assert.strictEqual(release.tag, `v${release.version}`); + assert.strictEqual(release.publicPath, `/downloads/${release.asset}`); + assert.ok(installer.includes('sync_desktop_release')); + assert.ok(compose.includes('desktop-release:')); +}); + test('Docker 构建上下文排除依赖、环境配置和运行时数据', () => { const dockerIgnore = fs.readFileSync(path.join(projectRoot, 'backend', '.dockerignore'), 'utf8'); for (const requiredEntry of ['node_modules', '.env', 'data/*', 'storage/*']) { diff --git a/backend/tests/release-sync-tests.js b/backend/tests/release-sync-tests.js new file mode 100644 index 0000000..0703a82 --- /dev/null +++ b/backend/tests/release-sync-tests.js @@ -0,0 +1,73 @@ +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const projectRoot = path.resolve(__dirname, '..', '..'); +const syncScript = path.join(projectRoot, 'frontend', 'scripts', 'sync-desktop-release.js'); +const results = { passed: 0, failed: 0 }; + +function test(name, fn) { + try { + fn(); + results.passed += 1; + console.log(` [PASS] ${name}`); + } catch (error) { + results.failed += 1; + console.error(` [FAIL] ${name}: ${error.message}`); + } +} + +console.log('\n========== 桌面安装包发布同步测试 ==========\n'); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-release-sync-')); +try { + const destinationDirectory = path.join(tempRoot, 'downloads'); + const asset = 'wanwan-cloud-desktop_v9.9.9_x64-setup.exe'; + const assetContent = Buffer.from('verified desktop release fixture'); + const sha256 = crypto.createHash('sha256').update(assetContent).digest('hex'); + const metadataPath = path.join(tempRoot, 'desktop-release.json'); + fs.mkdirSync(destinationDirectory, { recursive: true }); + fs.writeFileSync(path.join(destinationDirectory, asset), assetContent); + fs.writeFileSync(metadataPath, JSON.stringify({ + version: '9.9.9', + tag: 'v9.9.9', + asset, + downloadUrl: `https://example.test/releases/download/v9.9.9/${asset}`, + publicPath: `/downloads/${asset}`, + sha256, + size: assetContent.length + })); + + const runCheck = () => spawnSync(process.execPath, [ + syncScript, + '--metadata', metadataPath, + '--destination-dir', destinationDirectory, + '--check-only' + ], { encoding: 'utf8' }); + + test('已缓存安装包通过大小与 SHA256 校验', () => { + const result = runCheck(); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Desktop release verified/); + }); + + test('损坏的安装包会让只读校验失败', () => { + fs.appendFileSync(path.join(destinationDirectory, asset), 'corrupt'); + const result = runCheck(); + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /Desktop release check failed/); + }); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +console.log('\n========================================'); +console.log('测试总结'); +console.log('========================================'); +console.log(`通过: ${results.passed}`); +console.log(`失败: ${results.failed}`); + +process.exit(results.failed > 0 ? 1 : 0); diff --git a/backend/tests/run-all-tests.js b/backend/tests/run-all-tests.js index c27697f..a765250 100644 --- a/backend/tests/run-all-tests.js +++ b/backend/tests/run-all-tests.js @@ -11,6 +11,7 @@ const testFiles = [ 'domain-utils-tests.js', 'middleware-tests.js', 'architecture-tests.js', + 'release-sync-tests.js', 'archive-tests.js', 'network-concurrent-tests.js', 'state-consistency-tests.js' diff --git a/docker-compose.yml b/docker-compose.yml index 37a48f8..3fb62d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,17 @@ # ============================================ services: + desktop-release: + image: node:20-alpine + container_name: wanwanyun-desktop-release + restart: "no" + working_dir: /workspace/frontend + command: ["node", "scripts/sync-desktop-release.js"] + volumes: + - ./frontend:/workspace/frontend + networks: + - wanwanyun-network + # ============================================ # 后端服务 # ============================================ @@ -26,6 +37,9 @@ services: # - ADMIN_PASSWORD=<至少8位且至少包含两类字符的强密码> env_file: - ./backend/.env + depends_on: + desktop-release: + condition: service_completed_successfully volumes: # 数据持久化 - ./backend/data:/app/data @@ -64,7 +78,10 @@ services: # - /etc/letsencrypt:/etc/letsencrypt:ro # - ./certbot/www:/var/www/certbot:ro depends_on: - - backend + desktop-release: + condition: service_completed_successfully + backend: + condition: service_healthy networks: - wanwanyun-network healthcheck: diff --git a/frontend/desktop-release.json b/frontend/desktop-release.json new file mode 100644 index 0000000..965d197 --- /dev/null +++ b/frontend/desktop-release.json @@ -0,0 +1,9 @@ +{ + "version": "0.1.38", + "tag": "v0.1.38", + "asset": "wanwan-cloud-desktop_v0.1.38_x64-setup.exe", + "downloadUrl": "https://git.workyai.cn/237899745/vue-driven-cloud-storage/releases/download/v0.1.38/wanwan-cloud-desktop_v0.1.38_x64-setup.exe", + "publicPath": "/downloads/wanwan-cloud-desktop_v0.1.38_x64-setup.exe", + "sha256": "67373bc05df4c1e22927ea0123065f32c9b8303ff0d23a4e1d2e830bb2f31ba7", + "size": 8674567 +} diff --git a/frontend/downloads/README.md b/frontend/downloads/README.md new file mode 100644 index 0000000..5cd05c5 --- /dev/null +++ b/frontend/downloads/README.md @@ -0,0 +1,12 @@ +# Desktop release cache + +Desktop installers are Gitea Release assets and are not stored in Git. + +Run the following command from `frontend/` to download and verify the release +declared in `desktop-release.json`: + +```bash +node scripts/sync-desktop-release.js +``` + +The installer is written to this directory and served from `/downloads/`. diff --git a/frontend/package.json b/frontend/package.json index 3f1c80c..ed23b9b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build && node scripts/copy-runtime-assets.js && node scripts/verify-build.js", + "sync:desktop-release": "node scripts/sync-desktop-release.js", + "check:desktop-release": "node scripts/sync-desktop-release.js --check-only", "preview": "vite preview --host 0.0.0.0" }, "dependencies": { diff --git a/frontend/scripts/sync-desktop-release.js b/frontend/scripts/sync-desktop-release.js new file mode 100644 index 0000000..25a625e --- /dev/null +++ b/frontend/scripts/sync-desktop-release.js @@ -0,0 +1,227 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat +} from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const frontendRoot = path.resolve(path.dirname(scriptPath), '..'); + +function parseArguments(argv) { + const options = { + metadataPath: path.join(frontendRoot, 'desktop-release.json'), + destinationDirectory: path.join(frontendRoot, 'downloads'), + checkOnly: false + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--check-only') { + options.checkOnly = true; + continue; + } + if (argument === '--metadata' || argument === '--destination-dir') { + const value = argv[index + 1]; + if (!value) throw new Error(`${argument} requires a path`); + index += 1; + if (argument === '--metadata') options.metadataPath = path.resolve(value); + if (argument === '--destination-dir') options.destinationDirectory = path.resolve(value); + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + + return options; +} + +export function validateMetadata(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Desktop release metadata must be an object'); + } + + const metadata = { + version: String(value.version || '').trim(), + tag: String(value.tag || '').trim(), + asset: String(value.asset || '').trim(), + downloadUrl: String(value.downloadUrl || '').trim(), + publicPath: String(value.publicPath || '').trim(), + sha256: String(value.sha256 || '').trim().toLowerCase(), + size: Number(value.size) + }; + + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(metadata.version)) { + throw new Error('Desktop release version is invalid'); + } + if (metadata.tag !== `v${metadata.version}`) { + throw new Error('Desktop release tag must match the version'); + } + if (!metadata.asset || path.basename(metadata.asset) !== metadata.asset) { + throw new Error('Desktop release asset must be a file name'); + } + if (!/\.exe$/i.test(metadata.asset)) { + throw new Error('Desktop release asset must be an EXE file'); + } + if (!/^[a-f0-9]{64}$/.test(metadata.sha256)) { + throw new Error('Desktop release SHA256 is invalid'); + } + if (!Number.isSafeInteger(metadata.size) || metadata.size <= 0) { + throw new Error('Desktop release size is invalid'); + } + + let releaseUrl; + try { + releaseUrl = new URL(metadata.downloadUrl); + } catch { + throw new Error('Desktop release download URL is invalid'); + } + if (releaseUrl.protocol !== 'https:') { + throw new Error('Desktop release download URL must use HTTPS'); + } + if (decodeURIComponent(path.basename(releaseUrl.pathname)) !== metadata.asset) { + throw new Error('Desktop release URL does not match the asset name'); + } + if (metadata.publicPath !== `/downloads/${metadata.asset}`) { + throw new Error('Desktop release public path does not match the asset name'); + } + + return metadata; +} + +export async function computeFileSha256(filePath) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk); + } + return hash.digest('hex'); +} + +async function inspectInstaller(filePath, metadata) { + try { + const fileStats = await stat(filePath); + if (!fileStats.isFile() || fileStats.size !== metadata.size) { + return { valid: false, reason: 'size_mismatch' }; + } + const digest = await computeFileSha256(filePath); + return { + valid: digest === metadata.sha256, + reason: digest === metadata.sha256 ? 'verified' : 'sha256_mismatch' + }; + } catch (error) { + if (error?.code === 'ENOENT') return { valid: false, reason: 'missing' }; + throw error; + } +} + +async function downloadInstaller(metadata, temporaryPath) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 120_000); + try { + const response = await fetch(metadata.downloadUrl, { + headers: { 'user-agent': 'wanwanyun-release-sync/1.0' }, + redirect: 'follow', + signal: controller.signal + }); + if (!response.ok || !response.body) { + throw new Error(`Desktop release download failed with HTTP ${response.status}`); + } + if (new URL(response.url).protocol !== 'https:') { + throw new Error('Desktop release redirected to an insecure URL'); + } + + const hash = createHash('sha256'); + const output = await open(temporaryPath, 'wx', 0o600); + let downloadedBytes = 0; + try { + for await (const chunk of Readable.fromWeb(response.body)) { + const buffer = Buffer.from(chunk); + downloadedBytes += buffer.length; + hash.update(buffer); + let offset = 0; + while (offset < buffer.length) { + const { bytesWritten } = await output.write(buffer, offset); + if (bytesWritten <= 0) throw new Error('Desktop release write made no progress'); + offset += bytesWritten; + } + } + await output.sync(); + } finally { + await output.close(); + } + + const digest = hash.digest('hex'); + if (downloadedBytes !== metadata.size) { + throw new Error(`Desktop release size mismatch: expected ${metadata.size}, received ${downloadedBytes}`); + } + if (digest !== metadata.sha256) { + throw new Error('Desktop release SHA256 verification failed'); + } + } finally { + clearTimeout(timeout); + } +} + +async function replaceInstaller(temporaryPath, destinationPath) { + try { + await rename(temporaryPath, destinationPath); + } catch (error) { + if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error; + await rm(destinationPath, { force: true }); + await rename(temporaryPath, destinationPath); + } + await chmod(destinationPath, 0o644); +} + +export async function syncDesktopRelease(options) { + const rawMetadata = JSON.parse(await readFile(options.metadataPath, 'utf8')); + const metadata = validateMetadata(rawMetadata); + const destinationDirectory = path.resolve(options.destinationDirectory); + const destinationPath = path.join(destinationDirectory, metadata.asset); + await mkdir(destinationDirectory, { recursive: true }); + + const existing = await inspectInstaller(destinationPath, metadata); + if (existing.valid) { + return { status: 'verified', destinationPath, metadata }; + } + if (options.checkOnly) { + throw new Error(`Desktop release check failed: ${existing.reason}`); + } + + const temporaryPath = path.join( + destinationDirectory, + `.${metadata.asset}.${process.pid}.${Date.now()}.tmp` + ); + try { + await downloadInstaller(metadata, temporaryPath); + await replaceInstaller(temporaryPath, destinationPath); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } + + return { status: 'downloaded', destinationPath, metadata }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const result = await syncDesktopRelease(options); + console.log( + `Desktop release ${result.status}: ${result.metadata.asset} ` + + `(${result.metadata.sha256})` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + main().catch((error) => { + console.error(`Desktop release sync failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/install.sh b/install.sh index 933961f..8ffe25b 100644 --- a/install.sh +++ b/install.sh @@ -2022,6 +2022,24 @@ download_project() { echo "" } +sync_desktop_release() { + local project_root="${1:-$PROJECT_DIR}" + local sync_script="${project_root}/frontend/scripts/sync-desktop-release.js" + + print_step "同步桌面客户端 Release..." + if [[ ! -f "$sync_script" ]]; then + print_error "桌面安装包同步脚本不存在: $sync_script" + return 1 + fi + if node "$sync_script"; then + print_success "桌面客户端 Release 已下载并通过校验" + else + print_error "桌面客户端 Release 同步失败,停止部署" + return 1 + fi + echo "" +} + configure_admin_account() { print_step "配置管理员账号" echo "" @@ -3539,6 +3557,13 @@ update_pull_latest_code() { cp -a "$TEMP_BACKUP/frontend-downloads/." "/tmp/${PROJECT_NAME}-update/frontend/downloads/" fi + if ! sync_desktop_release "/tmp/${PROJECT_NAME}-update"; then + if command -v pm2 &> /dev/null; then + pm2 restart ${PROJECT_NAME}-backend || true + fi + exit 1 + fi + print_info "构建前端生产资源..." cd "/tmp/${PROJECT_NAME}-update/frontend" if ! npm ci --include=dev || ! npm run build; then @@ -4230,6 +4255,9 @@ main() { # 安装前端依赖并生成 dist install_frontend_dependencies + # 从 Gitea Release 下载并校验桌面安装包 + sync_desktop_release "$PROJECT_DIR" + # 创建配置文件 create_env_file