/** * 文件内容服务(Phase 3):路径安全 + 版本化文件操作 + 审计。 * * 顺序铁律(Metis 风险#1 的落地): * - 内容写:先 versionStore.commit(业务事实本体)→ 再 DB 事务(审计)。 * 宁多一个无审计的版本,不造一条假审计。 * - conflict:写 file.conflict_detected(冲突本身就是事件),再抛 409。 * - 读:随取随读,不写审计(需求 5.2 未列读操作)。 */ import { FileLibError } from "./model.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js"; import type { AccessDeps, FileLibActor } from "./treeService.js"; import { requireAccessInTx } from "./treeService.js"; import type { FileLibNode, PrismaClient } from "@prisma/client"; export const FILE_PATH_MAX_LENGTH = 512; export const FILE_PATH_MAX_DEPTH = 32; /** 单文件上限的出厂默认值(OPEN-5 初值)。实际生效值见 `resolveMaxFileBytes`。 */ export const FILE_CONTENT_MAX_BYTES_DEFAULT = 10 * 1024 * 1024; /** * 单文件字节上限的纯解析。非法值(非正整数/NaN)按缺省处理 —— * 配置写错不该让上传静默变成 0 上限(那会把每次上传都拒掉)。 * * 与 `resolveMaxFileBytes` 分开是有意的:带默认参数的单函数版本里, * 显式传 undefined 会触发默认值、回到读 env,于是“没传值”和“读环境变量” * 永远分不开,测试也会被 vitest 加载的 .env 干扰。 */ export function parseMaxFileBytes(raw: string | undefined): 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; } /** * 生效上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB。 * * 注意它与 `HUB_HTTP_BODY_LIMIT_BYTES` 是串联的:上传把内容放在 JSON body 里, * 二进制过 base64 体积涨 4/3,所以真正的天花板是 * min(本值, bodyLimit × 3/4)。body limit 太小时本值不可达,而且报错发生在 * Fastify 解析阶段(413 Payload Too Large),根本到不了下面的 checkSize。 */ export function resolveMaxFileBytes(): number { return parseMaxFileBytes(process.env["HUB_FILELIB_MAX_FILE_BYTES"]); } const CONTROL_CHARS = /[\p{C}]/u; const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]); /** * 路径安全(Metis 安全红线):NFC;拒绝反斜杠、控制字符、空段、 * "." / ".." / ".git" 段、绝对路径、超长/超深。返回规范化相对路径。 */ export function validateFilePath(raw: string): string { const normalized = raw.normalize("NFC"); if (normalized.length === 0 || normalized.length > FILE_PATH_MAX_LENGTH) { throw new FileLibError(400, "invalid_path", `path must be 1..${FILE_PATH_MAX_LENGTH} characters`); } if (normalized.includes("\\")) { throw new FileLibError(400, "invalid_path", "path must use '/' separators"); } if (CONTROL_CHARS.test(normalized)) { throw new FileLibError(400, "invalid_path", "path must not contain control characters"); } const segments = normalized.split("/"); if (segments.length > FILE_PATH_MAX_DEPTH) { throw new FileLibError(400, "invalid_path", `path depth exceeds ${FILE_PATH_MAX_DEPTH}`); } for (const segment of segments) { if (FORBIDDEN_SEGMENTS.has(segment)) { throw new FileLibError(400, "invalid_path", `forbidden path segment: "${segment}"`); } } return normalized; } export interface FileDeps extends AccessDeps { readonly prisma: PrismaClient; readonly versionStore: VersionStore; /** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */ readonly maxFileBytes?: number | undefined; } type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string }; async function requireProject( deps: FileDeps, actor: FileLibActor, projectId: string, minRole: "VIEW" | "EDIT", ): Promise { const { node } = await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, projectId, minRole), ); if (node.kind !== "PROJECT") { throw new FileLibError(400, "invalid_node_kind", "file operations apply to projects only"); } if (node.provisionStatus === "PROVISIONING") { throw new FileLibError(409, "project_not_ready", "project repository is still provisioning"); } if (node.provisionStatus === "FAILED") { throw new FileLibError(409, "project_not_ready", "project repository provisioning failed"); } if (node.storageDir === null) { throw new FileLibError(500, "storage_missing", "project has no storage directory"); } return { node, storageDir: node.storageDir }; } export type FileContentEncoding = "utf8" | "base64"; export interface FileContentDto { readonly path: string; readonly version: string; readonly encoding: FileContentEncoding; readonly content: string; readonly size: number; } export function decodeContent(content: string, encoding: FileContentEncoding): string | Buffer { return encoding === "base64" ? Buffer.from(content, "base64") : content; } function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding; readonly content: string } { // 粗判二进制:含 NUL 字节即按 base64 返回(需求 2.5 在线编辑仅针对文本)。 return buffer.includes(0) ? { encoding: "base64", content: buffer.toString("base64") } : { encoding: "utf8", content: buffer.toString("utf8") }; } function checkSize(content: string | Buffer, maxBytes: number): void { const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength; 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, action: string, project: ProjectChain, filePath: string, detail: Record, ): Promise { await deps.prisma.$transaction(async (tx) => { await writeFileLibAudit(tx, { action, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "file", objectId: project.node.id, objectPath: `${project.node.pathIds}:${filePath}`, detail, }); }); } /* ---------------------------------------------------------------- 读操作 */ export async function listFiles( deps: FileDeps, actor: FileLibActor, projectId: string, prefix?: string, ): Promise { const project = await requireProject(deps, actor, projectId, "VIEW"); return deps.versionStore.list(project.storageDir, prefix === undefined ? undefined : validateFilePath(prefix)); } export async function readFile( deps: FileDeps, actor: FileLibActor, projectId: string, rawPath: string, ): Promise { const filePath = validateFilePath(rawPath); const project = await requireProject(deps, actor, projectId, "VIEW"); const [version, buffer] = await Promise.all([ deps.versionStore.head(project.storageDir, filePath), deps.versionStore.read(project.storageDir, filePath), ]); const { encoding, content } = encodeContent(buffer); return { path: filePath, version, encoding, content, size: buffer.byteLength }; } /** 原始字节下载(浏览器 save-as 用):JSON 之外的第二条读取通道,同样的 VIEW 门禁。 */ export async function readFileRaw( deps: FileDeps, actor: FileLibActor, projectId: string, rawPath: string, ): Promise<{ readonly buffer: Buffer; readonly version: string; readonly filename: string }> { const filePath = validateFilePath(rawPath); const project = await requireProject(deps, actor, projectId, "VIEW"); const [version, buffer] = await Promise.all([ deps.versionStore.head(project.storageDir, filePath), deps.versionStore.read(project.storageDir, filePath), ]); return { buffer, version, filename: filePath.split("/").pop() ?? "download" }; } export async function fileHistory( deps: FileDeps, actor: FileLibActor, projectId: string, rawPath: string, limit?: number, ): Promise { const filePath = validateFilePath(rawPath); const project = await requireProject(deps, actor, projectId, "VIEW"); return deps.versionStore.history(project.storageDir, filePath, limit); } /** 项目级提交历史(所有文件),VIEW 即可访问。 */ export async function projectHistory( deps: FileDeps, actor: FileLibActor, projectId: string, limit?: number, ): Promise { const project = await requireProject(deps, actor, projectId, "VIEW"); return deps.versionStore.projectHistory(project.storageDir, limit); } export async function diffFile( deps: FileDeps, actor: FileLibActor, projectId: string, rawPath: string, from: string, to: string, ): Promise<{ readonly diff: string }> { const filePath = validateFilePath(rawPath); const project = await requireProject(deps, actor, projectId, "VIEW"); return { diff: await deps.versionStore.diff(project.storageDir, filePath, from, to) }; } /* ---------------------------------------------------------------- 写操作 */ export interface CommitInput { readonly path: string; /** null = 新建(已存在则 409);编辑时传 readFile 拿到的 version。 */ readonly baseVersion: string | null; readonly content: string; readonly encoding?: FileContentEncoding | undefined; readonly message?: string | undefined; } /** * 统一写入口(上传/编辑共用):先 commit,成功写 file.commit / file.upload 审计; * 冲突写 file.conflict_detected 后抛 409(details 带 currentVersion,编辑 UI 用它拉 diff)。 */ export async function commitFile( deps: FileDeps, actor: FileLibActor, projectId: string, input: CommitInput, ): Promise<{ readonly version: string }> { const filePath = validateFilePath(input.path); const content = decodeContent(input.content, input.encoding ?? "utf8"); 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, // 身份:name=displayName(回退 userId),email=@域名(git 实现里拼)。 author: { userId: actor.userId, displayName: actor.displayName }, }); if (result.status === "conflict") { await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, { baseVersion: input.baseVersion, currentVersion: result.currentVersion, }); throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", { currentVersion: result.currentVersion, }); } await auditFile( deps, actor, input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit, project, filePath, { version: result.version, message }, ); return { version: result.version }; } export async function deleteFile( deps: FileDeps, actor: FileLibActor, projectId: string, rawPath: string, baseVersion: string, ): Promise { const filePath = validateFilePath(rawPath); const project = await requireProject(deps, actor, projectId, "EDIT"); 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, currentVersion: result.currentVersion, }); throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", { currentVersion: result.currentVersion, }); } await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion }); }