forked from EduCraft/curriculum-project-hub
feat(filelib): 导出改为下载 cph 编译的真实 PDF
This commit is contained in:
@@ -51,8 +51,18 @@ HUB_SYSTEMD_UNIT="cph-hub-example.service"
|
||||
|
||||
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
|
||||
# the file to be executable and `cph --version` to succeed.
|
||||
#
|
||||
# Always set this explicitly. `cph` is also the command name of the unrelated
|
||||
# PyPI package conda-package-handling, so on any host with miniconda on PATH a
|
||||
# bare `cph` resolves to the wrong tool and exports fail with an argparse
|
||||
# "invalid choice: 'build'" that gives no hint about the name collision.
|
||||
CPH_BIN="/usr/local/bin/cph"
|
||||
|
||||
# The `cph-render` typst package directory (the folder holding lib.typ /
|
||||
# typst.toml). Needed by PDF export: when unset, cph falls back to resolving the
|
||||
# repo-relative `render/`, which does not exist in a deployed layout.
|
||||
CPH_RENDER_DIR="/opt/curriculum-project-hub/render"
|
||||
|
||||
# Hub bind address and port. Production defaults to loopback for a local TLS
|
||||
# reverse proxy; both values are validated and honored by the HTTP server.
|
||||
HOST="127.0.0.1"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
|
||||
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
|
||||
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
|
||||
download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import { currentNode } from "./browser.js";
|
||||
import type { ExportJob, NodeDetail } from "./types.js";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
let { node }: { node: NodeDetail } = $props();
|
||||
|
||||
let showEditDesc = $state(false);
|
||||
let descDraft = $state("");
|
||||
let exportJob = $state<ExportJob | null>(null);
|
||||
let exporting = $state(false);
|
||||
|
||||
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
||||
const canManage = $derived(node.role === "MANAGE");
|
||||
@@ -34,6 +36,7 @@
|
||||
$effect(() => {
|
||||
void node.id;
|
||||
exportJob = null;
|
||||
exporting = false;
|
||||
});
|
||||
|
||||
function openEditDesc(): void {
|
||||
@@ -57,31 +60,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExport(): Promise<void> {
|
||||
/**
|
||||
* 导出 PDF:提交 job → 轮询 → 完成即自动触发浏览器下载。
|
||||
*
|
||||
* 只有一个导出目标,所以不给目标选择器 —— target 由后端 adapter 固定。
|
||||
* 下载走 <a download> 而非 fetch+blob:接口是 same-origin cookie 认证,
|
||||
* 浏览器直接带上会话,不需要在 JS 里搬一遍字节。
|
||||
*/
|
||||
async function exportPdf(): Promise<void> {
|
||||
if (exporting) return;
|
||||
exporting = true;
|
||||
exportJob = null;
|
||||
try {
|
||||
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
|
||||
method: "POST",
|
||||
body: { target: "manifest" },
|
||||
body: { target: "pdf" },
|
||||
});
|
||||
toastOk("导出已提交");
|
||||
void pollExport(r.jobId);
|
||||
await pollExport(r.jobId);
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollExport(jobId: string): Promise<void> {
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
let job: ExportJob;
|
||||
try {
|
||||
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||
job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||
} catch (e) {
|
||||
exporting = false;
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
exportJob = job;
|
||||
if (job.status === "DONE" || job.status === "FAILED") break;
|
||||
} catch {
|
||||
break;
|
||||
if (job.status === "DONE") {
|
||||
exporting = false;
|
||||
toastOk("导出完成,开始下载");
|
||||
triggerDownload(`/database/api/exports/${job.id}/download`);
|
||||
return;
|
||||
}
|
||||
if (job.status === "FAILED") {
|
||||
exporting = false;
|
||||
toastErr(`导出失败:${job.error ?? "未知原因"}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(url: string): void {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
@@ -124,18 +159,16 @@
|
||||
|
||||
<div class="section-title mb-2">导出</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
|
||||
<button class="btn" onclick={submitExport}>开始导出</button>
|
||||
{#if exportJob}
|
||||
<button class="btn" onclick={exportPdf} disabled={exporting}>
|
||||
<Icon name="download" size={13} />
|
||||
{exporting ? "导出中…" : "导出 PDF"}
|
||||
</button>
|
||||
{#if exportJob?.status === "DONE"}
|
||||
<span class="file-meta">
|
||||
{#if exportJob.status === "DONE"}
|
||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
|
||||
{:else if exportJob.status === "FAILED"}
|
||||
失败:{exportJob.error ?? ""}
|
||||
{:else}
|
||||
{exportJob.status}…
|
||||
{/if}
|
||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
|
||||
</span>
|
||||
{:else if exportJob?.status === "FAILED"}
|
||||
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
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";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ExportAdapterInput {
|
||||
readonly storageDir: string;
|
||||
readonly target: string;
|
||||
@@ -49,8 +56,146 @@ export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]
|
||||
};
|
||||
}
|
||||
|
||||
/** `cph` 可执行文件位置;生产由 preflight 校验为绝对路径(见 deployment/preflight.ts)。 */
|
||||
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
|
||||
/** typst 渲染包目录;未设时由 cph 自行解析(仓库内 `render/`)。 */
|
||||
const CPH_RENDER_DIR = process.env["CPH_RENDER_DIR"];
|
||||
const CPH_BUILD_TIMEOUT_MS = 120_000;
|
||||
/**
|
||||
* 默认导出 target。
|
||||
*
|
||||
* UI 只给一个「导出 PDF」按钮,不让用户选 target;`student` 是 cph 自身在
|
||||
* 工程文件未声明 `[targets.*]` 时的默认值(cph-check DEFAULT_TARGET),
|
||||
* 与之保持一致,避免两端各有一套默认。
|
||||
*/
|
||||
const DEFAULT_PDF_TARGET = "student";
|
||||
|
||||
/**
|
||||
* 真导出适配器:把项目物化到临时目录,跑 `cph build`,取回 PDF 字节。
|
||||
*
|
||||
* 为什么不直接在 `storageDir`(git worktree)里跑构建:那是项目的版本库工作区,
|
||||
* 构建产物会变成未跟踪文件混进去,后续 `list`/`commit` 的语义会被污染。
|
||||
* 物化到临时目录让构建对版本库完全无副作用,代价是一次文件拷贝。
|
||||
*/
|
||||
export function createCphPdfAdapter(): ExportAdapter {
|
||||
return {
|
||||
target: "pdf",
|
||||
async run(input) {
|
||||
await requireCourswareCph();
|
||||
const target = typeof input.params["target"] === "string" ? input.params["target"] : DEFAULT_PDF_TARGET;
|
||||
const workDir = await mkdtemp(path.join(tmpdir(), "cph-export-"));
|
||||
try {
|
||||
await materialize(input, workDir);
|
||||
const outRel = path.join("build", `${target}.pdf`);
|
||||
const args = ["build", ".", "--target", target, "-o", outRel];
|
||||
if (CPH_RENDER_DIR !== undefined && CPH_RENDER_DIR !== "") {
|
||||
args.unshift("--render-dir", CPH_RENDER_DIR);
|
||||
}
|
||||
await runCphBuild(args, workDir);
|
||||
const content = await readFile(path.join(workDir, outRel));
|
||||
return { filename: `${target}.pdf`, content };
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 CPH_BIN 指向的是 Courseware 检查器,而不是同名的其它工具。
|
||||
*
|
||||
* `cph` 这个名字在 PyPI 上已被 conda 的 conda-package-handling 占用,装了
|
||||
* miniconda 的机器上 PATH 里的 `cph` 就是它。直接拿它跑 `build` 会得到
|
||||
* 一句 argparse 的 "invalid choice: 'build'",根本看不出是撞名 —— 所以这里先用
|
||||
* `--version` 探一下,把撞名变成一条能直接终止排查的错误。
|
||||
*
|
||||
* 结果缓存:探测只为拦配置错误,没必要每次导出都多起一个进程。
|
||||
*/
|
||||
let cphIdentityCheck: Promise<void> | null = null;
|
||||
|
||||
function requireCourswareCph(): Promise<void> {
|
||||
cphIdentityCheck ??= (async () => {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileAsync(CPH_BIN, ["--version"], { timeout: 10_000 }));
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileLibError(
|
||||
500,
|
||||
"cph_not_found",
|
||||
`cph binary not found at "${CPH_BIN}" (set CPH_BIN to the Courseware cph)`,
|
||||
);
|
||||
}
|
||||
throw new FileLibError(500, "cph_unusable", `cph --version failed at "${CPH_BIN}": ${String(err.message)}`);
|
||||
}
|
||||
if (!/^cph\s+\d+\.\d+\.\d+/.test(stdout.trim())) {
|
||||
throw new FileLibError(
|
||||
500,
|
||||
"cph_wrong_binary",
|
||||
`"${CPH_BIN}" is not the Courseware cph checker (--version said: ${stdout.trim().split("\n")[0] ?? ""}). ` +
|
||||
`Set CPH_BIN to the Courseware cph binary.`,
|
||||
);
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
// 不缓存失败:改完 CPH_BIN 重启前,下一次导出应该重新探测。
|
||||
cphIdentityCheck = null;
|
||||
throw error;
|
||||
});
|
||||
return cphIdentityCheck;
|
||||
}
|
||||
|
||||
/** 把版本库当前内容写进 workDir。路径已由 versionStore 的 safeRelPath 约束。 */
|
||||
async function materialize(input: ExportAdapterInput, workDir: string): Promise<void> {
|
||||
const files = await input.listFiles();
|
||||
if (files.length === 0) {
|
||||
throw new FileLibError(409, "export_empty_project", "project has no files to export");
|
||||
}
|
||||
for (const f of files) {
|
||||
const abs = path.join(workDir, f.path);
|
||||
await mkdir(path.dirname(abs), { recursive: true });
|
||||
await writeFile(abs, await input.readFile(f.path));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑 `cph build`。cph 的约定是诊断走 stderr、退出码非零表示构建失败(ADR-0010),
|
||||
* 所以失败时把 stderr 原样带进错误信息 —— 老师需要看到是哪个诊断挡住了导出。
|
||||
*/
|
||||
async function runCphBuild(args: readonly string[], cwd: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(CPH_BIN, args, { cwd, timeout: CPH_BUILD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileLibError(500, "cph_not_found", `cph binary not found at "${CPH_BIN}"`);
|
||||
}
|
||||
const detail = (err.stderr ?? "").trim() || (err.stdout ?? "").trim() || err.message;
|
||||
throw new FileLibError(422, "cph_build_failed", `cph build failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
|
||||
const artifacts = new Map<string, ExportArtifact>();
|
||||
/**
|
||||
* 内存里最多保留的产物份数。
|
||||
*
|
||||
* 产物不做持久化也不复用 —— 每次导出都按仓库当前内容重新编译,内存副本只为
|
||||
* 支撑「提交完成后那一次下载」。因此这里可以无条件淘汰最旧的:被淘汰的 job 再点
|
||||
* 下载会拿到 export_not_ready,重新导出即可,不存在数据丢失。
|
||||
* 没有上限的话每次导出都会永久占住一份 PDF(数百 KB 级),进程内存只增不减。
|
||||
*/
|
||||
const MAX_RETAINED_ARTIFACTS = 32;
|
||||
|
||||
/** Map 迭代顺序即插入顺序,首个 key 就是最旧的产物。 */
|
||||
function retainArtifact(jobId: string, artifact: ExportArtifact): void {
|
||||
artifacts.set(jobId, artifact);
|
||||
while (artifacts.size > MAX_RETAINED_ARTIFACTS) {
|
||||
const oldest = artifacts.keys().next();
|
||||
if (oldest.done === true) break;
|
||||
artifacts.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportDeps extends FileDeps {
|
||||
readonly adapters: readonly ExportAdapter[];
|
||||
@@ -136,7 +281,7 @@ async function runExportJob(
|
||||
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
|
||||
readFile: (path) => deps.versionStore.read(storageDir, path),
|
||||
});
|
||||
artifacts.set(jobId, artifact);
|
||||
retainArtifact(jobId, artifact);
|
||||
await deps.prisma.fileLibExportJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
|
||||
|
||||
@@ -33,7 +33,7 @@ import { createGitVersionStore } from "../filelib/gitVersionStore.js";
|
||||
import { resolveMaxFileBytes } from "../filelib/fileService.js";
|
||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
||||
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
@@ -160,7 +160,7 @@ export async function registerDatabaseRoutes(
|
||||
? createMemberGroupResolver(config.prisma)
|
||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)],
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
};
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
|
||||
@@ -225,9 +225,13 @@ export async function registerFileRoutes(
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
const artifact = await downloadExport(exportDeps, actor, jobId);
|
||||
// PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。
|
||||
const contentType = artifact.filename.toLowerCase().endsWith(".pdf")
|
||||
? "application/pdf"
|
||||
: "application/octet-stream";
|
||||
return reply
|
||||
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
||||
.type("application/octet-stream")
|
||||
.type(contentType)
|
||||
.send(artifact.content);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
|
||||
Reference in New Issue
Block a user