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
|
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
|
||||||
# the file to be executable and `cph --version` to succeed.
|
# 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"
|
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
|
# 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.
|
# reverse proxy; both values are validated and honored by the HTTP server.
|
||||||
HOST="127.0.0.1"
|
HOST="127.0.0.1"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
|
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
|
||||||
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
|
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",
|
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;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof ICONS;
|
export type IconName = keyof typeof ICONS;
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
import { currentNode } from "./browser.js";
|
import { currentNode } from "./browser.js";
|
||||||
import type { ExportJob, NodeDetail } from "./types.js";
|
import type { ExportJob, NodeDetail } from "./types.js";
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
let { node }: { node: NodeDetail } = $props();
|
let { node }: { node: NodeDetail } = $props();
|
||||||
|
|
||||||
let showEditDesc = $state(false);
|
let showEditDesc = $state(false);
|
||||||
let descDraft = $state("");
|
let descDraft = $state("");
|
||||||
let exportJob = $state<ExportJob | null>(null);
|
let exportJob = $state<ExportJob | null>(null);
|
||||||
|
let exporting = $state(false);
|
||||||
|
|
||||||
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
||||||
const canManage = $derived(node.role === "MANAGE");
|
const canManage = $derived(node.role === "MANAGE");
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void node.id;
|
void node.id;
|
||||||
exportJob = null;
|
exportJob = null;
|
||||||
|
exporting = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function openEditDesc(): void {
|
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 {
|
try {
|
||||||
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
|
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { target: "manifest" },
|
body: { target: "pdf" },
|
||||||
});
|
});
|
||||||
toastOk("导出已提交");
|
await pollExport(r.jobId);
|
||||||
void pollExport(r.jobId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastErr(e instanceof Error ? e.message : String(e));
|
toastErr(e instanceof Error ? e.message : String(e));
|
||||||
|
exporting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollExport(jobId: string): Promise<void> {
|
async function pollExport(jobId: string): Promise<void> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
let job: ExportJob;
|
||||||
try {
|
try {
|
||||||
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||||
exportJob = job;
|
} catch (e) {
|
||||||
if (job.status === "DONE" || job.status === "FAILED") break;
|
exporting = false;
|
||||||
} catch {
|
toastErr(e instanceof Error ? e.message : String(e));
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
|
exportJob = job;
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -124,18 +159,16 @@
|
|||||||
|
|
||||||
<div class="section-title mb-2">导出</div>
|
<div class="section-title mb-2">导出</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
|
<button class="btn" onclick={exportPdf} disabled={exporting}>
|
||||||
<button class="btn" onclick={submitExport}>开始导出</button>
|
<Icon name="download" size={13} />
|
||||||
{#if exportJob}
|
{exporting ? "导出中…" : "导出 PDF"}
|
||||||
|
</button>
|
||||||
|
{#if exportJob?.status === "DONE"}
|
||||||
<span class="file-meta">
|
<span class="file-meta">
|
||||||
{#if exportJob.status === "DONE"}
|
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
|
||||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
|
|
||||||
{:else if exportJob.status === "FAILED"}
|
|
||||||
失败:{exportJob.error ?? ""}
|
|
||||||
{:else}
|
|
||||||
{exportJob.status}…
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
|
{:else if exportJob?.status === "FAILED"}
|
||||||
|
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,11 +7,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
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 { FileLibError } from "./model.js";
|
||||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||||
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
|
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
|
||||||
import type { FileDeps } from "./fileService.js";
|
import type { FileDeps } from "./fileService.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export interface ExportAdapterInput {
|
export interface ExportAdapterInput {
|
||||||
readonly storageDir: string;
|
readonly storageDir: string;
|
||||||
readonly target: 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 产物存储(进程内存,重启即失;生产替换为持久存储)。
|
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
|
||||||
const artifacts = new Map<string, ExportArtifact>();
|
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 {
|
export interface ExportDeps extends FileDeps {
|
||||||
readonly adapters: readonly ExportAdapter[];
|
readonly adapters: readonly ExportAdapter[];
|
||||||
@@ -136,7 +281,7 @@ async function runExportJob(
|
|||||||
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
|
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
|
||||||
readFile: (path) => deps.versionStore.read(storageDir, path),
|
readFile: (path) => deps.versionStore.read(storageDir, path),
|
||||||
});
|
});
|
||||||
artifacts.set(jobId, artifact);
|
retainArtifact(jobId, artifact);
|
||||||
await deps.prisma.fileLibExportJob.update({
|
await deps.prisma.fileLibExportJob.update({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
|
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 { resolveMaxFileBytes } from "../filelib/fileService.js";
|
||||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.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 { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||||
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
||||||
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||||
@@ -160,7 +160,7 @@ export async function registerDatabaseRoutes(
|
|||||||
? createMemberGroupResolver(config.prisma)
|
? createMemberGroupResolver(config.prisma)
|
||||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||||
versionStore,
|
versionStore,
|
||||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)],
|
||||||
maxFileBytes: resolveMaxFileBytes(),
|
maxFileBytes: resolveMaxFileBytes(),
|
||||||
};
|
};
|
||||||
await registerFileLibRoutes(app, filelibDeps);
|
await registerFileLibRoutes(app, filelibDeps);
|
||||||
|
|||||||
@@ -225,9 +225,13 @@ export async function registerFileRoutes(
|
|||||||
try {
|
try {
|
||||||
const { jobId } = request.params as { jobId: string };
|
const { jobId } = request.params as { jobId: string };
|
||||||
const artifact = await downloadExport(exportDeps, actor, jobId);
|
const artifact = await downloadExport(exportDeps, actor, jobId);
|
||||||
|
// PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。
|
||||||
|
const contentType = artifact.filename.toLowerCase().endsWith(".pdf")
|
||||||
|
? "application/pdf"
|
||||||
|
: "application/octet-stream";
|
||||||
return reply
|
return reply
|
||||||
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
||||||
.type("application/octet-stream")
|
.type(contentType)
|
||||||
.send(artifact.content);
|
.send(artifact.content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sendRouteError(reply, error);
|
return sendRouteError(reply, error);
|
||||||
|
|||||||
Reference in New Issue
Block a user