/** * 文件内容服务(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 } 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; export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值 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; } 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): 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`); } } 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); } 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); const project = await requireProject(deps, actor, projectId, "EDIT"); const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, { baseVersion: input.baseVersion, content, message: input.message, author: actor.userId, }); 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: input.message ?? null }, ); 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); 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 }); }