forked from EduCraft/curriculum-project-hub
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user