Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
/**
|
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
* See the LICENSE file for details.
|
|
*/
|
|
|
|
import type { Request, Response } from "express";
|
|
import { Effect, Schema, Cause } from "effect";
|
|
import { Controller, Post } from "@plane/decorators";
|
|
import { logger } from "@plane/logger";
|
|
import { AppError } from "@/lib/errors";
|
|
import { PdfExportRequestBody, PdfValidationError, PdfAuthenticationError } from "@/schema/pdf-export";
|
|
import { PdfExportService, exportToPdf } from "@/services/pdf-export";
|
|
import type { PdfExportInput } from "@/services/pdf-export";
|
|
|
|
@Controller("/pdf-export")
|
|
export class PdfExportController {
|
|
/**
|
|
* Parses and validates the request, returning a typed input object
|
|
*/
|
|
private parseRequest(
|
|
req: Request,
|
|
requestId: string
|
|
): Effect.Effect<PdfExportInput, PdfValidationError | PdfAuthenticationError> {
|
|
return Effect.gen(function* () {
|
|
const cookie = req.headers.cookie || "";
|
|
if (!cookie) {
|
|
return yield* Effect.fail(
|
|
new PdfAuthenticationError({
|
|
message: "Authentication required",
|
|
})
|
|
);
|
|
}
|
|
|
|
const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe(
|
|
Effect.mapError(
|
|
(cause) =>
|
|
new PdfValidationError({
|
|
message: "Invalid request body",
|
|
cause,
|
|
})
|
|
)
|
|
);
|
|
|
|
return {
|
|
pageId: body.pageId,
|
|
workspaceSlug: body.workspaceSlug,
|
|
projectId: body.projectId,
|
|
title: body.title,
|
|
author: body.author,
|
|
subject: body.subject,
|
|
pageSize: body.pageSize,
|
|
pageOrientation: body.pageOrientation,
|
|
fileName: body.fileName,
|
|
noAssets: body.noAssets,
|
|
cookie,
|
|
requestId,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Maps domain errors to HTTP responses
|
|
*/
|
|
private mapErrorToHttpResponse(error: unknown): { status: number; error: string } {
|
|
if (error && typeof error === "object" && "_tag" in error) {
|
|
const tag = (error as { _tag: string })._tag;
|
|
const message = (error as { message?: string }).message || "Unknown error";
|
|
|
|
switch (tag) {
|
|
case "PdfValidationError":
|
|
return { status: 400, error: message };
|
|
case "PdfAuthenticationError":
|
|
return { status: 401, error: message };
|
|
case "PdfContentFetchError":
|
|
return {
|
|
status: message.includes("not found") ? 404 : 502,
|
|
error: message,
|
|
};
|
|
case "PdfTimeoutError":
|
|
return { status: 504, error: message };
|
|
case "PdfGenerationError":
|
|
return { status: 500, error: message };
|
|
case "PdfMetadataFetchError":
|
|
case "PdfImageProcessingError":
|
|
return { status: 502, error: message };
|
|
default:
|
|
return { status: 500, error: message };
|
|
}
|
|
}
|
|
return { status: 500, error: "Failed to generate PDF" };
|
|
}
|
|
|
|
@Post("/")
|
|
async exportToPdf(req: Request, res: Response) {
|
|
const requestId = crypto.randomUUID();
|
|
|
|
const effect = Effect.gen(this, function* () {
|
|
// Parse request
|
|
const input = yield* this.parseRequest(req, requestId);
|
|
|
|
// Delegate to service
|
|
return yield* exportToPdf(input);
|
|
}).pipe(
|
|
// Log errors before catching them
|
|
Effect.tapError((error) => Effect.logError("PDF_EXPORT: Export failed", { requestId, error })),
|
|
// Map all tagged errors to HTTP responses
|
|
Effect.catchAll((error) => Effect.succeed(this.mapErrorToHttpResponse(error))),
|
|
// Handle unexpected defects
|
|
Effect.catchAllDefect((defect) => {
|
|
const appError = new AppError(Cause.pretty(Cause.die(defect)), {
|
|
context: { requestId, operation: "exportToPdf" },
|
|
});
|
|
logger.error("PDF_EXPORT: Unexpected failure", appError);
|
|
return Effect.succeed({ status: 500, error: "Failed to generate PDF" });
|
|
})
|
|
);
|
|
|
|
const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default));
|
|
|
|
// Check if result is an error response
|
|
if ("error" in result && "status" in result) {
|
|
return res.status(result.status).json({ message: result.error });
|
|
}
|
|
|
|
// Success - send PDF
|
|
const { pdfBuffer, outputFileName } = result;
|
|
|
|
// Sanitize filename for Content-Disposition header to prevent header injection
|
|
const sanitizedFileName = outputFileName
|
|
.replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF
|
|
.replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore
|
|
|
|
res.setHeader("Content-Type", "application/pdf");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}`
|
|
);
|
|
res.setHeader("Content-Length", pdfBuffer.length);
|
|
return res.send(pdfBuffer);
|
|
}
|
|
}
|