Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand All @@ -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`
Expand Down Expand Up @@ -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` 回填。
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 短链接渲染页面 |

Expand Down
9 changes: 7 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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 | 删除指定历史版本 |

Expand Down
123 changes: 123 additions & 0 deletions lib/file-storage.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 4 additions & 1 deletion lib/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<base href="/api/files/' + file.id + '/asset/' + entryDirPart + '">';
const assetBasePath = file.asset_base_path || ('/api/files/' + file.id + '/asset/');
const baseTag = '<base href="' + assetBasePath + entryDirPart + '">';

if (entryExt === '.md' || entryExt === '.markdown') {
// Markdown 入口:marked 渲染 + 模板(命中缓存则跳过昂贵的渲染)
Expand All @@ -122,6 +123,8 @@ async function renderFile(res, file) {
content = content.replace(/<head>/i, '<head>\n' + baseTag);
} else if (/<html/i.test(content)) {
content = content.replace(/<html[^>]*>/i, '$&\n<head>' + baseTag + '</head>');
} else {
content = '<!doctype html><html><head><meta charset="UTF-8">' + baseTag + '</head><body>' + content + '</body></html>';
}
return sendHtmlDoc(res, ensureCharset(content));
} catch (e) {
Expand Down
Loading
Loading