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();
+});