forked from EduCraft/curriculum-project-hub
276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
/**
|
|
* 文件内容服务(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<ProjectChain> {
|
|
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<string, unknown>,
|
|
): Promise<void> {
|
|
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<readonly FileEntry[]> {
|
|
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<FileContentDto> {
|
|
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<readonly VersionInfo[]> {
|
|
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<void> {
|
|
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 });
|
|
}
|