From 944f818fb75db7bfe2317750448e5c7e842809c5 Mon Sep 17 00:00:00 2001 From: code2rich Date: Wed, 29 Jul 2026 09:40:42 +0800 Subject: [PATCH] feat: support ZIP bundle versioning --- AGENTS.md | 8 +- README.md | 2 + docs/api.md | 9 +- lib/file-storage.js | 123 ++++++++++++ lib/render.js | 5 +- lib/zip.js | 253 ++++++++++++++++++++---- mcp/tools-files.js | 7 +- migrations/027_zip_bundle_versions.js | 56 ++++++ public/js/pages/home.js | 36 ++-- public/js/pages/preview.js | 23 ++- routes/files/_shared.js | 87 ++------- routes/files/crud.js | 20 +- routes/files/overwrite.js | 41 ++++ routes/files/upload.js | 22 ++- routes/files/versions.js | 175 ++++++++++++++--- test/integration/zip-versions.test.js | 264 ++++++++++++++++++++++++++ 16 files changed, 951 insertions(+), 180 deletions(-) create mode 100644 lib/file-storage.js create mode 100644 migrations/027_zip_bundle_versions.js create mode 100644 test/integration/zip-versions.test.js diff --git a/AGENTS.md b/AGENTS.md index 18d2d94..ae50768 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,7 +244,7 @@ CI(`.github/workflows/ci.yml`)在 Node 20/22 矩阵上执行 `npm run lint` - `_migrations(id, name UNIQUE, applied_at)` - `files(id, original_name, stored_name, file_type, size, created_at, updated_at, is_public, uploaded_by, share_key, category_id, is_bundle, entry_path, view_count, template_id, share_expires_at, share_password_hash, upload_source, source_asset_id, created_from)` -- `file_versions(id, file_id, version, stored_name, size, created_at, uploaded_by, upload_source, performed_by)` +- `file_versions(id, file_id, version, stored_name, size, created_at, uploaded_by, upload_source, performed_by, is_bundle, entry_path, file_type)` - `users(id, username UNIQUE, email, email_verified, password_hash, role, created_at, total_storage_bytes, api_calls_count, storage_quota_bytes)`(email 有 `WHERE email IS NOT NULL` 唯一索引) - `api_calls(id, user_id, source, action, method, path, status, created_at)` - `tokens(id, user_id, name, token_hash UNIQUE, token_prefix, token_enc, last_used_at, created_at)` @@ -269,8 +269,8 @@ CI(`.github/workflows/ci.yml`)在 Node 20/22 矩阵上执行 `npm run lint` - **用户管理(admin)**:`/api/users` - **个人用量**:`/api/users/me/usage` - **API Token**:`/api/tokens`、`/api/tokens/:id/reveal` -- **文件管理**:`/api/files`、`/api/files/search`、`/api/files/upload`、`/api/files/upload-json`、`/api/files/upload-zip-base64`、`/api/files/batch`、`/api/files/:id`、`/api/files/:id/content`、`/api/files/:id/render`、`/api/files/:id/download`、`/api/files/:id/asset/*`、`/api/files/:id/overwrite`、`/api/files/:id/overwrite-json`、`/api/files/:id/stats` -- **版本历史**:`/api/files/:id/versions`、versions content/render/restore/delete +- **文件管理**:`/api/files`、`/api/files/search`、`/api/files/upload`、`/api/files/upload-json`、`/api/files/upload-zip-base64`、`/api/files/batch`、`/api/files/:id`、`/api/files/:id/content`、`/api/files/:id/render`、`/api/files/:id/download`、`/api/files/:id/asset/*`、`/api/files/:id/overwrite`、`/api/files/:id/overwrite-json`、`/api/files/:id/overwrite-zip-base64`、`/api/files/:id/stats` +- **版本历史**:`/api/files/:id/versions`、versions content/render/download/restore/delete(bundle 历史按目录保存) - **分享设置**:`/api/files/:id/share`(PUT 别名/过期/密码)、`/api/files/:id/share/regenerate` - **标签**:`/api/tags`、`/api/files/:id/tags` - **收藏**:`/api/files/:id/star` @@ -346,7 +346,7 @@ CLI 与 MCP 共用同一套 REST API,是对等的两个客户端入口。 - `_migrations(name UNIQUE)` 记录已应用的 migration。 - `migrations/` 下文件按文件名排序执行;每个文件导出 `{ name, async up(db, { dbRun, dbGet, dbAll }) }`。 - **新增 migration 规则**: - 1. 命名格式 `{序号}_{描述}.js`,序号接续当前最大值(当前已到 022)。 + 1. 命名格式 `{序号}_{描述}.js`,序号接续当前最大值(当前已到 027)。 2. 新建表用 `CREATE TABLE IF NOT EXISTS`。 3. 新增列必须幂等:先 `PRAGMA table_info` 检查是否存在,再 `ALTER TABLE ADD COLUMN`。 4. SQLite `ALTER TABLE ADD COLUMN` 不支持非恒定默认值;需要默认值时先加列(无默认或恒定默认),再 `UPDATE` 回填。 diff --git a/README.md b/README.md index fdad349..70eec3d 100644 --- a/README.md +++ b/README.md @@ -312,8 +312,10 @@ jpage/ | `/api/files/:id/download` | GET | 流式下载文件(Bundle 以 ZIP 下载) | | `/api/files/:id/asset/*` | GET | Bundle 资源文件访问 | | `/api/files/:id/overwrite` | POST | 覆盖上传(自动版本备份) | +| `/api/files/:id/overwrite-zip-base64` | POST | base64 bundle ZIP 显式覆盖 | | `/api/files/:id/versions` | GET | 版本历史列表 | | `/api/files/:id/versions/:ver/restore` | POST | 恢复到指定版本 | +| `/api/files/:id/versions/:ver/download` | GET | 下载历史版本(Bundle 以 ZIP 下载) | | `/api/files/:id/stats` | GET | 访问统计(viewCount/daily7/daily30) | | `/s/:key` | GET | 短链接渲染页面 | diff --git a/docs/api.md b/docs/api.md index acb67bb..5977e84 100644 --- a/docs/api.md +++ b/docs/api.md @@ -145,7 +145,7 @@ curl -b jpage.sid -X POST http://localhost:8858/api/files/upload \ 返回 `{id, original_name, file_type, size, is_public}`。 -`.zip` 走两种模式:(1) **网站包 Bundle**(含 `index.html` + 资源目录),存为解压目录,`is_bundle=1`;(2) **批量上传**(多个独立 HTML/MD),各自建文件记录。同名文件自动覆盖(旧版本备份到 `file_versions`)。 +`.zip` 走两种模式:(1) **网站包 Bundle**(含 `index.html` + 资源目录),存为解压目录,`is_bundle=1`;(2) **批量上传**(多个独立 HTML/MD),各自建文件记录。同一用户再次上传同名 bundle ZIP 时保留原 file ID / 短链并归档旧目录;batch ZIP 则按包内文件名逐项覆盖并生成各自历史版本。包内不同路径出现相同 basename 时拒绝上传。 ### `POST /api/files/upload-json` — JSON @@ -226,7 +226,11 @@ Bundle 资源文件访问(带路径穿越校验)。 ### `POST /api/files/:id/overwrite` — multipart -预览页专用覆盖上传,自动把旧版本备份到 `file_versions`。 +预览页专用覆盖上传,自动把旧版本备份到 `file_versions`。普通文件只能用相同 HTML/Markdown 类型覆盖;bundle 只能用分类结果仍为 bundle 的 ZIP 覆盖,batch ZIP 不能覆盖单一 ID。 + +### `POST /api/files/:id/overwrite-zip-base64` — JSON + +MCP 使用的 bundle ZIP 显式覆盖端点。Body: `{name, content(base64)}`。目标必须是 bundle,且 ZIP 必须分类为 bundle。 ### `POST /api/files/:id/overwrite-json` — JSON @@ -247,6 +251,7 @@ MCP 使用的 JSON 覆盖上传,自动版本备份。 | `/api/files/:id/versions` | GET | 版本历史列表 | | `/api/files/:id/versions/:ver/content` | GET | 历史版本原文 | | `/api/files/:id/versions/:ver/render` | GET | 渲染历史版本 | +| `/api/files/:id/versions/:ver/download` | GET | 下载历史版本;bundle 动态打包为 ZIP | | `/api/files/:id/versions/:ver/restore` | POST | 恢复到指定版本 | | `/api/files/:id/versions/:ver` | DELETE | 删除指定历史版本 | diff --git a/lib/file-storage.js b/lib/file-storage.js new file mode 100644 index 0000000..5619e35 --- /dev/null +++ b/lib/file-storage.js @@ -0,0 +1,123 @@ +// 文件/目录型存储对象的版本生命周期:命名、归档、恢复复制、删除和历史裁剪。 + +const fs = require('fs'); +const path = require('path'); +const { dbGet, dbAll, dbRun } = require('./db'); +const { now } = require('./util'); +const { UPLOAD_DIR } = require('./paths'); +const { addUserStorage } = require('./usage'); +const logger = require('../logger'); + +const MAX_FILE_VERSIONS = parseInt(process.env.MAX_FILE_VERSIONS, 10) || 20; + +function generateStoredName(ext = '') { + return Date.now() + '-' + Math.round(Math.random() * 1e9) + ext; +} + +async function removeStoredObject(storedName, isBundle = false) { + if (!storedName) return; + const target = path.join(UPLOAD_DIR, storedName); + await fs.promises.rm(target, { recursive: !!isBundle, force: true }); +} + +async function copyStoredObject(sourceStoredName, targetStoredName, isBundle = false) { + const source = path.join(UPLOAD_DIR, sourceStoredName); + const target = path.join(UPLOAD_DIR, targetStoredName); + if (isBundle) { + await fs.promises.cp(source, target, { recursive: true, errorOnExist: true }); + } else { + await fs.promises.copyFile(source, target, fs.constants.COPYFILE_EXCL); + } +} + +// 归档当前 files 行并切换到 next。DB 两步写入处于同一事务,避免只写入历史或只更新主记录。 +async function backupAndApplyVersion(file, next, recordedBy, source = 'web', performedBy) { + const performedById = performedBy !== undefined ? performedBy : recordedBy; + const nextFileType = next.fileType !== undefined ? next.fileType : file.file_type; + const nextIsBundle = next.isBundle !== undefined ? (next.isBundle ? 1 : 0) : (file.is_bundle ? 1 : 0); + const nextEntryPath = next.entryPath !== undefined ? next.entryPath : file.entry_path; + let nextVer; + + await dbRun('BEGIN IMMEDIATE'); + try { + const verRow = await dbGet( + 'SELECT COALESCE(MAX(version), 0) + 1 AS nextVer FROM file_versions WHERE file_id = ?', + [file.id] + ); + nextVer = verRow.nextVer; + await dbRun( + `INSERT INTO file_versions + (file_id, version, stored_name, size, uploaded_by, upload_source, performed_by, + is_bundle, entry_path, file_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + file.id, + nextVer, + file.stored_name, + file.size, + recordedBy, + source, + performedById, + file.is_bundle ? 1 : 0, + file.entry_path || null, + file.file_type, + ] + ); + await dbRun( + `UPDATE files + SET stored_name = ?, size = ?, file_type = ?, is_bundle = ?, entry_path = ?, + upload_source = ?, updated_at = ? + WHERE id = ?`, + [ + next.storedName, + next.size, + nextFileType, + nextIsBundle, + nextEntryPath || null, + source, + now(), + file.id, + ] + ); + await dbRun('COMMIT'); + } catch (e) { + await dbRun('ROLLBACK').catch(() => {}); + throw e; + } + + // 旧当前版本仍作为历史保留,因此新增占用等于新当前版本本身的大小。 + await addUserStorage(file.uploaded_by, next.size); + + try { + await pruneOldVersions(file.id, MAX_FILE_VERSIONS); + } catch (e) { + // 主版本切换已经提交;裁剪失败只能留下可清理的旧版本,不能让调用方误删当前存储。 + logger.error({ type: 'app', action: 'file.version.prune', fileId: file.id, error: e.message }); + } + return { version: nextVer + 1 }; +} + +async function pruneOldVersions(fileId, keep) { + if (!Number.isFinite(keep) || keep <= 0) return; + const all = await dbAll( + `SELECT id, stored_name, is_bundle, size, uploaded_by + FROM file_versions + WHERE file_id = ? + ORDER BY version DESC`, + [fileId] + ); + for (const version of all.slice(keep)) { + await removeStoredObject(version.stored_name, version.is_bundle); + await dbRun('DELETE FROM file_versions WHERE id = ?', [version.id]); + await addUserStorage(version.uploaded_by, -version.size); + } +} + +module.exports = { + MAX_FILE_VERSIONS, + generateStoredName, + removeStoredObject, + copyStoredObject, + backupAndApplyVersion, + pruneOldVersions, +}; diff --git a/lib/render.js b/lib/render.js index f3e3ddc..a12dc3d 100644 --- a/lib/render.js +++ b/lib/render.js @@ -97,7 +97,8 @@ async function renderFile(res, file) { // entry 在子目录时(如 deck/index.html)base 含 deck/,使 HTML 内相对资源路径正确解析。 const entryDir = (file.entry_path || 'index.html').split('/').slice(0, -1).join('/'); const entryDirPart = entryDir ? entryDir + '/' : ''; - const baseTag = ''; + const assetBasePath = file.asset_base_path || ('/api/files/' + file.id + '/asset/'); + const baseTag = ''; if (entryExt === '.md' || entryExt === '.markdown') { // Markdown 入口:marked 渲染 + 模板(命中缓存则跳过昂贵的渲染) @@ -122,6 +123,8 @@ async function renderFile(res, file) { content = content.replace(//i, '\n' + baseTag); } else if (/]*>/i, '$&\n' + baseTag + ''); + } else { + content = '' + baseTag + '' + content + ''; } return sendHtmlDoc(res, ensureCharset(content)); } catch (e) { diff --git a/lib/zip.js b/lib/zip.js index 7b5f76e..b6c8641 100644 --- a/lib/zip.js +++ b/lib/zip.js @@ -8,14 +8,35 @@ const JSZip = require('jszip'); const logger = require('../logger'); const { dbRun, dbGet } = require('./db'); const { UPLOAD_DIR } = require('./paths'); -const { now, generateShareKey, currentUserId, clientIp, unlinkQuiet, resolveUploadSource } = require('./util'); +const { now, generateShareKey, currentUserId, clientIp, resolveUploadSource } = require('./util'); const { isFtsIndexable, indexFileContent } = require('./fts'); +const { + generateStoredName, + removeStoredObject, + backupAndApplyVersion, +} = require('./file-storage'); +const { checkFileOwnership } = require('./middleware/files'); const { addUserStorage } = require('./usage'); // --- ZIP 安全常量 --- const ZIP_MAX_FILE_COUNT = 1000; const ZIP_MAX_EXTRACTED_SIZE = 200 * 1024 * 1024; const ZIP_MAX_SINGLE_FILE_SIZE = 50 * 1024 * 1024; +const zipIdentityLocks = new Map(); + +async function withZipIdentityLock(key, action) { + const previous = zipIdentityLocks.get(key) || Promise.resolve(); + let release; + const current = new Promise(resolve => { release = resolve; }); + zipIdentityLocks.set(key, current); + await previous; + try { + return await action(); + } finally { + release(); + if (zipIdentityLocks.get(key) === current) zipIdentityLocks.delete(key); + } +} // --- 错误分类工具 --- // 用户输入类问题(路径穿越、超限、类型不匹配、损坏 ZIP)标记为 400, @@ -112,7 +133,7 @@ function classifyZip(entries) { return { type: 'batch', files: [...htmlFiles, ...mdFiles] }; } -async function handleZipUpload(req, res, zipBuffer) { +async function handleZipUpload(req, res, zipBuffer, { targetFileId } = {}) { try { const zip = await JSZip.loadAsync(zipBuffer); const entries = await validateZipEntries(zip); @@ -127,41 +148,132 @@ async function handleZipUpload(req, res, zipBuffer) { const source = resolveUploadSource(req); if (classification.type === 'bundle') { - const dirName = Date.now() + '-' + Math.round(Math.random() * 1e9); + const dirName = generateStoredName(); const bundleDir = path.join(UPLOAD_DIR, dirName); - await extractEntries(zip, entries, bundleDir); - - const totalSize = await fs.promises.readdir(bundleDir).then(files => - Promise.all(files.map(f => fs.promises.stat(path.join(bundleDir, f)).then(s => s.size))) - ).then(sizes => sizes.reduce((a, b) => a + b, 0)); + let extraction; + try { + extraction = await extractEntries(zip, entries, bundleDir); + } catch (e) { + await removeStoredObject(dirName, true).catch(() => {}); + throw e; + } + const totalSize = extraction.totalSize; const originalName = req.file ? req.file.originalname : (req.body && req.body.name) || 'upload.zip'; const entryExt = path.extname(classification.entryFile).toLowerCase(); const fileType = (entryExt === '.md' || entryExt === '.markdown') ? 'markdown' : 'html'; + const lockKey = targetFileId + ? `target:${targetFileId}` + : `name:${userId}:${originalName}`; + + return withZipIdentityLock(lockKey, async () => { + let existing; + if (targetFileId) { + existing = await dbGet('SELECT * FROM files WHERE id = ?', [targetFileId]); + if (!existing) { + await removeStoredObject(dirName, true); + return res.status(404).json({ error: '文件不存在' }); + } + if (!checkFileOwnership(req, existing)) { + await removeStoredObject(dirName, true); + return res.status(403).json({ error: '无权操作此文件' }); + } + } else { + existing = await dbGet( + `SELECT * FROM files + WHERE original_name = ? AND uploaded_by = ? + ORDER BY id ASC + LIMIT 1`, + [originalName, userId] + ); + } + + if (existing && !existing.is_bundle) { + await removeStoredObject(dirName, true); + return res.status(400).json({ error: '文件类型不匹配:ZIP 网站包只能覆盖网站包' }); + } + + if (existing) { + try { + const { version } = await backupAndApplyVersion( + existing, + { + storedName: dirName, + size: totalSize, + fileType, + isBundle: true, + entryPath: classification.entryFile, + }, + existing.uploaded_by, + source, + userId + ); + logger.audit('file.overwrite', { + fileId: existing.id, + fileName: existing.original_name, + version, + fileType: 'bundle', + size: totalSize, + userId, + ip: clientIp(req), + }); + return res.json({ + id: existing.id, + overwritten: true, + version, + original_name: existing.original_name, + file_type: fileType, + size: totalSize, + is_public: existing.is_public, + is_bundle: 1, + entry_path: classification.entryFile, + share_key: existing.share_key, + }); + } catch (e) { + await removeStoredObject(dirName, true).catch(() => {}); + throw e; + } + } - const result = await dbRun( - 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, upload_source, updated_at, is_bundle, entry_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', - [originalName, dirName, fileType, totalSize, isPublic ? 1 : 0, userId, generateShareKey(), source, now(), classification.entryFile] - ); - await addUserStorage(userId, totalSize); - - const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [result.lastID]).then(r => r?.share_key); - logger.audit('file.upload', { fileId: result.lastID, fileName: originalName, fileType: 'bundle', size: totalSize, ip: clientIp(req) }); - return res.json({ - id: result.lastID, - original_name: originalName, - file_type: fileType, - size: totalSize, - is_public: isPublic ? 1 : 0, - is_bundle: 1, - entry_path: classification.entryFile, - share_key: shareKey + try { + const result = await dbRun( + 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, upload_source, updated_at, is_bundle, entry_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + [originalName, dirName, fileType, totalSize, isPublic ? 1 : 0, userId, generateShareKey(), source, now(), classification.entryFile] + ); + await addUserStorage(userId, totalSize); + const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [result.lastID]).then(r => r?.share_key); + logger.audit('file.upload', { fileId: result.lastID, fileName: originalName, fileType: 'bundle', size: totalSize, ip: clientIp(req) }); + return res.json({ + id: result.lastID, + original_name: originalName, + file_type: fileType, + size: totalSize, + is_public: isPublic ? 1 : 0, + is_bundle: 1, + entry_path: classification.entryFile, + share_key: shareKey + }); + } catch (e) { + await removeStoredObject(dirName, true).catch(() => {}); + throw e; + } }); } + if (targetFileId) { + return res.status(400).json({ error: '批量 ZIP 不能覆盖单个文件,请上传网站包 ZIP' }); + } + + const baseNames = classification.files.map(entry => path.basename(entry.name)); + const duplicateBaseNames = [...new Set(baseNames.filter((name, index) => baseNames.indexOf(name) !== index))]; + if (duplicateBaseNames.length > 0) { + return res.status(400).json({ error: 'ZIP 内存在同名文件: ' + duplicateBaseNames.join(', ') }); + } + // batch 模式:逐文件独立处理,单个失败不影响其余文件 const results = []; const failed = []; + let totalSize = 0; for (const entry of classification.files) { let storedName; try { @@ -171,26 +283,93 @@ async function handleZipUpload(req, res, zipBuffer) { continue; } const buf = await zipFile.async('nodebuffer'); + if (buf.length > ZIP_MAX_SINGLE_FILE_SIZE) throw userError('文件 ' + entry.name + ' 解压后超过单文件限制'); + totalSize += buf.length; + if (totalSize > ZIP_MAX_EXTRACTED_SIZE) throw userError('解压总大小超过 ' + Math.round(ZIP_MAX_EXTRACTED_SIZE / 1024 / 1024) + 'MB 限制'); const ext = path.extname(entry.name).toLowerCase(); const fileType = (ext === '.md' || ext === '.markdown') ? 'markdown' : 'html'; - const unique = Date.now() + '-' + Math.round(Math.random() * 1e9); - storedName = unique + ext; + storedName = generateStoredName(ext); const filePath = path.join(UPLOAD_DIR, storedName); await fs.promises.writeFile(filePath, buf); const baseName = path.basename(entry.name); - const dbResult = await dbRun( - 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, upload_source, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [baseName, storedName, fileType, buf.length, isPublic ? 1 : 0, userId, generateShareKey(), source, now()] - ); - await addUserStorage(userId, buf.length); - if (isFtsIndexable(fileType, storedName)) { - indexFileContent(dbResult.lastID, storedName); - } - results.push({ id: dbResult.lastID, original_name: baseName, file_type: fileType, size: buf.length }); + await withZipIdentityLock(`name:${userId}:${baseName}`, async () => { + const existing = await dbGet( + `SELECT * FROM files + WHERE original_name = ? AND uploaded_by = ? + ORDER BY id ASC + LIMIT 1`, + [baseName, userId] + ); + if (existing && existing.is_bundle) { + throw userError('文件类型不匹配:批量 ZIP 内文件不能覆盖网站包'); + } + + if (existing) { + const { version } = await backupAndApplyVersion( + existing, + { storedName, size: buf.length, fileType, isBundle: false, entryPath: null }, + existing.uploaded_by, + source, + userId + ); + const currentStoredName = storedName; + // 数据库已切换到新文件;后续非关键索引失败时不能把当前文件当作临时文件删除。 + storedName = null; + if (isFtsIndexable(fileType, currentStoredName)) { + try { + indexFileContent(existing.id, currentStoredName); + } catch (e) { + logger.error({ type: 'app', action: 'zip.batch.index', fileId: existing.id, error: e.message }); + } + } + results.push({ + id: existing.id, + overwritten: true, + version, + original_name: baseName, + file_type: fileType, + size: buf.length, + share_key: existing.share_key, + }); + logger.audit('file.overwrite', { + fileId: existing.id, + fileName: baseName, + version, + fileType, + size: buf.length, + userId, + ip: clientIp(req), + }); + return; + } + + const dbResult = await dbRun( + 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, upload_source, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [baseName, storedName, fileType, buf.length, isPublic ? 1 : 0, userId, generateShareKey(), source, now()] + ); + await addUserStorage(userId, buf.length); + const currentStoredName = storedName; + storedName = null; + if (isFtsIndexable(fileType, currentStoredName)) { + try { + indexFileContent(dbResult.lastID, currentStoredName); + } catch (e) { + logger.error({ type: 'app', action: 'zip.batch.index', fileId: dbResult.lastID, error: e.message }); + } + } + const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [dbResult.lastID]).then(row => row?.share_key); + results.push({ + id: dbResult.lastID, + original_name: baseName, + file_type: fileType, + size: buf.length, + share_key: shareKey, + }); + }); } catch (e) { // 写入中途失败:清理已落盘的临时文件,避免孤儿 - if (storedName) { await unlinkQuiet(path.join(UPLOAD_DIR, storedName)); } + if (storedName) await removeStoredObject(storedName).catch(() => {}); logger.error({ type: 'app', action: 'zip.batch.entry', file: entry.name, error: e.message }); failed.push({ name: path.basename(entry.name), error: e.isUserError ? e.message : '处理失败' }); } diff --git a/mcp/tools-files.js b/mcp/tools-files.js index 922c4d9..950f844 100644 --- a/mcp/tools-files.js +++ b/mcp/tools-files.js @@ -66,7 +66,7 @@ function registerFileTools(server, { api, port, mcpIp, protocol }) { .int() .positive() .optional() - .describe('显式指定覆盖目标文件 ID。提供时调用覆盖上传 API(POST /api/files/:id/overwrite-json),不提供时走同名自动覆盖逻辑。'), + .describe('显式指定覆盖目标文件 ID。HTML/Markdown 调用 overwrite-json;ZIP 调用 overwrite-zip-base64。ZIP 目标必须是网站包 bundle。'), tags: z .array(z.string()) .optional() @@ -89,7 +89,10 @@ function registerFileTools(server, { api, port, mcpIp, protocol }) { return textResult(`ZIP 文件过大 (${buf.length} 字节),上限 50MB`, { isError: true }); } try { - const data = await api.post('/api/files/upload-zip-base64', { + const uploadPath = overwriteFileId + ? `/api/files/${overwriteFileId}/overwrite-zip-base64` + : '/api/files/upload-zip-base64'; + const data = await api.post(uploadPath, { name, content, isPublic: isPublic ?? true, diff --git a/migrations/027_zip_bundle_versions.js b/migrations/027_zip_bundle_versions.js new file mode 100644 index 0000000..25f5c3b --- /dev/null +++ b/migrations/027_zip_bundle_versions.js @@ -0,0 +1,56 @@ +// 让 file_versions 能描述目录型 bundle 历史版本。 + +const fs = require('fs'); +const path = require('path'); +const { UPLOAD_DIR } = require('../lib/paths'); + +module.exports = { + name: 'zip_bundle_versions', + + async up(db, { dbRun, dbAll }) { + const columns = await dbAll(db, 'PRAGMA table_info(file_versions)'); + const names = new Set(columns.map(column => column.name)); + + if (!names.has('is_bundle')) { + await dbRun(db, 'ALTER TABLE file_versions ADD COLUMN is_bundle INTEGER NOT NULL DEFAULT 0'); + } + if (!names.has('entry_path')) { + await dbRun(db, 'ALTER TABLE file_versions ADD COLUMN entry_path TEXT'); + } + if (!names.has('file_type')) { + await dbRun(db, 'ALTER TABLE file_versions ADD COLUMN file_type TEXT'); + } + + await dbRun(db, ` + UPDATE file_versions + SET file_type = COALESCE( + file_type, + (SELECT files.file_type FROM files WHERE files.id = file_versions.file_id), + 'html' + ) + `); + + // 旧代码不会正常创建 bundle 历史;若通用 overwrite 曾错误用于 bundle, + // 历史 stored_name 可能已经是目录。按磁盘实态回填,保留可恢复的数据。 + const versions = await dbAll(db, ` + SELECT fv.id, fv.stored_name, f.entry_path + FROM file_versions fv + JOIN files f ON f.id = fv.file_id + WHERE COALESCE(fv.is_bundle, 0) = 0 + `); + for (const version of versions) { + try { + const stat = await fs.promises.stat(path.join(UPLOAD_DIR, version.stored_name)); + if (stat.isDirectory()) { + await dbRun( + db, + 'UPDATE file_versions SET is_bundle = 1, entry_path = ? WHERE id = ?', + [version.entry_path || 'index.html', version.id] + ); + } + } catch (e) { + if (!e || e.code !== 'ENOENT') throw e; + } + } + } +}; diff --git a/public/js/pages/home.js b/public/js/pages/home.js index 0a5c6ee..1a9b54e 100644 --- a/public/js/pages/home.js +++ b/public/js/pages/home.js @@ -20,7 +20,7 @@ let lastCheckedIndex = -1; let skillModalCurrent = null; let searchResults = null; let homeAbortController = null; -let versionUploadFileId = null; +let versionUploadTarget = null; // ---------- 视图模式(列表 / 卡片) ---------- const FILE_VIEW_KEY = 'jpage-file-view'; @@ -531,23 +531,28 @@ function setupVersionUpload(container) { if (!input) return; input.addEventListener('change', () => { - if (!input.files.length || !versionUploadFileId) { + if (!input.files.length || !versionUploadTarget) { input.value = ''; - versionUploadFileId = null; + versionUploadTarget = null; return; } const file = input.files[0]; - const allowed = ['.html', '.htm', '.md', '.markdown']; const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase(); - if (!allowed.includes(ext)) { - toast('仅支持 HTML 和 Markdown 文件', 'error'); + const target = versionUploadTarget; + const typeMatches = target.isBundle + ? ext === '.zip' + : (target.fileType === 'markdown' + ? ['.md', '.markdown'].includes(ext) + : ['.html', '.htm'].includes(ext)); + if (!typeMatches) { + toast(target.isBundle ? '网站包新版本必须上传 ZIP 文件' : '新版本文件类型必须与原文件一致', 'error'); input.value = ''; - versionUploadFileId = null; + versionUploadTarget = null; return; } - const fileId = versionUploadFileId; - versionUploadFileId = null; + const fileId = target.id; + versionUploadTarget = null; const progressEl = container.querySelector('#version-upload-progress'); const progressBar = container.querySelector('#version-upload-progress-bar'); @@ -950,7 +955,9 @@ function renderFileList(container, list, files) { el.querySelector('.btn-upload-version').addEventListener('click', e => { e.stopPropagation(); moreDropdown.classList.remove('open'); - versionUploadFileId = f.id; + versionUploadTarget = { id: f.id, isBundle: !!f.is_bundle, fileType: f.file_type }; + const input = container.querySelector('#version-file-input'); + if (input) input.accept = f.is_bundle ? '.zip' : (f.file_type === 'markdown' ? '.md,.markdown' : '.html,.htm'); container.querySelector('#version-file-input')?.click(); }); list.appendChild(el); @@ -1149,7 +1156,9 @@ function renderCardList(container, list, files) { el.querySelector('.btn-upload-version').addEventListener('click', e => { e.stopPropagation(); moreDropdown.classList.remove('open'); - versionUploadFileId = f.id; + versionUploadTarget = { id: f.id, isBundle: !!f.is_bundle, fileType: f.file_type }; + const input = container.querySelector('#version-file-input'); + if (input) input.accept = f.is_bundle ? '.zip' : (f.file_type === 'markdown' ? '.md,.markdown' : '.html,.htm'); container.querySelector('#version-file-input')?.click(); }); el.querySelectorAll('.file-badge-tag').forEach(badge => { @@ -1369,6 +1378,9 @@ async function openFileStatsDialog(fileId) { function buildVersionHistoryHtml(data) { const versions = data.versions || []; + const currentVersion = versions.length + ? Math.max(...versions.map(version => version.version)) + 1 + : 1; const currentSize = data.current ? formatSize(data.current.size) : ''; const currentTime = data.current ? relativeTime(data.current.updated_at) : ''; @@ -1377,7 +1389,7 @@ function buildVersionHistoryHtml(data) {
- 当前 (v${versions.length + 1}) + 当前 (v${currentVersion})
${currentSize} · ${currentTime}
diff --git a/public/js/pages/preview.js b/public/js/pages/preview.js index c46754b..25b2485 100644 --- a/public/js/pages/preview.js +++ b/public/js/pages/preview.js @@ -37,9 +37,12 @@ function loadVersions(container, fileId) { body.innerHTML = '
加载中…
'; api(`/api/files/${fileId}/versions`).then(data => { + const versions = data.versions || []; _versionPanelState.fileId = fileId; - _versionPanelState.versions = data.versions || []; - _versionPanelState.currentVer = (data.versions ? data.versions.length : 0) + 1; + _versionPanelState.versions = versions; + _versionPanelState.currentVer = versions.length + ? Math.max(...versions.map(version => version.version)) + 1 + : 1; renderVersionList(container, data); const menu = container.querySelector('#menu-version-history'); @@ -75,7 +78,7 @@ function renderVersionList(container, data) {
- 当前 (v${versions.length + 1}) + 当前 (v${_versionPanelState.currentVer})
${currentSize} · ${currentTime}
@@ -186,10 +189,14 @@ function setupVersionUpload(container, fileId) { input.addEventListener('change', () => { if (!input.files.length) return; const file = input.files[0]; - const allowed = ['.html', '.htm', '.md', '.markdown']; + const isBundle = input.dataset.isBundle === 'true'; + const fileType = input.dataset.fileType; + const allowed = isBundle + ? ['.zip'] + : (fileType === 'markdown' ? ['.md', '.markdown'] : ['.html', '.htm']); const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase(); if (!allowed.includes(ext)) { - toast('仅支持 HTML 和 Markdown 文件', 'error'); + toast(isBundle ? '网站包新版本必须上传 ZIP 文件' : '新版本文件类型必须与原文件一致', 'error'); input.value = ''; return; } @@ -711,6 +718,12 @@ function renderPreview(container, hash) { if (data.is_bundle && Array.isArray(data.entries) && data.entries.length > 0) { setupBundleTree(data.entries, data.entry_path, data.entries_truncated); } + const versionInput = container.querySelector('#version-file-input'); + if (versionInput) { + versionInput.dataset.isBundle = data.is_bundle ? 'true' : 'false'; + versionInput.dataset.fileType = data.file_type; + versionInput.accept = data.is_bundle ? '.zip' : (data.file_type === 'markdown' ? '.md,.markdown' : '.html,.htm'); + } if (spinner) spinner.style.display = 'flex'; iframe.src = API_BASE + `/api/files/${id}/render`; // 模板菜单项:仅 Markdown 文件且为所有者/admin 可见 diff --git a/routes/files/_shared.js b/routes/files/_shared.js index 91ba8dc..265aa2a 100644 --- a/routes/files/_shared.js +++ b/routes/files/_shared.js @@ -8,19 +8,21 @@ const express = require('express'); const multer = require('multer'); const rateLimit = require('express-rate-limit'); const path = require('path'); -const { dbGet, dbAll, dbRun } = require('../../lib/db'); -const { now, unlinkQuiet, decodeFilename } = require('../../lib/util'); +const { decodeFilename } = require('../../lib/util'); const { UPLOAD_DIR } = require('../../lib/paths'); -const { addUserStorage } = require('../../lib/usage'); +const { + MAX_FILE_VERSIONS, + generateStoredName, + removeStoredObject, + copyStoredObject, + backupAndApplyVersion, + pruneOldVersions, +} = require('../../lib/file-storage'); // --- 常量 --- const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB const ALLOWED_UPLOAD_EXTS = ['.html', '.htm', '.md', '.markdown', '.zip']; // 上传(含 ZIP) const ALLOWED_TEXT_EXTS = ['.html', '.htm', '.md', '.markdown']; // JSON 上传(无 ZIP) -// 单个文件保留的历史版本数(file_versions 表),超过则自动删除最旧的。 -// 当前版本存在 files 主记录,不计入此上限。env 可配,默认 20。 -// 注:推翻设计文档 013 原定的「version 不设上限」。 -const MAX_FILE_VERSIONS = parseInt(process.env.MAX_FILE_VERSIONS, 10) || 20; // --- 上传限流 --- const uploadLimiter = rateLimit({ @@ -55,75 +57,6 @@ const upload = multer({ // JSON body 解析器:上传类端点需要放宽到 50MB(全局默认 1MB) const largeJson = express.json({ limit: '50mb' }); -// --- 文件名生成:替换原先散落在 5 处的 Date.now()+'-'+Math.round(...)+ext --- -function generateStoredName(ext) { - return Date.now() + '-' + Math.round(Math.random() * 1e9) + ext; -} - -// --- 版本备份 + 主记录更新序列 --- -// 原先在 upload/upload-json/overwrite/overwrite-json/restore 共 5 处重复。 -// 行为零差异:读 nextVer → INSERT 旧版本到 file_versions → UPDATE files 主记录。 -// -// @param {object} file - 旧 files 行(含 stored_name/size/id) -// @param {object} next - 新版本数据 { storedName, size } -// @param {number} recordedBy - 写入 file_versions.uploaded_by 的用户 id。 -// 语义为「被归档那一版内容的原始上传者」,由各处传 -// file.uploaded_by(注意:这不是本次操作者)。 -// @param {string} [source] - 本次上传来源 'web'|'cli'|'mcp',缺省 'web'。 -// 写入 file_versions.upload_source(该版本被写入时的来源), -// 并刷新 files.upload_source(最近一次来源)。 -// @param {number} [performedBy] - 写入 file_versions.performed_by 的用户 id。 -// 语义为「触发本次版本创建的操作者」(覆盖/恢复动作的执行者), -// 与 recordedBy 区分。缺省时回退到 recordedBy,保持向后兼容。 -// @returns {Promise<{ version: number }>} 返回新版本号(nextVer + 1,对齐审计日志语义) -async function backupAndApplyVersion(file, next, recordedBy, source = 'web', performedBy) { - const verRow = await dbGet( - 'SELECT COALESCE(MAX(version), 0) + 1 AS nextVer FROM file_versions WHERE file_id = ?', - [file.id] - ); - const nextVer = verRow.nextVer; - // performed_by 缺省回退到 recordedBy(原始上传者):旧调用方未传时仍可审计。 - const performedById = performedBy !== undefined ? performedBy : recordedBy; - await dbRun( - 'INSERT INTO file_versions (file_id, version, stored_name, size, uploaded_by, upload_source, performed_by) VALUES (?, ?, ?, ?, ?, ?, ?)', - [file.id, nextVer, file.stored_name, file.size, recordedBy, source, performedById] - ); - await dbRun( - 'UPDATE files SET stored_name = ?, size = ?, upload_source = ?, updated_at = ? WHERE id = ?', - [next.storedName, next.size, source, now(), file.id] - ); - - // 维护用户存储量:当前文件大小变化量 = 新版本 - 旧版本 - await addUserStorage(file.uploaded_by, next.size - file.size); - - // 超过上限时删除最旧的历史版本(含磁盘文件),避免长期占盘。 - await pruneOldVersions(file.id, MAX_FILE_VERSIONS, file.uploaded_by); - return { version: nextVer + 1 }; -} - -// --- 历史版本裁剪 --- -// 保留每个文件最近 keep 个历史版本(version 最大的 keep 个),删掉更老的, -// 同步清理磁盘文件。当前版本在 files 主记录,不参与计数。 -async function pruneOldVersions(fileId, keep, ownerId) { - if (!Number.isFinite(keep) || keep <= 0) return; - // version 倒序:前 keep 条保留,其余删除 - const all = await dbAll( - 'SELECT id, stored_name, size FROM file_versions WHERE file_id = ? ORDER BY version DESC', - [fileId] - ); - const toRemove = all.slice(keep); - let removedBytes = 0; - for (const v of toRemove) { - if (v.stored_name) await unlinkQuiet(path.join(UPLOAD_DIR, v.stored_name)); - await dbRun('DELETE FROM file_versions WHERE id = ?', [v.id]); - if (v.size) removedBytes += v.size; - } - // 扣减被删版本占用的存储量 - if (removedBytes > 0 && ownerId) { - await addUserStorage(ownerId, -removedBytes); - } -} - // --- 下载 Content-Disposition 头(UTF-8 文件名) --- // 替换 download 路由两处重复的 encoded + filename*=UTF-8'' 拼接。 function setDownloadHeaders(res, name) { @@ -149,6 +82,8 @@ module.exports = { upload, largeJson, generateStoredName, + removeStoredObject, + copyStoredObject, backupAndApplyVersion, pruneOldVersions, setDownloadHeaders, diff --git a/routes/files/crud.js b/routes/files/crud.js index 2a17001..8fbc0b9 100644 --- a/routes/files/crud.js +++ b/routes/files/crud.js @@ -1,16 +1,14 @@ // 更新 / 删除 / 批量操作路由。 // 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。 -const fs = require('fs'); -const path = require('path'); const { dbGet, dbRun, dbAll } = require('../../lib/db'); const { requireAuth } = require('../../lib/middleware/auth'); -const { unlinkQuiet, clientIp } = require('../../lib/util'); -const { UPLOAD_DIR } = require('../../lib/paths'); +const { clientIp } = require('../../lib/util'); const { deleteFileIndex } = require('../../lib/fts'); const { invalidateRenderCache } = require('../../lib/render-cache'); const { checkFileOwnership } = require('../../lib/middleware/files'); const { subtractFileStorage } = require('../../lib/usage'); +const { removeStoredObject } = require('./_shared'); const logger = require('../../logger'); function registerCrud(router) { @@ -70,16 +68,14 @@ function registerCrud(router) { await subtractFileStorage(file); // 清理版本记录及对应磁盘文件 - const versions = await dbAll('SELECT stored_name FROM file_versions WHERE file_id = ?', [req.params.id]); + const versions = await dbAll('SELECT stored_name, is_bundle FROM file_versions WHERE file_id = ?', [req.params.id]); for (const v of versions) { - const p = path.join(UPLOAD_DIR, v.stored_name); - if (fs.existsSync(p)) await unlinkQuiet(p); + await removeStoredObject(v.stored_name, v.is_bundle); } await dbRun('DELETE FROM file_versions WHERE file_id = ?', [req.params.id]); // 删除主文件 - const filePath = path.join(UPLOAD_DIR, file.stored_name); - if (fs.existsSync(filePath)) await unlinkQuiet(filePath); + await removeStoredObject(file.stored_name, file.is_bundle); await dbRun('DELETE FROM files WHERE id = ?', [req.params.id]); invalidateRenderCache(req.params.id); logger.audit('file.delete', { fileId: req.params.id, fileName: file.original_name, ip: clientIp(req) }); @@ -120,13 +116,13 @@ function registerCrud(router) { try { await dbRun(`DELETE FROM file_tags WHERE file_id IN (${idPlaceholders})`, fileIds); await dbRun(`DELETE FROM starred_files WHERE file_id IN (${idPlaceholders})`, fileIds); - const versions = await dbAll(`SELECT stored_name FROM file_versions WHERE file_id IN (${idPlaceholders})`, fileIds); + const versions = await dbAll(`SELECT stored_name, is_bundle FROM file_versions WHERE file_id IN (${idPlaceholders})`, fileIds); for (const v of versions) { - await unlinkQuiet(path.join(UPLOAD_DIR, v.stored_name)); + await removeStoredObject(v.stored_name, v.is_bundle); } await dbRun(`DELETE FROM file_versions WHERE file_id IN (${idPlaceholders})`, fileIds); for (const f of files) { - await unlinkQuiet(path.join(UPLOAD_DIR, f.stored_name)); + await removeStoredObject(f.stored_name, f.is_bundle); } await dbRun(`DELETE FROM files WHERE id IN (${idPlaceholders})`, fileIds); await dbRun('COMMIT'); diff --git a/routes/files/overwrite.js b/routes/files/overwrite.js index e107880..09d9b19 100644 --- a/routes/files/overwrite.js +++ b/routes/files/overwrite.js @@ -11,6 +11,7 @@ const { UPLOAD_DIR } = require('../../lib/paths'); const { isFtsIndexable, indexFileContent } = require('../../lib/fts'); const { uploadLimiter, upload, largeJson, MAX_FILE_SIZE, backupAndApplyVersion } = require('./_shared'); const { checkFileOwnership } = require('../../lib/middleware/files'); +const { handleZipUpload, translateZipError } = require('../../lib/zip'); const logger = require('../../logger'); function registerOverwrite(router) { @@ -19,6 +20,20 @@ function registerOverwrite(router) { if (!req.file) return res.status(400).json({ error: '未上传文件' }); req.file.originalname = decodeFilename(req.file.originalname); const ext = path.extname(req.file.originalname).toLowerCase(); + if (ext === '.zip') { + try { + const zipBuffer = await fs.promises.readFile(req.file.path); + await unlinkQuiet(req.file.path); + return handleZipUpload(req, res, zipBuffer, { targetFileId: req.params.id }); + } catch (e) { + await unlinkQuiet(req.file.path); + if (res.headersSent) return; + logger.error({ type: 'app', action: 'zip.overwrite', error: e.message }); + return res.status(e.statusCode || 500).json({ + error: e.isUserError ? e.message : translateZipError(e) + }); + } + } let fileType = 'html'; if (ext === '.md' || ext === '.markdown') fileType = 'markdown'; const source = resolveUploadSource(req); @@ -33,6 +48,10 @@ function registerOverwrite(router) { await unlinkQuiet(path.join(UPLOAD_DIR, req.file.filename)); return res.status(403).json({ error: '无权操作此文件' }); } + if (file.is_bundle) { + await unlinkQuiet(path.join(UPLOAD_DIR, req.file.filename)); + return res.status(400).json({ error: 'ZIP 网站包只能使用 ZIP 文件覆盖' }); + } // 校验文件类型 if (file.file_type !== fileType) { @@ -69,6 +88,27 @@ function registerOverwrite(router) { } }); + // --- ZIP(base64) 显式覆盖 --- + router.post('/:id/overwrite-zip-base64', requireAuth, uploadLimiter, largeJson, async (req, res) => { + const { name, content } = req.body || {}; + if (typeof name !== 'string' || path.extname(name).toLowerCase() !== '.zip') { + return res.status(400).json({ error: '仅支持 ZIP 文件' }); + } + if (typeof content !== 'string') return res.status(400).json({ error: 'content 必须是字符串' }); + try { + const zipBuffer = Buffer.from(content, 'base64'); + if (zipBuffer.length > MAX_FILE_SIZE) return res.status(400).json({ error: 'ZIP 文件超过50MB限制' }); + req.file = { originalname: decodeFilename(name) }; + return handleZipUpload(req, res, zipBuffer, { targetFileId: req.params.id }); + } catch (e) { + if (res.headersSent) return; + logger.error({ type: 'app', action: 'zip.overwrite.base64', error: e.message }); + return res.status(e.statusCode || 500).json({ + error: e.isUserError ? e.message : translateZipError(e) + }); + } + }); + // --- JSON 覆盖 --- router.post('/:id/overwrite-json', requireAuth, uploadLimiter, largeJson, async (req, res) => { const { content } = req.body || {}; @@ -79,6 +119,7 @@ function registerOverwrite(router) { const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]); if (!file) return res.status(404).json({ error: '文件不存在' }); if (!checkFileOwnership(req, file)) return res.status(403).json({ error: '无权操作此文件' }); + if (file.is_bundle) return res.status(400).json({ error: 'ZIP 网站包只能使用 ZIP 文件覆盖' }); const size = Buffer.byteLength(content, 'utf-8'); if (size > MAX_FILE_SIZE) return res.status(400).json({ error: '文件大小超过50MB限制' }); diff --git a/routes/files/upload.js b/routes/files/upload.js index ad6c0bc..18099ba 100644 --- a/routes/files/upload.js +++ b/routes/files/upload.js @@ -22,7 +22,15 @@ function registerUpload(router) { const ext = path.extname(req.file.originalname).toLowerCase(); // ZIP 处理 if (ext === '.zip') { - return handleZipUpload(req, res, await fs.promises.readFile(req.file.path)); + try { + const zipBuffer = await fs.promises.readFile(req.file.path); + await unlinkQuiet(req.file.path); + return handleZipUpload(req, res, zipBuffer); + } catch (e) { + await unlinkQuiet(req.file.path); + logger.error({ type: 'app', action: 'zip.multipart', error: e.message }); + return res.status(500).json({ error: '读取 ZIP 文件失败' }); + } } let fileType = 'html'; if (ext === '.md' || ext === '.markdown') fileType = 'markdown'; @@ -32,11 +40,15 @@ function registerUpload(router) { // 检查同名文件(按用户隔离:同名只在当前用户命名空间内匹配, // 不会命中其他用户的同名记录,避免跨用户覆盖) const existing = await dbGet( - 'SELECT id, stored_name, size, uploaded_by, file_type, is_public FROM files WHERE original_name = ? AND uploaded_by = ?', + 'SELECT id, stored_name, size, uploaded_by, file_type, is_public, is_bundle, entry_path FROM files WHERE original_name = ? AND uploaded_by = ?', [req.file.originalname, currentUserId(req)] ); if (existing) { + if (existing.is_bundle) { + await unlinkQuiet(path.join(UPLOAD_DIR, req.file.filename)); + return res.status(400).json({ error: '网站包只能使用 ZIP 文件覆盖' }); + } // 同名文件:校验文件类型 if (existing.file_type !== fileType) { // 类型不匹配,清理已上传的文件,拒绝覆盖 @@ -123,11 +135,15 @@ function registerUpload(router) { // 检查同名文件(按用户隔离,理由同 multipart /upload) const existing = await dbGet( - 'SELECT id, stored_name, size, uploaded_by, file_type, is_public, share_key FROM files WHERE original_name = ? AND uploaded_by = ?', + 'SELECT id, stored_name, size, uploaded_by, file_type, is_public, share_key, is_bundle, entry_path FROM files WHERE original_name = ? AND uploaded_by = ?', [decoded, currentUserId(req)] ).catch(() => null); if (existing) { + if (existing.is_bundle) { + await unlinkQuiet(filePath); + return res.status(400).json({ error: '网站包只能使用 ZIP 文件覆盖' }); + } // 同名文件:校验文件类型 if (existing.file_type !== fileType) { await unlinkQuiet(filePath); diff --git a/routes/files/versions.js b/routes/files/versions.js index 88a9771..40b2ed5 100644 --- a/routes/files/versions.js +++ b/routes/files/versions.js @@ -3,12 +3,22 @@ const fs = require('fs'); const path = require('path'); +const archiver = require('archiver'); const { dbGet, dbAll, dbRun } = require('../../lib/db'); const { requireAuth } = require('../../lib/middleware/auth'); -const { unlinkQuiet, currentUserId, clientIp, resolveUploadSource } = require('../../lib/util'); +const { currentUserId, clientIp, resolveUploadSource } = require('../../lib/util'); const { UPLOAD_DIR } = require('../../lib/paths'); -const { renderFile } = require('../../lib/render'); -const { generateStoredName, backupAndApplyVersion } = require('./_shared'); +const { renderFile, listBundleEntries } = require('../../lib/render'); +const { + generateStoredName, + removeStoredObject, + copyStoredObject, + backupAndApplyVersion, + setDownloadHeaders, + isWithinBundle, +} = require('./_shared'); +const { isFtsIndexable, indexFileContent } = require('../../lib/fts'); +const { checkFileOwnership } = require('../../lib/middleware/files'); const { addUserStorage } = require('../../lib/usage'); const logger = require('../../logger'); @@ -21,6 +31,7 @@ function registerVersions(router) { const versions = await dbAll( `SELECT fv.id, fv.version, fv.size, fv.created_at, fv.performed_by, + fv.is_bundle, fv.entry_path, fv.file_type, pu.username AS performed_by_name FROM file_versions fv LEFT JOIN users pu ON fv.performed_by = pu.id @@ -53,6 +64,35 @@ function registerVersions(router) { if (req.userRole !== 'admin' && file?.uploaded_by !== req.userId) { return res.status(403).json({ error: '无权读取此文件原文' }); } + if (!file) return res.status(404).json({ error: '文件不存在' }); + + if (ver.is_bundle) { + const bundleDir = path.join(UPLOAD_DIR, ver.stored_name); + const entryPath = path.join(bundleDir, ver.entry_path || 'index.html'); + if (!isWithinBundle(entryPath, bundleDir)) { + return res.status(403).json({ error: '非法路径' }); + } + let content; + try { + content = await fs.promises.readFile(entryPath, 'utf-8'); + } catch (e) { + if (e && e.code === 'ENOENT') return res.status(404).json({ error: '版本文件已丢失' }); + throw e; + } + const { entries, truncated } = await listBundleEntries(bundleDir); + return res.json({ + id: parseInt(req.params.id), + version: ver.version, + original_name: file.original_name, + file_type: ver.file_type || file.file_type, + is_bundle: 1, + entry_path: ver.entry_path, + content, + entries, + entries_truncated: truncated, + }); + } + let content; try { content = await fs.promises.readFile(filePath, 'utf-8'); @@ -84,20 +124,95 @@ function registerVersions(router) { const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]); if (!file) return res.status(404).json({ error: '文件不存在' }); - // 构造一个 file-like 对象,使用历史版本的 stored_name - const versionFile = { ...file, stored_name: ver.stored_name }; + // 构造历史版本的完整 file-like 对象,bundle 依赖入口路径与存储形态。 + const versionFile = { + ...file, + stored_name: ver.stored_name, + size: ver.size, + file_type: ver.file_type || file.file_type, + is_bundle: ver.is_bundle ? 1 : 0, + entry_path: ver.entry_path, + updated_at: ver.created_at, + asset_base_path: `/api/files/${file.id}/versions/${ver.version}/asset/`, + }; await renderFile(res, versionFile); } catch (e) { res.status(500).json({ error: '渲染版本失败' }); } }); + // --- bundle 历史版本资源 --- + router.get('/:id/versions/:ver/asset/*', requireAuth, async (req, res) => { + try { + const [file, ver] = await Promise.all([ + dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]), + dbGet('SELECT * FROM file_versions WHERE file_id = ? AND version = ?', [req.params.id, req.params.ver]), + ]); + if (!file) return res.status(404).json({ error: '文件不存在' }); + if (!ver) return res.status(404).json({ error: '版本不存在' }); + if (!ver.is_bundle) return res.status(400).json({ error: '非网站包版本' }); + if (req.userRole !== 'admin' && file.uploaded_by !== req.userId) { + return res.status(403).json({ error: '无权读取此文件版本' }); + } + + const bundleDir = path.join(UPLOAD_DIR, ver.stored_name); + const assetPath = path.join(bundleDir, req.params[0]); + if (!isWithinBundle(assetPath, bundleDir)) return res.status(403).json({ error: '非法路径' }); + const stat = await fs.promises.stat(assetPath).catch(() => null); + if (!stat || stat.isDirectory()) return res.status(404).json({ error: '资源不存在' }); + return res.sendFile(path.resolve(assetPath)); + } catch (e) { + if (!res.headersSent) res.status(500).json({ error: '读取版本资源失败' }); + } + }); + + // --- 历史版本下载 --- + router.get('/:id/versions/:ver/download', requireAuth, async (req, res) => { + try { + const [file, ver] = await Promise.all([ + dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]), + dbGet('SELECT * FROM file_versions WHERE file_id = ? AND version = ?', [req.params.id, req.params.ver]), + ]); + if (!file) return res.status(404).json({ error: '文件不存在' }); + if (!ver) return res.status(404).json({ error: '版本不存在' }); + if (req.userRole !== 'admin' && file.uploaded_by !== req.userId) { + return res.status(403).json({ error: '无权下载此文件版本' }); + } + + const storedPath = path.join(UPLOAD_DIR, ver.stored_name); + setDownloadHeaders(res, file.original_name); + if (ver.is_bundle) { + res.setHeader('Content-Type', 'application/zip'); + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.directory(storedPath, false); + archive.on('error', error => { + logger.error({ type: 'app', action: 'file.version.download', error: error.message }); + if (!res.headersSent) res.status(500).json({ error: '版本打包失败' }); + }); + archive.pipe(res); + await archive.finalize(); + return; + } + return res.sendFile(storedPath, error => { + if (error && !res.headersSent) { + if (error.code === 'ENOENT') return res.status(404).json({ error: '版本文件已丢失' }); + return res.status(500).json({ error: '版本下载失败' }); + } + }); + } catch (e) { + if (!res.headersSent) res.status(500).json({ error: '版本下载失败' }); + } + }); + // --- 恢复历史版本 --- router.post('/:id/versions/:ver/restore', requireAuth, async (req, res) => { let newStoredName; + let newIsBundle = false; + let versionApplied = false; try { const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]); if (!file) return res.status(404).json({ error: '文件不存在' }); + if (!checkFileOwnership(req, file)) return res.status(403).json({ error: '无权操作此文件' }); const targetVer = await dbGet( 'SELECT * FROM file_versions WHERE file_id = ? AND version = ?', @@ -105,32 +220,42 @@ function registerVersions(router) { ); if (!targetVer) return res.status(404).json({ error: '版本不存在' }); - // 读取目标版本文件内容 - const targetPath = path.join(UPLOAD_DIR, targetVer.stored_name); - let targetContent; + newIsBundle = !!targetVer.is_bundle; + const targetFileType = targetVer.file_type || file.file_type; + const ext = newIsBundle ? '' : (targetFileType === 'markdown' ? '.md' : '.html'); + newStoredName = generateStoredName(ext); try { - targetContent = await fs.promises.readFile(targetPath, 'utf-8'); + await copyStoredObject(targetVer.stored_name, newStoredName, newIsBundle); } catch (e) { if (e && e.code === 'ENOENT') return res.status(404).json({ error: '版本文件已丢失' }); throw e; } - // 复制到新磁盘文件 - const ext = file.file_type === 'markdown' ? '.md' : '.html'; - newStoredName = generateStoredName(ext); - await fs.promises.writeFile(path.join(UPLOAD_DIR, newStoredName), targetContent, 'utf-8'); - // 当前版本备份并更新主记录。 // 注意:restore 记录的是执行恢复的用户(currentUserId),与 upload/overwrite // 记录 file.uploaded_by(原始上传者)的语义不同,此处刻意保留原行为。 - const newSize = Buffer.byteLength(targetContent, 'utf-8'); + const newSize = targetVer.size; const { version } = await backupAndApplyVersion( file, - { storedName: newStoredName, size: newSize }, + { + storedName: newStoredName, + size: newSize, + fileType: targetFileType, + isBundle: newIsBundle, + entryPath: targetVer.entry_path, + }, currentUserId(req), resolveUploadSource(req), currentUserId(req) ); + versionApplied = true; + if (!newIsBundle && isFtsIndexable(targetFileType, newStoredName)) { + try { + indexFileContent(file.id, newStoredName); + } catch (e) { + logger.error({ type: 'app', action: 'file.restore.index', fileId: file.id, error: e.message }); + } + } logger.audit('file.restore', { fileId: file.id, fileName: file.original_name, restoredVersion: parseInt(req.params.ver), newVersion: version, userId: currentUserId(req), ip: clientIp(req) }); res.json({ @@ -141,7 +266,9 @@ function registerVersions(router) { size: newSize }); } catch (e) { - if (newStoredName) { await unlinkQuiet(path.join(UPLOAD_DIR, newStoredName)); } + if (newStoredName && !versionApplied) { + await removeStoredObject(newStoredName, newIsBundle).catch(() => {}); + } res.status(500).json({ error: '恢复版本失败' }); } }); @@ -154,20 +281,16 @@ function registerVersions(router) { [req.params.id, req.params.ver] ); if (!ver) return res.status(404).json({ error: '版本不存在' }); - - const file = await dbGet('SELECT uploaded_by FROM files WHERE id = ?', [req.params.id]); + const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]); + if (!file) return res.status(404).json({ error: '文件不存在' }); + if (!checkFileOwnership(req, file)) return res.status(403).json({ error: '无权操作此文件' }); // 删除磁盘文件 - const filePath = path.join(UPLOAD_DIR, ver.stored_name); - if (fs.existsSync(filePath)) await unlinkQuiet(filePath); + await removeStoredObject(ver.stored_name, ver.is_bundle); // 删除版本记录 await dbRun('DELETE FROM file_versions WHERE id = ?', [ver.id]); - - // 扣减该版本占用的存储量 - if (ver.size && file) { - await addUserStorage(file.uploaded_by, -ver.size); - } + await addUserStorage(file.uploaded_by, -ver.size); logger.audit('file.version.delete', { fileId: parseInt(req.params.id), version: parseInt(req.params.ver), userId: currentUserId(req), ip: clientIp(req) }); res.json({ success: true }); diff --git a/test/integration/zip-versions.test.js b/test/integration/zip-versions.test.js new file mode 100644 index 0000000..05d2e92 --- /dev/null +++ b/test/integration/zip-versions.test.js @@ -0,0 +1,264 @@ +// ZIP 多版本覆盖集成测试:bundle / batch 同名覆盖、显式覆盖、历史版本生命周期。 + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const JSZip = require('jszip'); +const { createTestEnv } = require('../helpers/setup'); + +process.env.MAX_FILE_VERSIONS = '3'; + +let env; +let agent; + +async function makeZip(files) { + const zip = new JSZip(); + for (const [name, content] of Object.entries(files)) zip.file(name, content); + return zip.generateAsync({ type: 'nodebuffer' }); +} + +async function uploadZip(name, files) { + const buf = await makeZip(files); + return agent.post('/api/files/upload-zip-base64').send({ + name, + content: buf.toString('base64'), + isPublic: false, + }); +} + +async function assertStorageAccounting() { + const { dbGet } = require('../../lib/db'); + const [user, logical] = await Promise.all([ + dbGet('SELECT total_storage_bytes FROM users WHERE id = 1'), + dbGet(` + SELECT COALESCE((SELECT SUM(size) FROM files WHERE uploaded_by = 1), 0) + + COALESCE((SELECT SUM(size) FROM file_versions WHERE uploaded_by = 1), 0) AS total + `), + ]); + assert.strictEqual(user.total_storage_bytes, logical.total); +} + +test.before(async () => { + env = createTestEnv(); + await env.ready(); + agent = request.agent(env.app); + await agent.post('/api/auth/login').send({ + username: 'admin', + password: 'testpassword123', + }); +}); + +test.after(() => env.cleanup()); + +test('同名 bundle ZIP 覆盖保持 file ID/share key,并可渲染历史版本', async () => { + const first = await uploadZip('versioned-site.zip', { + 'index.html': '

bundle-v1

', + 'assets/app.js': 'window.bundleVersion=1', + }); + const second = await uploadZip('versioned-site.zip', { + 'index.html': '

bundle-v2

', + 'assets/app.js': 'window.bundleVersion=2', + }); + + assert.strictEqual(first.status, 200); + assert.strictEqual(second.status, 200); + assert.strictEqual(second.body.id, first.body.id); + assert.strictEqual(second.body.share_key, first.body.share_key); + assert.strictEqual(second.body.overwritten, true); + assert.strictEqual(second.body.version, 2); + + const versions = await agent.get(`/api/files/${first.body.id}/versions`); + assert.strictEqual(versions.status, 200); + assert.strictEqual(versions.body.versions.length, 1); + assert.strictEqual(versions.body.versions[0].is_bundle, 1); + assert.strictEqual(versions.body.versions[0].entry_path, 'index.html'); + + const oldRender = await agent.get(`/api/files/${first.body.id}/versions/1/render`); + const currentRender = await agent.get(`/api/files/${first.body.id}/render`); + assert.strictEqual(oldRender.status, 200); + assert.match(oldRender.text, /bundle-v1/); + assert.match(oldRender.text, new RegExp(`/api/files/${first.body.id}/versions/1/asset/`)); + const oldAsset = await agent.get(`/api/files/${first.body.id}/versions/1/asset/assets/app.js`); + assert.strictEqual(oldAsset.status, 200); + assert.match(oldAsset.text, /bundleVersion=1/); + assert.strictEqual(currentRender.status, 200); + assert.match(currentRender.text, /bundle-v2/); + await assertStorageAccounting(); +}); + +test('bundle 历史版本可恢复、下载和递归删除', async () => { + const first = await uploadZip('restore-site.zip', { + 'index.html': '

restore-v1

', + 'nested/asset.txt': 'asset-v1', + }); + await uploadZip('restore-site.zip', { + 'index.html': '

restore-v2

', + 'nested/asset.txt': 'asset-v2', + }); + + const download = await agent + .get(`/api/files/${first.body.id}/versions/1/download`) + .buffer(true) + .parse((res, cb) => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => cb(null, Buffer.concat(chunks))); + }); + assert.strictEqual(download.status, 200); + const archive = await JSZip.loadAsync(download.body); + assert.strictEqual(await archive.file('nested/asset.txt').async('string'), 'asset-v1'); + + const restore = await agent.post(`/api/files/${first.body.id}/versions/1/restore`); + assert.strictEqual(restore.status, 200); + const restoredRender = await agent.get(`/api/files/${first.body.id}/render`); + assert.match(restoredRender.text, /restore-v1/); + + const { dbGet } = require('../../lib/db'); + const archived = await dbGet( + 'SELECT stored_name, is_bundle FROM file_versions WHERE file_id = ? AND version = 1', + [first.body.id] + ); + const archivedPath = path.join(env.dataDir, 'uploads', archived.stored_name); + assert.strictEqual(archived.is_bundle, 1); + assert.strictEqual(fs.existsSync(archivedPath), true); + + const deleted = await agent.delete(`/api/files/${first.body.id}/versions/1`); + assert.strictEqual(deleted.status, 200); + assert.strictEqual(fs.existsSync(archivedPath), false); + await assertStorageAccounting(); +}); + +test('batch ZIP 对包内同名文件逐项覆盖并生成各自历史版本', async () => { + const first = await uploadZip('batch-versions.zip', { + 'alpha.html': '

alpha-v1

', + 'beta.md': '# beta-v1', + }); + const second = await uploadZip('batch-versions.zip', { + 'alpha.html': '

alpha-v2

', + 'beta.md': '# beta-v2', + }); + + assert.strictEqual(first.status, 200); + assert.strictEqual(second.status, 200); + assert.strictEqual(second.body.type, 'batch'); + assert.deepStrictEqual( + second.body.files.map(file => file.id), + first.body.files.map(file => file.id) + ); + assert.ok(second.body.files.every(file => file.overwritten === true && file.version === 2)); + + for (const file of second.body.files) { + const versions = await agent.get(`/api/files/${file.id}/versions`); + assert.strictEqual(versions.body.versions.length, 1); + const current = await agent.get(`/api/files/${file.id}/content`); + assert.match(current.body.content, /v2/); + } + await assertStorageAccounting(); +}); + +test('显式 bundle ZIP 覆盖支持 multipart/base64,且拒绝存储形态错配', async () => { + const first = await uploadZip('explicit-site.zip', { + 'index.html': '

explicit-v1

', + 'assets/app.js': 'window.v=1', + }); + const multipartZip = await makeZip({ + 'index.html': '

explicit-v2

', + 'assets/app.js': 'window.v=2', + }); + const multipart = await agent + .post(`/api/files/${first.body.id}/overwrite`) + .attach('file', multipartZip, 'explicit-site.zip'); + assert.strictEqual(multipart.status, 200); + assert.strictEqual(multipart.body.id, first.body.id); + assert.strictEqual(multipart.body.overwritten, true); + assert.match((await agent.get(`/api/files/${first.body.id}/render`)).text, /explicit-v2/); + + const base64Zip = await makeZip({ + 'index.html': '

explicit-v3

', + 'assets/app.js': 'window.v=3', + }); + const base64 = await agent + .post(`/api/files/${first.body.id}/overwrite-zip-base64`) + .send({ name: 'explicit-site.zip', content: base64Zip.toString('base64') }); + assert.strictEqual(base64.status, 200); + assert.strictEqual(base64.body.version, 3); + assert.match((await agent.get(`/api/files/${first.body.id}/render`)).text, /explicit-v3/); + + const bundleWithText = await agent + .post(`/api/files/${first.body.id}/overwrite`) + .attach('file', Buffer.from('

wrong

'), 'wrong.html'); + assert.strictEqual(bundleWithText.status, 400); + assert.match((await agent.get(`/api/files/${first.body.id}/render`)).text, /explicit-v3/); + + const text = await agent.post('/api/files/upload-json').send({ + name: 'plain.html', + content: '

plain

', + }); + const textWithZip = await agent + .post(`/api/files/${text.body.id}/overwrite`) + .attach('file', multipartZip, 'wrong.zip'); + assert.strictEqual(textWithZip.status, 400); + assert.match((await agent.get(`/api/files/${text.body.id}/render`)).text, /plain/); + await assertStorageAccounting(); +}); + +test('显式覆盖拒绝 batch ZIP;batch 内重复 basename 也确定性拒绝', async () => { + const bundle = await uploadZip('single-target.zip', { + 'index.html': '

target

', + 'assets/app.js': 'window.v=1', + }); + const batch = await makeZip({ + 'a.html': '

a

', + 'b.html': '

b

', + }); + const explicitBatch = await agent + .post(`/api/files/${bundle.body.id}/overwrite-zip-base64`) + .send({ name: 'batch.zip', content: batch.toString('base64') }); + assert.strictEqual(explicitBatch.status, 400); + + const duplicateBasename = await uploadZip('duplicate-basename.zip', { + 'a/same.html': '

a

', + 'b/same.html': '

b

', + 'readme.md': '# force batch', + }); + assert.strictEqual(duplicateBasename.status, 400); + assert.match(duplicateBasename.body.error, /同名/); + await assertStorageAccounting(); +}); + +test('bundle 历史超过上限时裁剪最旧目录且不留孤儿', async () => { + const first = await uploadZip('pruned-site.zip', { + 'index.html': '

prune-v1

', + 'assets/app.js': 'window.v=1', + }); + await uploadZip('pruned-site.zip', { + 'index.html': '

prune-v2

', + 'assets/app.js': 'window.v=2', + }); + + const { dbGet } = require('../../lib/db'); + const oldest = await dbGet( + 'SELECT stored_name FROM file_versions WHERE file_id = ? AND version = 1', + [first.body.id] + ); + const oldestPath = path.join(env.dataDir, 'uploads', oldest.stored_name); + assert.strictEqual(fs.existsSync(oldestPath), true); + + for (let version = 3; version <= 5; version++) { + await uploadZip('pruned-site.zip', { + 'index.html': `

prune-v${version}

`, + 'assets/app.js': `window.v=${version}`, + }); + } + + const versions = await agent.get(`/api/files/${first.body.id}/versions`); + assert.strictEqual(versions.body.versions.length, 3); + assert.deepStrictEqual( + versions.body.versions.map(item => item.version).sort((a, b) => a - b), + [2, 3, 4] + ); + assert.strictEqual(fs.existsSync(oldestPath), false); + await assertStorageAccounting(); +});