forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 文件内容服务(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 });
|
||||
}
|
||||
Reference in New Issue
Block a user