forked from bai/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:
@@ -110,7 +110,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
|
||||
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
|
||||
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
|
||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(**仅测试用**,ADR-0030) |
|
||||
| `filelib/gitVersionStore.ts` | **生产** C1 实现:一项目一 git 仓库,VersionId = commit hash(ADR-0030) |
|
||||
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
|
||||
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
|
||||
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
|
||||
@@ -125,9 +126,20 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
|
||||
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
|
||||
|
||||
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
|
||||
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
|
||||
> 持久化 git 包到位后此问题消失。
|
||||
## 存储布局(ADR-0030)
|
||||
|
||||
- **文件夹不落盘。** `FOLDER` 节点的 `storageDir` 永为 `NULL`,磁盘上不存在任何
|
||||
对应目录;树形只由 `parentId` + `pathIds`(id 编码的物化路径)表达。
|
||||
- **项目扁平、以 id 命名。** 仓库就是 `<storageRoot>/<nodeId>`(nodeId 是 uuid)。
|
||||
名字不进路径 —— 这是 rename 不动磁盘、也不重写后代路径的原因。
|
||||
- **一项目一真 git 仓库。** `init` 建目录 + `git init`;`VersionId` 是 40 位 commit
|
||||
hash;某文件的版本 = `git log -1 -- <path>`,所以一个文件的提交不会使另一个
|
||||
文件的 `baseVersion` 失效(D16)。删除也是一个 commit,旧版本仍可读。
|
||||
- git 用 `execFile` 调系统二进制,不引依赖。**每次调用都禁 hooks、隔离全局/系统
|
||||
gitconfig、`GIT_LITERAL_PATHSPECS=1`** —— 项目仓库是老师上传的**数据**而非可信代码。
|
||||
- 宿主必须有 `git`;缺失时建项目报 `provision_failed`。
|
||||
- 写入仅**进程内**串行化。共享 storage root 的多进程会在同一仓库上竞争 git 锁;
|
||||
alpha Silo 是一 org 一进程(ADR-0025),目前不可达。
|
||||
|
||||
关键语义速查:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* VersionStore(契约 C1)的真 git 实现 —— ADR-0030。
|
||||
*
|
||||
* 一个项目一个 git 仓库,位于 <storageRoot>/<nodeId>(nodeId 是 uuid;
|
||||
* 文件夹不落盘,见 ADR-0030 Context)。VersionId = commit hash。
|
||||
*
|
||||
* D16 文件级版本的映射(ADR-0030 Decision):一次写只碰一个路径、只产生一个
|
||||
* commit;某文件的版本 = `git log -1 -- <path>` 的 hash。因此 a.md 的提交不出现在
|
||||
* b.md 的 log 里,两者 baseVersion 互不失效 —— 尽管 commit 本身是仓库级对象。
|
||||
*
|
||||
* 不引 npm 依赖:三条 execFile 就够,见 ADR-0030 Alternatives。
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { FileLibError } from "./model.js";
|
||||
import type {
|
||||
CommitAuthor,
|
||||
CommitRequest,
|
||||
CommitResult,
|
||||
FileEntry,
|
||||
VersionId,
|
||||
VersionInfo,
|
||||
VersionStore,
|
||||
} from "./versionStore.js";
|
||||
|
||||
/** 无 author 时的固定身份(ADR-0030 Consequences:不再是 undefined)。 */
|
||||
const FALLBACK_AUTHOR = "filelib";
|
||||
/** 提交者 email 域名:`<userId>@filelib.paradigm-edu.net`。 */
|
||||
const AUTHOR_EMAIL_DOMAIN = "filelib.paradigm-edu.net";
|
||||
|
||||
/**
|
||||
* 每次调用都带的加固(ADR-0030 Decision)。项目仓库是老师上传的**数据**,
|
||||
* 不是可信代码:hooks 必须禁用,全局/系统 gitconfig 必须隔离,否则仓库内容
|
||||
* 或开发者机器上的配置就能改变服务端行为。
|
||||
*/
|
||||
const HARDENING_ARGS = ["-c", "core.hooksPath=", "-c", "commit.gpgsign=false"] as const;
|
||||
|
||||
/**
|
||||
* 仓库定位参数。**这两个不能省**:git 默认会沿目录树**向上**找 `.git`,
|
||||
* 而 storage root 很可能就在另一个 git 仓库里(本地开发的默认值
|
||||
* `hub/.filelib-repos` 就在本 repo 内)。不钉死的后果是项目目录没自己的 `.git`
|
||||
* 时,所有命令默默落到**外层仓库**上 —— 轻则 `git add` 报 ignored,
|
||||
* 重则把老师的文件提交进源码仓。
|
||||
*/
|
||||
function repoArgs(projectDir: string): readonly string[] {
|
||||
return [`--git-dir=${path.join(projectDir, ".git")}`, `--work-tree=${projectDir}`];
|
||||
}
|
||||
|
||||
const HARDENING_ENV = {
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_CONFIG_SYSTEM: "/dev/null",
|
||||
// 文件名永远不被重解释为 pathspec magic(`:(glob)` 等)。
|
||||
GIT_LITERAL_PATHSPECS: "1",
|
||||
// 仓库不得因为凭据提示卡住一个 HTTP 请求。
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 继承来的这几个会劫持全部命令(比如 hub 自身被一个 git hook 启动时),
|
||||
* 必须从子进程 env 里**删掉**而不是置空 —— 置空在 git 里的含义并不统一。
|
||||
* 我们只认 repoArgs 里显式传的那一份。
|
||||
*/
|
||||
const STRIPPED_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY"] as const;
|
||||
|
||||
function childEnv(extra: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, ...HARDENING_ENV, ...extra };
|
||||
for (const key of STRIPPED_ENV) delete env[key];
|
||||
return env;
|
||||
}
|
||||
|
||||
interface GitResult {
|
||||
readonly stdout: Buffer;
|
||||
readonly code: number;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
/** execFile 的 args 数组形式:不拼 shell,文件名不参与命令解析。 */
|
||||
function runGit(
|
||||
cwd: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>> = {},
|
||||
): Promise<GitResult> {
|
||||
return execGit(cwd, [...repoArgs(cwd), ...args], env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 不钉 --git-dir 的调用。**只给 `git init` 用** —— 那一刻 `.git` 尚不存在,
|
||||
* 钉上去 git 会直接报错。init 自己总是在 cwd 建仓,不会向上找。
|
||||
*/
|
||||
function runGitBare(
|
||||
cwd: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>> = {},
|
||||
): Promise<GitResult> {
|
||||
return execGit(cwd, args, env);
|
||||
}
|
||||
|
||||
function execGit(
|
||||
cwd: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>>,
|
||||
): Promise<GitResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"git",
|
||||
[...HARDENING_ARGS, ...args],
|
||||
{
|
||||
cwd,
|
||||
encoding: "buffer",
|
||||
env: childEnv(env),
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(String(stdout));
|
||||
const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr);
|
||||
if (error === null) {
|
||||
resolve({ stdout: out, code: 0, stderr: err });
|
||||
return;
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException & { code?: number | string }).code;
|
||||
if (code === "ENOENT") {
|
||||
// 坑:spawn 的 ENOENT 有两种来源且**报错完全一样**(都是 path:"git"、
|
||||
// syscall:"spawn git") —— git 真的不在 PATH 上,或者 cwd 目录不存在。
|
||||
// 后者在这里是常态(DB 里有 storageDir、磁盘上却没建过,比如内存 store
|
||||
// 时代留下的旧项目),必须报 repo_not_found 而不是冤枉 git 没装。
|
||||
void access(cwd).then(
|
||||
() => reject(new FileLibError(500, "git_missing", "git executable not found on PATH")),
|
||||
() => reject(new FileLibError(404, "repo_not_found", `repository directory missing: ${cwd}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 非零退出是常规控制流(文件不存在、空仓库等),交给调用点判断。
|
||||
resolve({ stdout: out, code: typeof code === "number" ? code : 1, stderr: err });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function requireGit(cwd: string, args: readonly string[], env?: Record<string, string>): Promise<Buffer> {
|
||||
const res = await runGit(cwd, args, env);
|
||||
if (res.code !== 0) {
|
||||
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
|
||||
}
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
async function requireGitBare(cwd: string, args: readonly string[]): Promise<Buffer> {
|
||||
const res = await runGitBare(cwd, args);
|
||||
if (res.code !== 0) {
|
||||
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
|
||||
}
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* S4:同仓库写操作串行化。git 的并发写会在 index.lock 上打架,
|
||||
* 串行化把它变成干净的 conflict 返回值而不是锁错误。仅进程内有效(ADR-0030)。
|
||||
*/
|
||||
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
|
||||
const tails = new Map<string, Promise<unknown>>();
|
||||
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
|
||||
const prev = tails.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
tails.set(key, next.catch(() => undefined));
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓库内相对路径的再校验。fileService.validateFilePath 已经把过一遍,
|
||||
* 但 ADR-0030 把这条从卫生升级为安全边界 —— 本层不信调用方。
|
||||
*/
|
||||
function safeRelPath(filePath: string): string {
|
||||
const normalized = filePath.normalize("NFC");
|
||||
if (normalized === "" || path.isAbsolute(normalized) || normalized.includes("\\")) {
|
||||
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
|
||||
}
|
||||
const segments = normalized.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "" || segment === "." || segment === ".." || segment === ".git") {
|
||||
throw new FileLibError(400, "invalid_path", `unsafe path segment in: ${filePath}`);
|
||||
}
|
||||
}
|
||||
// 解析后必须仍在仓库内(符号链接由 git 自身不跟随 + 此处前缀检查共同兜住)。
|
||||
const resolved = path.posix.normalize(normalized);
|
||||
if (resolved.startsWith("..") || path.isAbsolute(resolved)) {
|
||||
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交者身份 → git author/committer。
|
||||
* name = displayName(缺失回退 userId);email = `<userId>@filelib.paradigm-edu.net`。
|
||||
* email 用 userId 而不用 displayName:昵称会改,身份追溯不能跟着漂。
|
||||
* name 里的换行/`<`/`>` 必须清掉 —— 它们会破坏 git 的 ident 行格式。
|
||||
*/
|
||||
function authorEnv(author: CommitAuthor | undefined): Record<string, string> {
|
||||
const rawName = author?.displayName;
|
||||
const fallback = author?.userId ?? FALLBACK_AUTHOR;
|
||||
const name = (rawName === undefined || rawName.trim() === "" ? fallback : rawName.trim())
|
||||
.replace(/[<>\n\r]/g, " ")
|
||||
.trim();
|
||||
const localPart = (author?.userId ?? FALLBACK_AUTHOR).replace(/[^\w.-]/g, "_");
|
||||
const email = `${localPart}@${AUTHOR_EMAIL_DOMAIN}`;
|
||||
return {
|
||||
GIT_AUTHOR_NAME: name === "" ? FALLBACK_AUTHOR : name,
|
||||
GIT_AUTHOR_EMAIL: email,
|
||||
GIT_COMMITTER_NAME: name === "" ? FALLBACK_AUTHOR : name,
|
||||
GIT_COMMITTER_EMAIL: email,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGitVersionStore(): VersionStore {
|
||||
const serialize = createKeySerializer();
|
||||
|
||||
/**
|
||||
* 未 init → repo_not_found。
|
||||
* 不能用 `git rev-parse --git-dir`:repoArgs 已把 --git-dir 钉死,rev-parse 会
|
||||
* 原样回显它而不验证存在;而不钉死时它又会向上找到外层仓库。所以直接
|
||||
* 测文件系统:项目目录里必须有属于它自己的 `.git`。
|
||||
*/
|
||||
async function requireRepo(projectDir: string): Promise<void> {
|
||||
try {
|
||||
await access(path.join(projectDir, ".git"));
|
||||
} catch {
|
||||
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 该路径在 HEAD 上的当前版本;不存在(或从未提交)→ null。 */
|
||||
async function currentVersion(projectDir: string, filePath: string): Promise<VersionId | null> {
|
||||
const exists = await runGit(projectDir, ["cat-file", "-e", `HEAD:${filePath}`]);
|
||||
if (exists.code !== 0) return null; // 空仓库、已删除、或从无此文件
|
||||
const log = await runGit(projectDir, ["log", "-1", "--format=%H", "--", filePath]);
|
||||
if (log.code !== 0) return null;
|
||||
const hash = log.stdout.toString("utf8").trim();
|
||||
return hash === "" ? null : hash;
|
||||
}
|
||||
|
||||
async function headVersion(projectDir: string, filePath: string): Promise<VersionId> {
|
||||
const version = await currentVersion(projectDir, filePath);
|
||||
if (version === null) {
|
||||
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
/** commit id 必须存在,否则 version_not_found(而非把 git 错误透出去)。 */
|
||||
async function requireCommit(projectDir: string, version: VersionId): Promise<void> {
|
||||
const res = await runGit(projectDir, ["rev-parse", "--verify", "--quiet", `${version}^{commit}`]);
|
||||
if (res.code !== 0) {
|
||||
throw new FileLibError(404, "version_not_found", `version not found: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交暂存区里已备好的单个路径。返回新 commit hash。 */
|
||||
async function commitPath(
|
||||
projectDir: string,
|
||||
filePath: string,
|
||||
message: string,
|
||||
author: CommitAuthor | undefined,
|
||||
): Promise<VersionId> {
|
||||
const env = authorEnv(author);
|
||||
await requireGit(projectDir, ["commit", "--quiet", "--allow-empty", "-m", message, "--", filePath], env);
|
||||
const hash = await requireGit(projectDir, ["rev-parse", "HEAD"]);
|
||||
return hash.toString("utf8").trim();
|
||||
}
|
||||
|
||||
return {
|
||||
/** S7 幂等:已有仓库就不重建。 */
|
||||
async init(projectDir) {
|
||||
await serialize(projectDir, async () => {
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
try {
|
||||
await access(path.join(projectDir, ".git"));
|
||||
return; // 已是仓库,不清空
|
||||
} catch { /* 继续 init */ }
|
||||
// init 用 runGitBare:此时 .git 尚不存在,钉 --git-dir 反而会让 git 报错。
|
||||
await requireGitBare(projectDir, ["init", "--quiet"]);
|
||||
// 默认分支名不依赖宿主 git 版本/配置(全局配置已被隔离)。
|
||||
await requireGit(projectDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
|
||||
});
|
||||
},
|
||||
|
||||
async list(projectDir, prefix) {
|
||||
await requireRepo(projectDir);
|
||||
const res = await runGit(projectDir, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
|
||||
if (res.code !== 0) return []; // 空仓库(无 HEAD)
|
||||
const out: FileEntry[] = [];
|
||||
for (const record of res.stdout.toString("utf8").split("\0")) {
|
||||
if (record === "") continue;
|
||||
// 形如:"<mode> <type> <object> <size>\t<path>"
|
||||
const tab = record.indexOf("\t");
|
||||
if (tab === -1) continue;
|
||||
const meta = record.slice(0, tab).split(/\s+/);
|
||||
const entryPath = record.slice(tab + 1);
|
||||
if (meta[1] !== "blob") continue;
|
||||
if (prefix !== undefined && !entryPath.startsWith(prefix)) continue;
|
||||
out.push({ path: entryPath, size: Number.parseInt(meta[3] ?? "0", 10) || 0 });
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
},
|
||||
|
||||
async head(projectDir, filePath) {
|
||||
await requireRepo(projectDir);
|
||||
return headVersion(projectDir, safeRelPath(filePath));
|
||||
},
|
||||
|
||||
async read(projectDir, filePath, at) {
|
||||
await requireRepo(projectDir);
|
||||
const rel = safeRelPath(filePath);
|
||||
if (at === undefined) {
|
||||
await headVersion(projectDir, rel); // 存在性 → 404 file_not_found
|
||||
const res = await runGit(projectDir, ["show", `HEAD:${rel}`]);
|
||||
if (res.code !== 0) {
|
||||
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
|
||||
}
|
||||
return res.stdout;
|
||||
}
|
||||
await requireCommit(projectDir, at);
|
||||
const res = await runGit(projectDir, ["show", `${at}:${rel}`]);
|
||||
if (res.code !== 0) {
|
||||
// commit 存在但该版本里没有这个路径。
|
||||
throw new FileLibError(404, "version_not_found", `version not found: ${rel}@${at}`);
|
||||
}
|
||||
return res.stdout;
|
||||
},
|
||||
|
||||
async commit(projectDir, filePath, req: CommitRequest): Promise<CommitResult> {
|
||||
const rel = safeRelPath(filePath);
|
||||
return serialize(projectDir, async (): Promise<CommitResult> => {
|
||||
await requireRepo(projectDir);
|
||||
const current = await currentVersion(projectDir, rel);
|
||||
|
||||
// S2:baseVersion=null 表新建,已存在即 conflict;
|
||||
// S1:否则要求 baseVersion 精确等于当前版本。
|
||||
if (req.baseVersion === null) {
|
||||
if (current !== null) return { status: "conflict", currentVersion: current };
|
||||
} else if (req.baseVersion !== current) {
|
||||
return { status: "conflict", currentVersion: current ?? req.baseVersion };
|
||||
}
|
||||
|
||||
const abs = path.join(projectDir, rel);
|
||||
await mkdir(path.dirname(abs), { recursive: true });
|
||||
await writeFile(abs, req.content);
|
||||
await requireGit(projectDir, ["add", "--", rel]);
|
||||
const message = req.message ?? (req.baseVersion === null ? `create ${rel}` : `update ${rel}`);
|
||||
const version = await commitPath(projectDir, rel, message, req.author);
|
||||
return { status: "ok", version };
|
||||
});
|
||||
},
|
||||
|
||||
async remove(projectDir, filePath, baseVersion, author): Promise<CommitResult> {
|
||||
const rel = safeRelPath(filePath);
|
||||
return serialize(projectDir, async (): Promise<CommitResult> => {
|
||||
await requireRepo(projectDir);
|
||||
const current = await currentVersion(projectDir, rel);
|
||||
if (current === null) {
|
||||
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
|
||||
}
|
||||
if (baseVersion !== current) return { status: "conflict", currentVersion: current };
|
||||
await requireGit(projectDir, ["rm", "--quiet", "--", rel]);
|
||||
const version = await commitPath(projectDir, rel, `remove ${rel}`, author);
|
||||
return { status: "ok", version };
|
||||
});
|
||||
},
|
||||
|
||||
async diff(projectDir, filePath, from, to) {
|
||||
await requireRepo(projectDir);
|
||||
const rel = safeRelPath(filePath);
|
||||
await requireCommit(projectDir, from);
|
||||
await requireCommit(projectDir, to);
|
||||
const res = await runGit(projectDir, ["diff", from, to, "--", rel]);
|
||||
if (res.code !== 0) {
|
||||
throw new FileLibError(500, "git_failed", `git diff failed: ${res.stderr.trim()}`);
|
||||
}
|
||||
return res.stdout.toString("utf8");
|
||||
},
|
||||
|
||||
async history(projectDir, filePath, limit) {
|
||||
await requireRepo(projectDir);
|
||||
const rel = safeRelPath(filePath);
|
||||
const args = ["log", "--format=%H%x1f%an%x1f%aI%x1f%s%x1e"];
|
||||
if (limit !== undefined) args.push(`-${limit}`);
|
||||
args.push("--", rel);
|
||||
const res = await runGit(projectDir, args);
|
||||
if (res.code !== 0) return []; // 空仓库
|
||||
const out: VersionInfo[] = [];
|
||||
for (const record of res.stdout.toString("utf8").split("\x1e")) {
|
||||
const line = record.trim();
|
||||
if (line === "") continue;
|
||||
const [version, author, committedAt, message] = line.split("\x1f");
|
||||
if (version === undefined) continue;
|
||||
out.push({
|
||||
version,
|
||||
message: message ?? "",
|
||||
author: author === undefined || author === "" ? undefined : author,
|
||||
committedAt: committedAt ?? "",
|
||||
});
|
||||
}
|
||||
return out; // git log 已是新→旧
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -43,5 +43,7 @@ export async function requireFileLibActor(
|
||||
return {
|
||||
userId: auth.user.id,
|
||||
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
|
||||
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
|
||||
displayName: auth.user.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface FileLibRouteDeps {
|
||||
readonly groupResolver: GroupResolver;
|
||||
readonly versionStore: VersionStore;
|
||||
readonly exportAdapters: readonly ExportAdapter[];
|
||||
/** 单文件字节上限(`HUB_FILELIB_MAX_FILE_BYTES`)。 */
|
||||
readonly maxFileBytes: number;
|
||||
}
|
||||
|
||||
/** 组装 treeService 依赖(路由处理内直接使用)。 */
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
/**
|
||||
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
|
||||
* 权限判定一律用 userId。缺失时回退到 userId。
|
||||
*/
|
||||
readonly displayName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
@@ -229,6 +234,8 @@ export async function createNode(
|
||||
}
|
||||
|
||||
if (input.kind === "PROJECT") {
|
||||
// ADR-0030:项目扁平居于同一根下、以 uuid 命名;名字不进路径(所以 rename
|
||||
// 不动磁盘)。FOLDER 永不赋值 —— 文件夹不落盘,只存在于 DB 的 parentId/pathIds。
|
||||
storageDir = path.join(deps.storageRoot, id);
|
||||
}
|
||||
|
||||
@@ -308,6 +315,7 @@ export async function createNode(
|
||||
});
|
||||
|
||||
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
|
||||
// ADR-0030:这一步真的建目录并 `git init`;宿主无 git 则此处 provision_failed。
|
||||
if (input.kind === "PROJECT" && storageDir !== null) {
|
||||
try {
|
||||
await deps.versionStore.init(storageDir);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* VersionStore port(契约 C1)+ 开发用内存实现。
|
||||
* VersionStore port(契约 C1)+ **仅测试用**的内存实现。
|
||||
*
|
||||
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
|
||||
* 生产实现是 `gitVersionStore.ts`(一项目一 git 仓库,VersionId = commit hash),
|
||||
* 见 ADR-0030。本文件留下来只为让不关心版本落盘的测试快速起个 store;它的
|
||||
* VersionId 是每仓库计数器(`v1`/`v2`),与生产**不同形**,不要据此写断言。
|
||||
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
|
||||
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
|
||||
*
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
|
||||
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复。ADR-0030 之后
|
||||
* 已无生产调用点 —— 生产走 git,不再有这份进程级 JSON 快照。
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
@@ -15,12 +17,21 @@ import { FileLibError } from "./model.js";
|
||||
|
||||
export type VersionId = string;
|
||||
|
||||
/**
|
||||
* 提交者身份。`userId` 是稳定主键(追溯用),`displayName` 只影响展示。
|
||||
* git 实现把它们映射成 `name <userId@域名>`(ADR-0030)。
|
||||
*/
|
||||
export interface CommitAuthor {
|
||||
readonly userId: string;
|
||||
readonly displayName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CommitRequest {
|
||||
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
|
||||
readonly baseVersion: VersionId | null;
|
||||
readonly content: string | Buffer;
|
||||
readonly message?: string | undefined;
|
||||
readonly author?: string | undefined;
|
||||
readonly author?: CommitAuthor | undefined;
|
||||
}
|
||||
|
||||
export type CommitResult =
|
||||
@@ -45,7 +56,13 @@ export interface VersionStore {
|
||||
head(projectDir: string, filePath: string): Promise<VersionId>;
|
||||
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
|
||||
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
|
||||
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
|
||||
/** 删除也是一次提交,所以同样带提交者身份。 */
|
||||
remove(
|
||||
projectDir: string,
|
||||
filePath: string,
|
||||
baseVersion: VersionId,
|
||||
author?: CommitAuthor,
|
||||
): Promise<CommitResult>;
|
||||
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
|
||||
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
|
||||
}
|
||||
@@ -80,6 +97,13 @@ function toBuffer(content: string | Buffer): Buffer {
|
||||
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
||||
}
|
||||
|
||||
/** VersionInfo.author 是展示字符串;语义与 git 实现对齐(取 displayName,回退 userId)。 */
|
||||
function authorLabel(author: CommitAuthor | undefined): string | undefined {
|
||||
if (author === undefined) return undefined;
|
||||
const name = author.displayName;
|
||||
return name === undefined || name.trim() === "" ? author.userId : name.trim();
|
||||
}
|
||||
|
||||
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
|
||||
function naiveDiff(fromText: string, toText: string): string {
|
||||
const a = fromText.split("\n");
|
||||
@@ -221,7 +245,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
version,
|
||||
content: toBuffer(req.content),
|
||||
message: req.message ?? `commit ${version}`,
|
||||
author: req.author,
|
||||
author: authorLabel(req.author),
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: false,
|
||||
});
|
||||
@@ -231,7 +255,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
});
|
||||
},
|
||||
|
||||
async remove(projectDir, filePath, baseVersion) {
|
||||
async remove(projectDir, filePath, baseVersion, author) {
|
||||
return serialize(projectDir, async (): Promise<CommitResult> => {
|
||||
const repo = requireRepo(projectDir);
|
||||
const chain = repo.files.get(filePath) ?? [];
|
||||
@@ -247,7 +271,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
version,
|
||||
content: Buffer.alloc(0),
|
||||
message: `remove ${filePath}`,
|
||||
author: undefined,
|
||||
author: authorLabel(author),
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: true,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,8 @@ import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||
import { registerFileRoutes } from "./fileRoutes.js";
|
||||
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
||||
import { registerTeacherApp } from "./teacherApp.js";
|
||||
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
||||
import { createGitVersionStore } from "../filelib/gitVersionStore.js";
|
||||
import { resolveMaxFileBytes } from "../filelib/fileService.js";
|
||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
@@ -60,6 +61,8 @@ export async function registerDatabaseRoutes(
|
||||
app.get("/database/config", async () => ({
|
||||
orgSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
// 上传上限由后端下发,前端不再写死 —— 两处各自硬编码会随配置漂。
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
}));
|
||||
|
||||
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
|
||||
@@ -132,7 +135,8 @@ export async function registerDatabaseRoutes(
|
||||
}
|
||||
|
||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
|
||||
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
||||
// 依赖装配:VersionStore 是真 git —— 一项目一仓库 <storageRoot>/<nodeId>,
|
||||
// VersionId = commit hash(ADR-0030);
|
||||
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
||||
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||
@@ -145,7 +149,7 @@ export async function registerDatabaseRoutes(
|
||||
return;
|
||||
}
|
||||
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
|
||||
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json"));
|
||||
const versionStore = createGitVersionStore();
|
||||
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
|
||||
const filelibDeps: FileLibRouteDeps = {
|
||||
prisma: config.prisma,
|
||||
@@ -157,6 +161,7 @@ export async function registerDatabaseRoutes(
|
||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
};
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
await registerFileRoutes(app, filelibDeps);
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function registerFileRoutes(
|
||||
organizationId: deps.organizationId,
|
||||
groupResolver: deps.groupResolver,
|
||||
versionStore: deps.versionStore,
|
||||
maxFileBytes: deps.maxFileBytes,
|
||||
};
|
||||
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user