forked from bai/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 文件库审计 sink(契约 C3 的入驻适配)。
|
||||
*
|
||||
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
|
||||
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
|
||||
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。
|
||||
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
|
||||
*/
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
|
||||
export const FILE_LIB_AUDIT_ACTIONS = {
|
||||
folderCreate: "folder.create",
|
||||
folderRename: "folder.rename",
|
||||
folderMove: "folder.move",
|
||||
folderDelete: "folder.delete",
|
||||
projectCreate: "project.create",
|
||||
projectRename: "project.rename",
|
||||
projectMove: "project.move",
|
||||
projectDelete: "project.delete",
|
||||
permissionGrant: "permission.grant",
|
||||
permissionUpdate: "permission.update",
|
||||
permissionRevoke: "permission.revoke",
|
||||
independentEnable: "project.independent_permission.enable",
|
||||
independentDisable: "project.independent_permission.disable",
|
||||
independentChange: "project.independent_permission.change",
|
||||
fileUpload: "file.upload",
|
||||
fileRename: "file.rename",
|
||||
fileDelete: "file.delete",
|
||||
fileCommit: "file.commit",
|
||||
fileConflictDetected: "file.conflict_detected",
|
||||
exportRun: "export.run",
|
||||
adminForceAdjust: "admin.force_adjust",
|
||||
} as const;
|
||||
|
||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job";
|
||||
|
||||
export interface FileLibAuditEntry {
|
||||
readonly action: string;
|
||||
readonly actorUserId: string;
|
||||
readonly organizationId: string;
|
||||
readonly objectType: FileLibAuditObjectType;
|
||||
readonly objectId: string;
|
||||
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
|
||||
readonly objectPath: string;
|
||||
readonly detail?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
|
||||
* (需求 5.1"操作成功则日志必存在"的强保证)。
|
||||
*/
|
||||
export async function writeFileLibAudit(
|
||||
tx: Prisma.TransactionClient,
|
||||
entry: FileLibAuditEntry,
|
||||
): Promise<void> {
|
||||
const metadata: Record<string, unknown> = {
|
||||
objectType: entry.objectType,
|
||||
objectId: entry.objectId,
|
||||
objectPath: entry.objectPath,
|
||||
...(entry.detail ?? {}),
|
||||
};
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
action: entry.action,
|
||||
actorUserId: entry.actorUserId,
|
||||
organizationId: entry.organizationId,
|
||||
metadata: metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。
|
||||
*
|
||||
* ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。
|
||||
* 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑;
|
||||
* 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { FileLibError } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
|
||||
import type { FileDeps } from "./fileService.js";
|
||||
|
||||
export interface ExportAdapterInput {
|
||||
readonly storageDir: string;
|
||||
readonly target: string;
|
||||
readonly params: Record<string, unknown>;
|
||||
readonly listFiles: (prefix?: string) => Promise<readonly { path: string; size: number }[]>;
|
||||
readonly readFile: (path: string) => Promise<Buffer>;
|
||||
}
|
||||
|
||||
export interface ExportArtifact {
|
||||
readonly filename: string;
|
||||
readonly content: Buffer;
|
||||
}
|
||||
|
||||
export interface ExportAdapter {
|
||||
readonly target: string;
|
||||
run(input: ExportAdapterInput): Promise<ExportArtifact>;
|
||||
}
|
||||
|
||||
/** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */
|
||||
export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter {
|
||||
return {
|
||||
target: "manifest",
|
||||
async run(input) {
|
||||
const files = await input.listFiles();
|
||||
const lines = [
|
||||
`# Export manifest (stub adapter)`,
|
||||
`target: ${input.target}`,
|
||||
`storageDir: ${input.storageDir}`,
|
||||
`files: ${files.length}`,
|
||||
``,
|
||||
...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`),
|
||||
];
|
||||
return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
|
||||
const artifacts = new Map<string, ExportArtifact>();
|
||||
|
||||
export interface ExportDeps extends FileDeps {
|
||||
readonly adapters: readonly ExportAdapter[];
|
||||
}
|
||||
|
||||
export interface ExportJobDto {
|
||||
readonly id: string;
|
||||
readonly nodeId: string;
|
||||
readonly target: string;
|
||||
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
|
||||
readonly error: string | null;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
/** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */
|
||||
export async function submitExport(
|
||||
deps: ExportDeps,
|
||||
actor: FileLibActor,
|
||||
projectId: string,
|
||||
target: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ExportJobDto> {
|
||||
const { node } = await deps.prisma.$transaction(async (tx) =>
|
||||
requireAccessInTx(tx, deps, actor, projectId, "VIEW"),
|
||||
);
|
||||
if (node.kind !== "PROJECT") {
|
||||
throw new FileLibError(400, "invalid_node_kind", "export applies to projects only");
|
||||
}
|
||||
const adapter = deps.adapters.find((a) => a.target === target);
|
||||
if (adapter === undefined) {
|
||||
throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`);
|
||||
}
|
||||
if (node.storageDir === null) {
|
||||
throw new FileLibError(409, "project_not_ready", "project repository is not ready");
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const job = await deps.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.fileLibExportJob.create({
|
||||
data: {
|
||||
id: jobId,
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: node.id,
|
||||
target,
|
||||
params: params as never,
|
||||
status: "QUEUED",
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "export_job",
|
||||
objectId: jobId,
|
||||
objectPath: node.pathIds,
|
||||
detail: { target, params },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
const storageDir = node.storageDir;
|
||||
setImmediate(() => {
|
||||
void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined);
|
||||
});
|
||||
return toDto(job);
|
||||
}
|
||||
|
||||
async function runExportJob(
|
||||
deps: ExportDeps,
|
||||
adapter: ExportAdapter,
|
||||
jobId: string,
|
||||
storageDir: string,
|
||||
target: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } });
|
||||
try {
|
||||
const artifact = await adapter.run({
|
||||
storageDir,
|
||||
target,
|
||||
params,
|
||||
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
|
||||
readFile: (path) => deps.versionStore.read(storageDir, path),
|
||||
});
|
||||
artifacts.set(jobId, artifact);
|
||||
await deps.prisma.fileLibExportJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.prisma.fileLibExportJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "FAILED", error: String(error) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExportJob(
|
||||
deps: ExportDeps,
|
||||
actor: FileLibActor,
|
||||
jobId: string,
|
||||
): Promise<ExportJobDto> {
|
||||
const job = await deps.prisma.fileLibExportJob.findFirst({
|
||||
where: { id: jobId, organizationId: deps.organizationId },
|
||||
});
|
||||
if (job === null) throw new FileLibError(404, "export_not_found", "export job not found");
|
||||
// D8:对源项目无 View → 404(不泄露 job 存在性)。
|
||||
await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW"));
|
||||
return toDto(job);
|
||||
}
|
||||
|
||||
export async function downloadExport(
|
||||
deps: ExportDeps,
|
||||
actor: FileLibActor,
|
||||
jobId: string,
|
||||
): Promise<ExportArtifact> {
|
||||
await getExportJob(deps, actor, jobId);
|
||||
const artifact = artifacts.get(jobId);
|
||||
if (artifact === undefined) {
|
||||
throw new FileLibError(409, "export_not_ready", "export artifact is not available");
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function toDto(job: {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
target: string;
|
||||
status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
}): ExportJobDto {
|
||||
return {
|
||||
id: job.id,
|
||||
nodeId: job.nodeId,
|
||||
target: job.target,
|
||||
status: job.status,
|
||||
error: job.error,
|
||||
createdAt: job.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 授权管理(契约 8.1 矩阵的服务端强制)。
|
||||
*
|
||||
* 矩阵:
|
||||
* - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动
|
||||
* - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者
|
||||
* - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡)
|
||||
* - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计
|
||||
*
|
||||
* D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。
|
||||
*/
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client";
|
||||
import { FileLibError, type FileLibRole } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import {
|
||||
requireAccessInTx,
|
||||
type AccessDeps,
|
||||
type FileLibActor,
|
||||
type InitialGrant,
|
||||
} from "./treeService.js";
|
||||
|
||||
export interface GrantDto {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: FileLibRole;
|
||||
readonly isCreatorGrant: boolean;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
function toDto(grant: FileLibGrant): GrantDto {
|
||||
return {
|
||||
id: grant.id,
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
role: grant.role,
|
||||
isCreatorGrant: grant.isCreatorGrant,
|
||||
createdAt: grant.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
type Deps = AccessDeps & { readonly prisma: PrismaClient };
|
||||
|
||||
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
|
||||
async function requireManage(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
tx?: Tx,
|
||||
): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> {
|
||||
if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE");
|
||||
return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE"));
|
||||
}
|
||||
|
||||
/** 列出节点活跃授权(需 MANAGE)。 */
|
||||
export async function listGrants(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<readonly GrantDto[]> {
|
||||
await requireManage(deps, actor, nodeId);
|
||||
const grants = await deps.prisma.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return grants.map(toDto);
|
||||
}
|
||||
|
||||
export interface PutGrantsResult {
|
||||
readonly granted: number;
|
||||
readonly updated: number;
|
||||
readonly grants: readonly GrantDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update);
|
||||
* 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。
|
||||
*/
|
||||
export async function putGrants(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
items: readonly InitialGrant[],
|
||||
): Promise<PutGrantsResult> {
|
||||
validateGrantItems(items);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireManage(deps, actor, nodeId, tx);
|
||||
const isCreator = node.creatorId === actor.userId;
|
||||
for (const item of items) {
|
||||
if (item.role === "MANAGE" && !isCreator) {
|
||||
throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE");
|
||||
}
|
||||
if (item.principalType === "USER" && item.principalId === node.creatorId) {
|
||||
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
|
||||
}
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
let updated = 0;
|
||||
for (const item of items) {
|
||||
const existing = await tx.fileLibGrant.findFirst({
|
||||
where: {
|
||||
nodeId: node.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
if (existing !== null) {
|
||||
if (existing.isCreatorGrant) {
|
||||
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
|
||||
}
|
||||
if (existing.role !== item.role) {
|
||||
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
|
||||
updated += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
|
||||
}
|
||||
} else {
|
||||
await tx.fileLibGrant.create({
|
||||
data: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: node.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
role: item.role,
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
granted += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
|
||||
}
|
||||
}
|
||||
const grants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */
|
||||
export async function revokeGrant(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
grantId: string,
|
||||
): Promise<void> {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireManage(deps, actor, nodeId, tx);
|
||||
const grant = await tx.fileLibGrant.findFirst({
|
||||
where: { id: grantId, nodeId: node.id, revokedAt: null },
|
||||
});
|
||||
if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found");
|
||||
if (grant.isCreatorGrant) {
|
||||
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
|
||||
}
|
||||
if (grant.role === "MANAGE" && node.creatorId !== actor.userId) {
|
||||
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
|
||||
}
|
||||
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
role: grant.role,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免;
|
||||
* 每一条变更都落 admin.force_adjust 审计(高危留痕)。
|
||||
*/
|
||||
export async function forceAdjustGrants(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
items: readonly InitialGrant[],
|
||||
): Promise<PutGrantsResult> {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "force adjust requires website administrator");
|
||||
}
|
||||
validateGrantItems(items);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const node = await tx.fileLibNode.findFirst({
|
||||
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null },
|
||||
});
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
|
||||
let granted = 0;
|
||||
let updated = 0;
|
||||
for (const item of items) {
|
||||
const existing = await tx.fileLibGrant.findFirst({
|
||||
where: {
|
||||
nodeId: node.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
if (existing !== null) {
|
||||
if (existing.role !== item.role) {
|
||||
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
|
||||
updated += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
|
||||
change: "update",
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
from: existing.role,
|
||||
to: item.role,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await tx.fileLibGrant.create({
|
||||
data: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: node.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
role: item.role,
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
granted += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
|
||||
change: "grant",
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
role: item.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
const grants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
|
||||
export async function setIndependentPermission(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ readonly enabled: boolean }> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireManage(deps, actor, nodeId, tx);
|
||||
if (node.kind !== "PROJECT") {
|
||||
throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
|
||||
}
|
||||
const current = await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
});
|
||||
if ((current?.independentPermissionsEnabled ?? false) === enabled) {
|
||||
return { enabled }; // 状态未变:空操作,不产生审计
|
||||
}
|
||||
await tx.fileLibProjectSettings.upsert({
|
||||
where: { nodeId: node.id },
|
||||
update: { independentPermissionsEnabled: enabled },
|
||||
create: { nodeId: node.id, independentPermissionsEnabled: enabled },
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: enabled
|
||||
? FILE_LIB_AUDIT_ACTIONS.independentEnable
|
||||
: FILE_LIB_AUDIT_ACTIONS.independentDisable,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "project",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { enabled },
|
||||
});
|
||||
return { enabled };
|
||||
});
|
||||
}
|
||||
|
||||
function validateGrantItems(items: readonly InitialGrant[]): void {
|
||||
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.principalType !== "USER" && item.principalType !== "GROUP") {
|
||||
throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`);
|
||||
}
|
||||
if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") {
|
||||
throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`);
|
||||
}
|
||||
if (item.principalId.trim() === "") {
|
||||
throw new FileLibError(400, "invalid_request", "principalId must not be empty");
|
||||
}
|
||||
const key = `${item.principalType}:${item.principalId}`;
|
||||
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(
|
||||
tx: Prisma.TransactionClient,
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
action: string,
|
||||
nodeId: string,
|
||||
pathIds: string,
|
||||
detail: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await writeFileLibAudit(tx, {
|
||||
action,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "grant",
|
||||
objectId: nodeId,
|
||||
objectPath: pathIds,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GroupResolver port(契约 C2)。
|
||||
*
|
||||
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
|
||||
* Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此
|
||||
* port,真身到位后替换实现,不换调用点。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface GroupResolver {
|
||||
resolveMemberGroupIds(userId: string): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
||||
* 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎
|
||||
* 的 Group 通路今天就是真的,而不是 mock。
|
||||
*/
|
||||
export function createTeamGroupResolver(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): GroupResolver {
|
||||
return {
|
||||
async resolveMemberGroupIds(userId) {
|
||||
const memberships = await prisma.teamMembership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
team: { organizationId, archivedAt: null },
|
||||
},
|
||||
select: { teamId: true },
|
||||
});
|
||||
return memberships.map((m) => m.teamId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 单测 mock:静态 用户→组 映射。 */
|
||||
export function createStaticGroupResolver(
|
||||
map: Readonly<Record<string, readonly string[]>>,
|
||||
): GroupResolver {
|
||||
return {
|
||||
async resolveMemberGroupIds(userId) {
|
||||
return map[userId] ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* GroupResolver 的 HTTP 实现(契约 C2,Group 团队服务到位后启用,
|
||||
* 经 HUB_GROUP_SERVICE_URL 配置)。
|
||||
*
|
||||
* 语义红线:
|
||||
* - 失败 → FileLibError(503, group_unavailable)。依赖故障不是"无权限",
|
||||
* 绝不伪装成 404/403(计划 D13)。
|
||||
* - 我方绝不自己推祖先:返回什么用什么,不在本地补逻辑。
|
||||
*/
|
||||
|
||||
import { FileLibError } from "./model.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
|
||||
export interface HttpGroupResolverConfig {
|
||||
readonly baseUrl: string;
|
||||
readonly timeoutMs?: number;
|
||||
/** 测试可注入假 fetch;生产用全局 fetch。 */
|
||||
readonly fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
export function createHttpGroupResolver(config: HttpGroupResolverConfig): GroupResolver {
|
||||
const timeoutMs = config.timeoutMs ?? 2_000;
|
||||
const fetchFn = config.fetchFn ?? fetch;
|
||||
return {
|
||||
async resolveMemberGroupIds(userId) {
|
||||
const url = `${config.baseUrl.replace(/\/$/, "")}/groups/resolve-member-groups?userId=${encodeURIComponent(userId)}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch (error) {
|
||||
throw new FileLibError(503, "group_unavailable", `group service unreachable: ${String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new FileLibError(503, "group_unavailable", `group service returned ${response.status}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new FileLibError(503, "group_unavailable", "group service returned malformed JSON");
|
||||
}
|
||||
const groupIds = (body as { groupIds?: unknown }).groupIds;
|
||||
if (!Array.isArray(groupIds) || groupIds.some((id) => typeof id !== "string")) {
|
||||
throw new FileLibError(503, "group_unavailable", "group service returned malformed payload");
|
||||
}
|
||||
return groupIds as readonly string[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 文件库 HTTP 门禁(契约 C4 的入驻适配)。
|
||||
*
|
||||
* 身份链:hub session(飞书 OAuth / dev bypass)→ silo org membership。
|
||||
* 网站管理员 = org 的 OWNER/ADMIN(D19:仅 root 创建与 force_adjust 特权,
|
||||
* 不给内容读旁路);普通成员 = 任何活跃 membership;非成员 = 403。
|
||||
*/
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
|
||||
import { requireSession, sendError } from "../../admin/auth/guards.js";
|
||||
import type { FileLibActor } from "./treeService.js";
|
||||
|
||||
export interface FileLibGuardDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
/** 文件库归属的 silo org(ADR-0020/0025)。 */
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
const WEBSITE_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
|
||||
|
||||
/** 每个 /database/api/* 端点第一行调它;返回 null 时响应已发出,fail closed。 */
|
||||
export async function requireFileLibActor(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: FileLibGuardDeps,
|
||||
): Promise<FileLibActor | null> {
|
||||
const auth = await requireSession(request, reply, {
|
||||
prisma: deps.prisma,
|
||||
sessionSecret: deps.sessionSecret,
|
||||
});
|
||||
if (auth === null) return null;
|
||||
|
||||
const membership = await deps.prisma.organizationMembership.findFirst({
|
||||
where: { organizationId: deps.organizationId, userId: auth.user.id, revokedAt: null },
|
||||
select: { role: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
await sendError(reply, 403, "forbidden", "not a member of this organization");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: auth.user.id,
|
||||
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 文件库领域基础:角色秩、命名规则(D14)、错误类型。
|
||||
*
|
||||
* 语义锚定:仓库根《文件库-接口契约.md》(2.2 权限等级 / D8 可见性 / D14 命名)
|
||||
* 与 .omo/文件库-开工计划.md。本模块是独立文件库,不复用 hub 的
|
||||
* Folder/Project/PermissionGrant 体系。
|
||||
*/
|
||||
|
||||
export const FILE_LIB_ROLES = ["VIEW", "EDIT", "MANAGE"] as const;
|
||||
export type FileLibRole = (typeof FILE_LIB_ROLES)[number];
|
||||
|
||||
/** 契约 2.2:MANAGE > EDIT > VIEW,严格全序。 */
|
||||
export const ROLE_RANK: Record<FileLibRole, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
|
||||
|
||||
export function roleAtLeast(role: FileLibRole, min: FileLibRole): boolean {
|
||||
return ROLE_RANK[role] >= ROLE_RANK[min];
|
||||
}
|
||||
|
||||
/** 业务错误。statusCode 由路由层映射为 HTTP 响应(D8 语义在此层只表达为 code)。 */
|
||||
export class FileLibError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
/** 结构化附加信息(如 409 时的 currentVersion),路由层并入错误响应。 */
|
||||
readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileLibError";
|
||||
}
|
||||
}
|
||||
|
||||
export const NODE_NAME_MAX_LENGTH = 128;
|
||||
|
||||
/** D14:禁 `/`;反斜杠同样禁止(它会变成存储路径的分隔符,且易用于伪装)。控制字符禁。 */
|
||||
const FORBIDDEN_NAME_CHARS = /[/\\\p{C}]/u;
|
||||
|
||||
/**
|
||||
* D14:NFC 归一化 + trim,然后校验长度与字符集。
|
||||
* 违规抛 FileLibError(400, "invalid_name"),路由层原样透传。
|
||||
*/
|
||||
export function normalizeNodeName(raw: string): string {
|
||||
const name = raw.normalize("NFC").trim();
|
||||
if (name.length === 0) {
|
||||
throw new FileLibError(400, "invalid_name", "name must not be empty");
|
||||
}
|
||||
if (name.length > NODE_NAME_MAX_LENGTH) {
|
||||
throw new FileLibError(400, "invalid_name", `name exceeds ${NODE_NAME_MAX_LENGTH} characters`);
|
||||
}
|
||||
if (FORBIDDEN_NAME_CHARS.test(name)) {
|
||||
throw new FileLibError(400, "invalid_name", "name must not contain '/', '\\' or control characters");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** D14:活跃兄弟节点大小写不敏感唯一的比较键(DB 层另有部分唯一索引兜底)。 */
|
||||
export function nameKey(normalizedName: string): string {
|
||||
return normalizedName.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 纯权限 reducer(契约 P6 / D11 / D8)。
|
||||
*
|
||||
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
|
||||
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
|
||||
* 存储做密集单测与随机化不变量测试。
|
||||
*/
|
||||
|
||||
import type { FileLibRole } from "./model.js";
|
||||
import { ROLE_RANK } from "./model.js";
|
||||
|
||||
export interface FileLibGrantFact {
|
||||
readonly nodeId: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: FileLibRole;
|
||||
readonly isCreatorGrant: boolean;
|
||||
}
|
||||
|
||||
export interface EffectiveRoleInput {
|
||||
/** 目标节点(self)。 */
|
||||
readonly nodeId: string;
|
||||
readonly nodeKind: "FOLDER" | "PROJECT";
|
||||
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
|
||||
readonly ancestorIds: readonly string[];
|
||||
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
|
||||
readonly independentPermissionsEnabled: boolean;
|
||||
readonly userId: string;
|
||||
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
|
||||
readonly groupIds: readonly string[];
|
||||
/** self ∪ ancestors 上的全部活跃 grant(revokedAt 已由获取层过滤)。 */
|
||||
readonly grants: readonly FileLibGrantFact[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约 P6:effective(user, R) = max { grant.role | s ∈ {user} ∪ groups*(user),
|
||||
* r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。
|
||||
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
|
||||
*
|
||||
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
|
||||
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
|
||||
* grant 不受开关影响。
|
||||
*/
|
||||
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
|
||||
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
|
||||
const groups = new Set(input.groupIds);
|
||||
const freezeProjectGrants =
|
||||
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
|
||||
|
||||
let best: FileLibRole | null = null;
|
||||
for (const grant of input.grants) {
|
||||
if (!onChain.has(grant.nodeId)) continue;
|
||||
if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
|
||||
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
|
||||
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
|
||||
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* D8 可见性语义:
|
||||
* - 完全无权限(effective === null)→ "not_found"(路由层映射 404,不泄露存在性);
|
||||
* - 有权限但不足 → "forbidden"(路由层映射 403)。
|
||||
*/
|
||||
export type AccessVerdict =
|
||||
| { readonly allowed: true; readonly role: FileLibRole }
|
||||
| { readonly allowed: false; readonly reason: "not_found" | "forbidden" };
|
||||
|
||||
export function checkAccess(effective: FileLibRole | null, min: FileLibRole): AccessVerdict {
|
||||
if (effective === null) return { allowed: false, reason: "not_found" };
|
||||
if (ROLE_RANK[effective] < ROLE_RANK[min]) return { allowed: false, reason: "forbidden" };
|
||||
return { allowed: true, role: effective };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* /database/api/* 路由共享件:依赖装配、actor 门禁、统一错误映射。
|
||||
* 约定(与 admin 面一致):绝对路径;guard 前置 fail closed;查询 scope 到 silo org。
|
||||
*/
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { FileLibError } from "./model.js";
|
||||
import { requireFileLibActor } from "./guards.js";
|
||||
import type { FileLibActor, TreeServiceDeps } from "./treeService.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
import type { VersionStore } from "./versionStore.js";
|
||||
import type { ExportAdapter } from "./exportService.js";
|
||||
|
||||
export interface FileLibRouteDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly organizationId: string;
|
||||
readonly storageRoot: string;
|
||||
readonly groupResolver: GroupResolver;
|
||||
readonly versionStore: VersionStore;
|
||||
readonly exportAdapters: readonly ExportAdapter[];
|
||||
}
|
||||
|
||||
/** 组装 treeService 依赖(路由处理内直接使用)。 */
|
||||
export function treeDeps(deps: FileLibRouteDeps): TreeServiceDeps {
|
||||
return {
|
||||
prisma: deps.prisma,
|
||||
groupResolver: deps.groupResolver,
|
||||
versionStore: deps.versionStore,
|
||||
organizationId: deps.organizationId,
|
||||
storageRoot: deps.storageRoot,
|
||||
};
|
||||
}
|
||||
|
||||
/** 端点第一行调用;null = 响应已发(401/403),fail closed。 */
|
||||
export async function actorOrNull(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: FileLibRouteDeps,
|
||||
): Promise<FileLibActor | null> {
|
||||
return requireFileLibActor(request, reply, {
|
||||
prisma: deps.prisma,
|
||||
sessionSecret: deps.sessionSecret,
|
||||
organizationId: deps.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 统一错误出口:FileLibError → 语义码;P2002 → 409;其余 → 500(不泄露内部)。 */
|
||||
export async function sendRouteError(reply: FastifyReply, error: unknown): Promise<void> {
|
||||
if (error instanceof FileLibError) {
|
||||
await reply.status(error.statusCode).send({
|
||||
error: { code: error.code, message: error.message, ...(error.details ?? {}) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
await reply.status(409).send({ error: { code: "conflict", message: "uniqueness conflict" } });
|
||||
return;
|
||||
}
|
||||
reply.log.error({ err: error }, "filelib route: unexpected error");
|
||||
await reply.status(500).send({ error: { code: "internal", message: "internal error" } });
|
||||
}
|
||||
|
||||
/** 请求体轻量校验(抛 FileLibError 400)。 */
|
||||
export function bodyObject(body: unknown): Record<string, unknown> {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new FileLibError(400, "invalid_request", "request body must be a JSON object");
|
||||
}
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function requireString(obj: Record<string, unknown>, key: string): string {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "string" || value === "") {
|
||||
throw new FileLibError(400, "invalid_request", `missing or invalid field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", `invalid field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。
|
||||
*
|
||||
* 语义锚定:
|
||||
* - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess)
|
||||
* - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts
|
||||
* - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环
|
||||
* - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底)
|
||||
* - D15 删除只打标本节点;"任一祖先已删"即整支不可见
|
||||
* - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字
|
||||
* - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径,
|
||||
* rename 不重写后代;move 用一次前缀重写维护)
|
||||
*
|
||||
* 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建
|
||||
* 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走
|
||||
* effectiveRole,没有 admin 旁路。
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClient, FileLibNode } from "@prisma/client";
|
||||
import {
|
||||
FileLibError,
|
||||
nameKey,
|
||||
normalizeNodeName,
|
||||
type FileLibRole,
|
||||
} from "./model.js";
|
||||
import { checkAccess, effectiveRole } from "./permission.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
import type { VersionStore } from "./versionStore.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
|
||||
export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly groupResolver: GroupResolver;
|
||||
readonly versionStore: VersionStore;
|
||||
/** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */
|
||||
readonly organizationId: string;
|
||||
/** 项目 git 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
|
||||
readonly storageRoot: string;
|
||||
}
|
||||
|
||||
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
|
||||
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
|
||||
|
||||
export interface InitialGrant {
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: FileLibRole;
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
interface Chain {
|
||||
readonly node: FileLibNode;
|
||||
/** 根在前、直接父在后;不含 node 自身。 */
|
||||
readonly ancestors: readonly FileLibNode[];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部工具 */
|
||||
|
||||
/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */
|
||||
function ancestorIdsOf(node: FileLibNode): string[] {
|
||||
return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1);
|
||||
}
|
||||
|
||||
/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */
|
||||
async function loadVisibleChain(
|
||||
tx: Tx,
|
||||
organizationId: string,
|
||||
nodeId: string,
|
||||
): Promise<Chain> {
|
||||
const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } });
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
const ancestorIds = ancestorIdsOf(node);
|
||||
const ancestors = ancestorIds.length === 0
|
||||
? []
|
||||
: await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } });
|
||||
if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) {
|
||||
// D15:已删子树对外"不存在"(D8 不泄露)。
|
||||
throw new FileLibError(404, "node_not_found", "node not found");
|
||||
}
|
||||
const byId = new Map(ancestors.map((a) => [a.id, a]));
|
||||
const ordered = ancestorIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((a): a is FileLibNode => a !== undefined);
|
||||
return { node, ancestors: ordered };
|
||||
}
|
||||
|
||||
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
|
||||
async function resolveRole(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
actor: FileLibActor,
|
||||
chain: Chain,
|
||||
): Promise<FileLibRole | null> {
|
||||
const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id];
|
||||
const grants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
let independentPermissionsEnabled = false;
|
||||
if (chain.node.kind === "PROJECT") {
|
||||
const settings = await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: chain.node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
});
|
||||
independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
|
||||
}
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
return effectiveRole({
|
||||
nodeId: chain.node.id,
|
||||
nodeKind: chain.node.kind,
|
||||
ancestorIds: chain.ancestors.map((a) => a.id),
|
||||
independentPermissionsEnabled,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants,
|
||||
});
|
||||
}
|
||||
|
||||
/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */
|
||||
async function requireAccess(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
minRole: FileLibRole,
|
||||
): Promise<Chain & { readonly role: FileLibRole }> {
|
||||
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
||||
const role = await resolveRole(tx, deps, actor, chain);
|
||||
const verdict = checkAccess(role, minRole);
|
||||
if (!verdict.allowed) {
|
||||
throw verdict.reason === "not_found"
|
||||
? new FileLibError(404, "node_not_found", "node not found")
|
||||
: new FileLibError(403, "forbidden", `requires ${minRole}`);
|
||||
}
|
||||
return { ...chain, role: verdict.role };
|
||||
}
|
||||
|
||||
/**
|
||||
* 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做
|
||||
* 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。
|
||||
*/
|
||||
export async function requireAccessInTx(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
minRole: FileLibRole,
|
||||
): Promise<Chain & { readonly role: FileLibRole }> {
|
||||
return requireAccess(tx, deps, actor, nodeId, minRole);
|
||||
}
|
||||
|
||||
/** P2002(活跃兄弟名部分唯一索引)→ 409。 */
|
||||
function rethrowNameConflict(error: unknown, name: string): never {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string {
|
||||
const table = kind === "PROJECT"
|
||||
? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete }
|
||||
: { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete };
|
||||
return table[verb];
|
||||
}
|
||||
|
||||
function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const grant of grants) {
|
||||
const key = `${grant.principalType}:${grant.principalId}`;
|
||||
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`);
|
||||
seen.add(key);
|
||||
if (grant.principalType === "USER" && grant.principalId === actor.userId) {
|
||||
throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant");
|
||||
}
|
||||
// v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 公共操作 */
|
||||
|
||||
export interface CreateNodeInput {
|
||||
readonly parentId: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
readonly name: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly grants?: readonly InitialGrant[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。
|
||||
* creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。
|
||||
*/
|
||||
export async function createNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
input: CreateNodeInput,
|
||||
): Promise<FileLibNode> {
|
||||
const name = normalizeNodeName(input.name);
|
||||
const initialGrants = input.grants ?? [];
|
||||
validateInitialGrants(actor, initialGrants);
|
||||
|
||||
const id = randomUUID();
|
||||
let pathIds: string;
|
||||
let storageDir: string | null = null;
|
||||
|
||||
const created = await deps.prisma.$transaction(async (tx) => {
|
||||
if (input.parentId === null) {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "root creation requires website administrator");
|
||||
}
|
||||
pathIds = `/${id}`;
|
||||
} else {
|
||||
const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT");
|
||||
if (parent.node.kind !== "FOLDER") {
|
||||
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
|
||||
}
|
||||
pathIds = `${parent.node.pathIds}/${id}`;
|
||||
}
|
||||
|
||||
if (input.kind === "PROJECT") {
|
||||
storageDir = path.join(deps.storageRoot, id);
|
||||
}
|
||||
|
||||
let node: FileLibNode;
|
||||
try {
|
||||
node = await tx.fileLibNode.create({
|
||||
data: {
|
||||
id,
|
||||
organizationId: deps.organizationId,
|
||||
parentId: input.parentId,
|
||||
kind: input.kind,
|
||||
name,
|
||||
nameLower: nameKey(name),
|
||||
pathIds,
|
||||
creatorId: actor.userId,
|
||||
provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY",
|
||||
storageDir,
|
||||
...(input.description !== undefined && input.description.trim() !== ""
|
||||
? { description: input.description.trim() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNameConflict(error, name);
|
||||
}
|
||||
|
||||
await tx.fileLibGrant.create({
|
||||
data: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: id,
|
||||
principalType: "USER",
|
||||
principalId: actor.userId,
|
||||
role: "MANAGE",
|
||||
isCreatorGrant: true,
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
for (const grant of initialGrants) {
|
||||
await tx.fileLibGrant.create({
|
||||
data: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: id,
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
role: grant.role,
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.kind === "PROJECT") {
|
||||
await tx.fileLibProjectSettings.create({
|
||||
data: { nodeId: id, independentPermissionsEnabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(input.kind, "Create"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: input.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: id,
|
||||
objectPath: pathIds,
|
||||
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
|
||||
});
|
||||
for (const grant of initialGrants) {
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "grant",
|
||||
objectId: id,
|
||||
objectPath: pathIds,
|
||||
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
||||
});
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
|
||||
if (input.kind === "PROJECT" && storageDir !== null) {
|
||||
try {
|
||||
await deps.versionStore.init(storageDir);
|
||||
return await deps.prisma.fileLibNode.update({
|
||||
where: { id: created.id },
|
||||
data: { provisionStatus: "READY" },
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.prisma.fileLibNode
|
||||
.update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } })
|
||||
.catch(() => undefined);
|
||||
throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */
|
||||
export async function renameNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
rawName: string,
|
||||
): Promise<FileLibNode> {
|
||||
const name = normalizeNodeName(rawName);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
let updated: FileLibNode;
|
||||
try {
|
||||
updated = await tx.fileLibNode.update({
|
||||
where: { id: node.id },
|
||||
data: { name, nameLower: nameKey(name) },
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNameConflict(error, name);
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Rename"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { from: node.name, to: name },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员);
|
||||
* 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。
|
||||
*/
|
||||
export async function moveNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
newParentId: string | null,
|
||||
): Promise<FileLibNode> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`;
|
||||
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
if (node.parentId === newParentId) return node;
|
||||
|
||||
let newPathIds: string;
|
||||
if (newParentId === null) {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "moving to root requires website administrator");
|
||||
}
|
||||
newPathIds = `/${node.id}`;
|
||||
} else {
|
||||
const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT");
|
||||
if (parent.node.kind !== "FOLDER") {
|
||||
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
|
||||
}
|
||||
if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) {
|
||||
throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree");
|
||||
}
|
||||
newPathIds = `${parent.node.pathIds}/${node.id}`;
|
||||
}
|
||||
|
||||
const oldPrefix = node.pathIds;
|
||||
let updated: FileLibNode;
|
||||
try {
|
||||
updated = await tx.fileLibNode.update({
|
||||
where: { id: node.id },
|
||||
data: { parentId: newParentId, pathIds: newPathIds },
|
||||
});
|
||||
// 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。
|
||||
await tx.$executeRaw`
|
||||
UPDATE "FileLibNode"
|
||||
SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int)
|
||||
WHERE "organizationId" = ${deps.organizationId}
|
||||
AND "pathIds" LIKE ${oldPrefix + "/%"}
|
||||
`;
|
||||
} catch (error) {
|
||||
rethrowNameConflict(error, node.name);
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Move"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: newPathIds,
|
||||
detail: { fromParentId: node.parentId, toParentId: newParentId },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */
|
||||
export async function softDeleteNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<void> {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Delete"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */
|
||||
export async function getEffectiveRole(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<FileLibRole | null> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
||||
const role = await resolveRole(tx, deps, actor, chain);
|
||||
if (role === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
return role;
|
||||
});
|
||||
}
|
||||
|
||||
export interface BreadcrumbEntry {
|
||||
readonly depth: number;
|
||||
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
|
||||
readonly id: string | null;
|
||||
readonly name: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
}
|
||||
|
||||
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
|
||||
export async function breadcrumb(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<readonly BreadcrumbEntry[]> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
||||
const selfRole = await resolveRole(tx, deps, actor, chain);
|
||||
if (checkAccess(selfRole, "VIEW").allowed !== true) {
|
||||
throw new FileLibError(404, "node_not_found", "node not found");
|
||||
}
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
const chainNodes = [...chain.ancestors, chain.node];
|
||||
const chainIds = chainNodes.map((n) => n.id);
|
||||
const allGrants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
const settings = chain.node.kind === "PROJECT"
|
||||
? await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: chain.node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return chainNodes.map((current, depth) => {
|
||||
const role = effectiveRole({
|
||||
nodeId: current.id,
|
||||
nodeKind: current.kind,
|
||||
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
|
||||
independentPermissionsEnabled:
|
||||
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
});
|
||||
const visible = role !== null;
|
||||
return {
|
||||
depth,
|
||||
id: visible ? current.id : null,
|
||||
name: visible ? current.name : null,
|
||||
kind: current.kind,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface ChildNodeDto {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
readonly name: string;
|
||||
readonly role: FileLibRole;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */
|
||||
export async function listChildren(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
parentId: string | null,
|
||||
): Promise<readonly ChildNodeDto[]> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
let parentAncestorIds: string[] = [];
|
||||
if (parentId !== null) {
|
||||
const parent = await requireAccess(tx, deps, actor, parentId, "VIEW");
|
||||
parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id];
|
||||
}
|
||||
const children = await tx.fileLibNode.findMany({
|
||||
where: { organizationId: deps.organizationId, parentId, deletedAt: null },
|
||||
orderBy: [{ kind: "asc" }, { nameLower: "asc" }],
|
||||
});
|
||||
if (children.length === 0) return [];
|
||||
|
||||
// D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。
|
||||
const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)];
|
||||
const allGrants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
|
||||
const settingsRows = projectIds.length === 0
|
||||
? []
|
||||
: await tx.fileLibProjectSettings.findMany({
|
||||
where: { nodeId: { in: projectIds } },
|
||||
select: { nodeId: true, independentPermissionsEnabled: true },
|
||||
});
|
||||
const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
|
||||
const out: ChildNodeDto[] = [];
|
||||
for (const child of children) {
|
||||
const role = effectiveRole({
|
||||
nodeId: child.id,
|
||||
nodeKind: child.kind,
|
||||
ancestorIds: parentAncestorIds,
|
||||
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
});
|
||||
if (role === null) continue;
|
||||
out.push({
|
||||
id: child.id,
|
||||
parentId: child.parentId,
|
||||
kind: child.kind,
|
||||
name: child.name,
|
||||
role,
|
||||
createdAt: child.createdAt,
|
||||
updatedAt: child.updatedAt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */
|
||||
export async function updateNodeDescription(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
description: string | null,
|
||||
): Promise<FileLibNode> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT");
|
||||
return tx.fileLibNode.update({
|
||||
where: { id: node.id },
|
||||
data: { description: description?.trim() || null },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* VersionStore port(契约 C1)+ 开发用内存实现。
|
||||
*
|
||||
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
|
||||
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
|
||||
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
|
||||
*
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
|
||||
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { FileLibError } from "./model.js";
|
||||
|
||||
export type VersionId = string;
|
||||
|
||||
export interface CommitRequest {
|
||||
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
|
||||
readonly baseVersion: VersionId | null;
|
||||
readonly content: string | Buffer;
|
||||
readonly message?: string | undefined;
|
||||
readonly author?: string | undefined;
|
||||
}
|
||||
|
||||
export type CommitResult =
|
||||
| { readonly status: "ok"; readonly version: VersionId }
|
||||
| { readonly status: "conflict"; readonly currentVersion: VersionId };
|
||||
|
||||
export interface VersionInfo {
|
||||
readonly version: VersionId;
|
||||
readonly message: string;
|
||||
readonly author: string | undefined;
|
||||
readonly committedAt: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface VersionStore {
|
||||
init(projectDir: string): Promise<void>;
|
||||
list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
|
||||
head(projectDir: string, filePath: string): Promise<VersionId>;
|
||||
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
|
||||
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
|
||||
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
|
||||
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
|
||||
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
|
||||
}
|
||||
|
||||
interface StoredVersion {
|
||||
readonly version: VersionId;
|
||||
readonly content: Buffer;
|
||||
readonly message: string;
|
||||
readonly author: string | undefined;
|
||||
readonly committedAt: string;
|
||||
readonly deleted: boolean;
|
||||
}
|
||||
|
||||
interface Repo {
|
||||
/** 每文件一条版本链(D16:版本是文件级的,互不干扰)。 */
|
||||
readonly files: Map<string, StoredVersion[]>;
|
||||
counter: number;
|
||||
}
|
||||
|
||||
/** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */
|
||||
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
|
||||
const tails = new Map<string, Promise<unknown>>();
|
||||
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
|
||||
const prev = tails.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
tails.set(key, next.catch(() => undefined));
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
function toBuffer(content: string | Buffer): Buffer {
|
||||
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
||||
}
|
||||
|
||||
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
|
||||
function naiveDiff(fromText: string, toText: string): string {
|
||||
const a = fromText.split("\n");
|
||||
const b = toText.split("\n");
|
||||
const out: string[] = ["--- a", "+++ b"];
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const al = a[i];
|
||||
const bl = b[i];
|
||||
if (al === bl) {
|
||||
if (al !== undefined) out.push(` ${al}`);
|
||||
} else {
|
||||
if (al !== undefined) out.push(`-${al}`);
|
||||
if (bl !== undefined) out.push(`+${bl}`);
|
||||
}
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
const repos = new Map<string, Repo>();
|
||||
const serialize = createKeySerializer();
|
||||
|
||||
/* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */
|
||||
interface PersistedVersion extends Omit<StoredVersion, "content"> {
|
||||
content: string; // base64
|
||||
}
|
||||
function loadPersisted(): void {
|
||||
if (persistPath === undefined) return;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(persistPath, "utf8")) as {
|
||||
repos: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }>;
|
||||
};
|
||||
for (const [dir, repo] of Object.entries(raw.repos)) {
|
||||
const files = new Map<string, StoredVersion[]>();
|
||||
for (const [filePath, versions] of Object.entries(repo.files)) {
|
||||
files.set(filePath, versions.map((v) => ({ ...v, content: Buffer.from(v.content, "base64") })));
|
||||
}
|
||||
repos.set(dir, { files, counter: repo.counter });
|
||||
}
|
||||
} catch { /* 无快照或损坏 → 空库起步(开发语义) */ }
|
||||
}
|
||||
function savePersisted(): void {
|
||||
if (persistPath === undefined) return;
|
||||
const out: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }> = {};
|
||||
for (const [dir, repo] of repos) {
|
||||
const files: Record<string, PersistedVersion[]> = {};
|
||||
for (const [filePath, versions] of repo.files) {
|
||||
files[filePath] = versions.map((v) => ({ ...v, content: v.content.toString("base64") }));
|
||||
}
|
||||
out[dir] = { counter: repo.counter, files };
|
||||
}
|
||||
try {
|
||||
mkdirSync(path.dirname(persistPath), { recursive: true });
|
||||
const tmp = `${persistPath}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify({ repos: out }), "utf8");
|
||||
renameSync(tmp, persistPath);
|
||||
} catch { /* 快照失败不影响开发使用 */ }
|
||||
}
|
||||
loadPersisted();
|
||||
|
||||
function requireRepo(projectDir: string): Repo {
|
||||
const repo = repos.get(projectDir);
|
||||
if (repo === undefined) {
|
||||
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
function liveVersion(repo: Repo, filePath: string): StoredVersion {
|
||||
const chain = repo.files.get(filePath);
|
||||
const latest = chain?.[chain.length - 1];
|
||||
if (chain === undefined || latest === undefined || latest.deleted) {
|
||||
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function findVersion(repo: Repo, filePath: string, version: VersionId): StoredVersion {
|
||||
const found = repo.files.get(filePath)?.find((v) => v.version === version);
|
||||
if (found === undefined) {
|
||||
throw new FileLibError(404, "version_not_found", `version not found: ${filePath}@${version}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
return {
|
||||
async init(projectDir) {
|
||||
await serialize(projectDir, async () => {
|
||||
// S7:幂等,重复调用不报错、不重建。
|
||||
const created = repos.get(projectDir) === undefined;
|
||||
repos.set(projectDir, repos.get(projectDir) ?? { files: new Map(), counter: 0 });
|
||||
if (created) savePersisted();
|
||||
});
|
||||
},
|
||||
|
||||
async list(projectDir, prefix) {
|
||||
const repo = requireRepo(projectDir);
|
||||
const out: FileEntry[] = [];
|
||||
for (const [path, chain] of repo.files) {
|
||||
const latest = chain[chain.length - 1];
|
||||
if (latest === undefined || latest.deleted) continue;
|
||||
if (prefix !== undefined && !path.startsWith(prefix)) continue;
|
||||
out.push({ path, size: latest.content.byteLength });
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
},
|
||||
|
||||
async head(projectDir, filePath) {
|
||||
return liveVersion(requireRepo(projectDir), filePath).version;
|
||||
},
|
||||
|
||||
async read(projectDir, filePath, at) {
|
||||
const repo = requireRepo(projectDir);
|
||||
if (at !== undefined) return findVersion(repo, filePath, at).content;
|
||||
return liveVersion(repo, filePath).content;
|
||||
},
|
||||
|
||||
async commit(projectDir, filePath, req) {
|
||||
return serialize(projectDir, async (): Promise<CommitResult> => {
|
||||
const repo = requireRepo(projectDir);
|
||||
const chain = repo.files.get(filePath) ?? [];
|
||||
const latest = chain[chain.length - 1];
|
||||
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
|
||||
|
||||
// S2:新建(baseVersion null)要求文件当前不存在;否则要求 baseVersion 精确等于当前版本(S1)。
|
||||
if (req.baseVersion === null) {
|
||||
if (currentVersion !== null) return { status: "conflict", currentVersion };
|
||||
} else if (req.baseVersion !== currentVersion) {
|
||||
return {
|
||||
status: "conflict",
|
||||
currentVersion: currentVersion ?? req.baseVersion,
|
||||
};
|
||||
}
|
||||
|
||||
repo.counter += 1;
|
||||
const version = `v${repo.counter}`;
|
||||
chain.push({
|
||||
version,
|
||||
content: toBuffer(req.content),
|
||||
message: req.message ?? `commit ${version}`,
|
||||
author: req.author,
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: false,
|
||||
});
|
||||
repo.files.set(filePath, chain);
|
||||
savePersisted();
|
||||
return { status: "ok", version };
|
||||
});
|
||||
},
|
||||
|
||||
async remove(projectDir, filePath, baseVersion) {
|
||||
return serialize(projectDir, async (): Promise<CommitResult> => {
|
||||
const repo = requireRepo(projectDir);
|
||||
const chain = repo.files.get(filePath) ?? [];
|
||||
const latest = chain[chain.length - 1];
|
||||
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
|
||||
if (currentVersion === null) {
|
||||
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
|
||||
}
|
||||
if (baseVersion !== currentVersion) return { status: "conflict", currentVersion };
|
||||
repo.counter += 1;
|
||||
const version = `v${repo.counter}`;
|
||||
chain.push({
|
||||
version,
|
||||
content: Buffer.alloc(0),
|
||||
message: `remove ${filePath}`,
|
||||
author: undefined,
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: true,
|
||||
});
|
||||
repo.files.set(filePath, chain);
|
||||
savePersisted();
|
||||
return { status: "ok", version };
|
||||
});
|
||||
},
|
||||
|
||||
async diff(projectDir, filePath, from, to) {
|
||||
const repo = requireRepo(projectDir);
|
||||
const a = findVersion(repo, filePath, from);
|
||||
const b = findVersion(repo, filePath, to);
|
||||
return naiveDiff(a.content.toString("utf8"), b.content.toString("utf8"));
|
||||
},
|
||||
|
||||
async history(projectDir, filePath, limit) {
|
||||
const repo = requireRepo(projectDir);
|
||||
const chain = repo.files.get(filePath) ?? [];
|
||||
const infos: VersionInfo[] = chain.map((v) => ({
|
||||
version: v.version,
|
||||
message: v.message,
|
||||
author: v.author,
|
||||
committedAt: v.committedAt,
|
||||
}));
|
||||
const ordered = infos.reverse();
|
||||
return limit !== undefined ? ordered.slice(0, limit) : ordered;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user