forked from EduCraft/curriculum-project-hub
340 lines
13 KiB
TypeScript
340 lines
13 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, 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<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, 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<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);
|
||
}
|
||
|
||
/** 项目级提交历史(所有文件),VIEW 即可访问。 */
|
||
export async function projectHistory(
|
||
deps: FileDeps,
|
||
actor: FileLibActor,
|
||
projectId: string,
|
||
limit?: number,
|
||
): Promise<readonly ProjectCommitInfo[]> {
|
||
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=<userId>@域名(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<void> {
|
||
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 });
|
||
}
|