Files
curriculum-project-hub/hub/src/database/filelib/fileService.ts
T

340 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文件内容服务(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 });
}