feat(filelib)!: VersionStore 改为真 git,一项目一仓库

内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
This commit is contained in:
2026-07-27 15:49:26 +08:00
parent a306c58db2
commit 82241afb56
16 changed files with 1150 additions and 40 deletions
+46 -9
View File
@@ -17,7 +17,20 @@ import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32;
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
/** 单文件上限的出厂默认值(OPEN-5 初值)。实际生效值见 `resolveMaxFileBytes`。 */
export const FILE_CONTENT_MAX_BYTES_DEFAULT = 10 * 1024 * 1024;
/**
* 单文件字节上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB。
* 非法值(非正整数/NaN)按缺省处理 —— 配置写错不该让上传静默变成 0 上限。
*/
export function resolveMaxFileBytes(raw: string | undefined = process.env["HUB_FILELIB_MAX_FILE_BYTES"]): number {
if (raw === undefined || raw.trim() === "") return FILE_CONTENT_MAX_BYTES_DEFAULT;
const parsed = Number(raw.trim());
if (!Number.isSafeInteger(parsed) || parsed <= 0) return FILE_CONTENT_MAX_BYTES_DEFAULT;
return parsed;
}
const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
@@ -52,6 +65,8 @@ export function validateFilePath(raw: string): string {
export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient;
readonly versionStore: VersionStore;
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
readonly maxFileBytes?: number | undefined;
}
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
@@ -101,13 +116,25 @@ function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding
: { encoding: "utf8", content: buffer.toString("utf8") };
}
function checkSize(content: string | Buffer): void {
function checkSize(content: string | Buffer, maxBytes: number): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > FILE_CONTENT_MAX_BYTES) {
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`);
if (bytes > maxBytes) {
throw new FileLibError(413, "file_too_large", `file exceeds ${maxBytes} bytes`);
}
}
/**
* commit message 的默认文案:`【用户名】修改了【路径】`。
* 用户名取 displayName,缺失回退 userId(权限判定从不看它)。
* 显式传 message 的调用方优先 —— 这里只填空缺。
*/
export function defaultCommitMessage(actor: FileLibActor, filePath: string): string {
const who = actor.displayName === undefined || actor.displayName.trim() === ""
? actor.userId
: actor.displayName.trim();
return `${who}】修改了【${filePath}`;
}
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
@@ -221,14 +248,21 @@ export async function commitFile(
): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content);
checkSize(content, deps.maxFileBytes ?? resolveMaxFileBytes());
const project = await requireProject(deps, actor, projectId, "EDIT");
// 调用方传了非空 message 则用它,否则回退默认文案。
// 空串必须当作没传:`git commit -m ""` 会以 empty commit message 失败。
const trimmedMessage = input.message?.trim();
const message = trimmedMessage === undefined || trimmedMessage === ""
? defaultCommitMessage(actor, filePath)
: trimmedMessage;
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion,
content,
message: input.message,
author: actor.userId,
message,
// 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
author: { userId: actor.userId, displayName: actor.displayName },
});
if (result.status === "conflict") {
@@ -247,7 +281,7 @@ export async function commitFile(
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message: input.message ?? null },
{ version: result.version, message },
);
return { version: result.version };
}
@@ -261,7 +295,10 @@ export async function deleteFile(
): Promise<void> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion);
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion, {
userId: actor.userId,
displayName: actor.displayName,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion,