forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* /database/api/* 文件内容与导出端点(契约 9.3/9.4)。
|
||||
* 冲突流:POST commits 返回 200 {version} 或 409 {currentVersion}(编辑 UI 拉 diff 后重提)。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import {
|
||||
commitFile,
|
||||
deleteFile,
|
||||
diffFile,
|
||||
fileHistory,
|
||||
listFiles,
|
||||
readFile,
|
||||
readFileRaw,
|
||||
type FileContentEncoding,
|
||||
} from "../filelib/fileService.js";
|
||||
import {
|
||||
createManifestStubAdapter,
|
||||
downloadExport,
|
||||
getExportJob,
|
||||
submitExport,
|
||||
} from "../filelib/exportService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
actorOrNull,
|
||||
bodyObject,
|
||||
optionalString,
|
||||
requireString,
|
||||
sendRouteError,
|
||||
type FileLibRouteDeps,
|
||||
} from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerFileRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: FileLibRouteDeps,
|
||||
): Promise<void> {
|
||||
const fileDeps = {
|
||||
prisma: deps.prisma,
|
||||
organizationId: deps.organizationId,
|
||||
groupResolver: deps.groupResolver,
|
||||
versionStore: deps.versionStore,
|
||||
};
|
||||
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
|
||||
|
||||
function pathParam(request: FastifyRequest): string {
|
||||
const path = (request.query as { path?: string }).path;
|
||||
if (path === undefined) throw new FileLibError(400, "invalid_request", "missing query: path");
|
||||
return path;
|
||||
}
|
||||
|
||||
function encodingParam(raw: unknown): FileContentEncoding {
|
||||
if (raw === undefined || raw === "utf8") return "utf8";
|
||||
if (raw === "base64") return "base64";
|
||||
throw new FileLibError(400, "invalid_request", "encoding must be utf8 or base64");
|
||||
}
|
||||
|
||||
app.get("/database/api/projects/:id/files", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const prefix = (request.query as { prefix?: string }).prefix;
|
||||
return { files: await listFiles(fileDeps, actor, id, prefix) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/file", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return await readFile(fileDeps, actor, id, pathParam(request));
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 原始字节下载(Content-Disposition: attachment;浏览器直接 save-as)
|
||||
app.get("/database/api/projects/:id/file/raw", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const raw = await readFileRaw(fileDeps, actor, id, pathParam(request));
|
||||
return reply
|
||||
.header("Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(raw.filename)}"; filename*=UTF-8''${encodeURIComponent(raw.filename)}`)
|
||||
.header("X-Content-Version", raw.version)
|
||||
.type("application/octet-stream")
|
||||
.send(raw.buffer);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 新建/上传(baseVersion 恒 null;已存在 → 409)
|
||||
app.put("/database/api/projects/:id/file", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const result = await commitFile(fileDeps, actor, id, {
|
||||
path: requireString(body, "path"),
|
||||
baseVersion: null,
|
||||
content: requireString(body, "content"),
|
||||
encoding: encodingParam(body["encoding"]),
|
||||
message: optionalString(body, "message"),
|
||||
});
|
||||
return reply.status(201).send(result);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 提交编辑(乐观并发;409 → details.currentVersion + file.conflict_detected 审计)
|
||||
app.post("/database/api/projects/:id/file/commits", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const baseVersion = body["baseVersion"];
|
||||
if (typeof baseVersion !== "string" || baseVersion === "") {
|
||||
throw new FileLibError(400, "invalid_request", "baseVersion must be a non-empty string");
|
||||
}
|
||||
return await commitFile(fileDeps, actor, id, {
|
||||
path: requireString(body, "path"),
|
||||
baseVersion,
|
||||
content: requireString(body, "content"),
|
||||
encoding: encodingParam(body["encoding"]),
|
||||
message: optionalString(body, "message"),
|
||||
});
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/projects/:id/file", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
await deleteFile(fileDeps, actor, id, pathParam(request), requireString(body, "baseVersion"));
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/file/diff", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const query = request.query as { from?: string; to?: string };
|
||||
if (query.from === undefined || query.to === undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "missing query: from / to");
|
||||
}
|
||||
return await diffFile(fileDeps, actor, id, pathParam(request), query.from, query.to);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/file/history", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const rawLimit = (request.query as { limit?: string }).limit;
|
||||
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
|
||||
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
|
||||
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
|
||||
}
|
||||
return { history: await fileHistory(fileDeps, actor, id, pathParam(request), limit) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 导出(D10 异步) */
|
||||
|
||||
app.post("/database/api/projects/:id/exports", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const params = body["params"];
|
||||
if (params !== undefined && (typeof params !== "object" || params === null || Array.isArray(params))) {
|
||||
throw new FileLibError(400, "invalid_request", "params must be an object");
|
||||
}
|
||||
const job = await submitExport(
|
||||
exportDeps,
|
||||
actor,
|
||||
id,
|
||||
requireString(body, "target"),
|
||||
(params as Record<string, unknown> | undefined) ?? {},
|
||||
);
|
||||
return reply.status(202).send({ jobId: job.id, status: job.status });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/exports/:jobId", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
return await getExportJob(exportDeps, actor, jobId);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/exports/:jobId/download", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
const artifact = await downloadExport(exportDeps, actor, jobId);
|
||||
return reply
|
||||
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
||||
.type("application/octet-stream")
|
||||
.send(artifact.content);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user