base
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Hocuspocus } from "@hocuspocus/server";
|
||||
import type { Request } from "express";
|
||||
import type WebSocket from "ws";
|
||||
// plane imports
|
||||
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
@Controller("/collaboration")
|
||||
export class CollaborationController {
|
||||
[key: string]: unknown;
|
||||
private readonly hocusPocusServer: Hocuspocus;
|
||||
|
||||
constructor(hocusPocusServer: Hocuspocus) {
|
||||
this.hocusPocusServer = hocusPocusServer;
|
||||
}
|
||||
|
||||
@WSDecorator("/")
|
||||
handleConnection(ws: WebSocket, req: Request) {
|
||||
try {
|
||||
// Initialize the connection with Hocuspocus
|
||||
this.hocusPocusServer.handleConnection(ws, req);
|
||||
|
||||
// Set up error handling for the connection
|
||||
ws.on("error", (error: Error) => {
|
||||
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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 { z } from "zod";
|
||||
// helpers
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
import type { TConvertDocumentRequestBody } from "@/types";
|
||||
|
||||
// Define the schema with more robust validation
|
||||
const convertDocumentSchema = z.object({
|
||||
description_html: z
|
||||
.string()
|
||||
.min(1, "HTML content cannot be empty")
|
||||
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
|
||||
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
|
||||
variant: z.enum(["rich", "document"]),
|
||||
});
|
||||
|
||||
@Controller("/convert-document")
|
||||
export class DocumentController {
|
||||
@Post("/")
|
||||
async convertDocument(req: Request, res: Response) {
|
||||
try {
|
||||
// Validate request body
|
||||
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
|
||||
const { description_html, variant } = validatedData;
|
||||
|
||||
// Process document conversion
|
||||
const { description_json, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
res.status(200).json({
|
||||
description_json,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationErrors = error.errors.map((err) => ({
|
||||
path: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
logger.error("DOCUMENT_CONTROLLER: Validation error", {
|
||||
validationErrors,
|
||||
});
|
||||
return res.status(400).json({
|
||||
message: `Validation error`,
|
||||
context: {
|
||||
validationErrors,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.error("DOCUMENT_CONTROLLER: Internal server error", error);
|
||||
return res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 { Controller, Get } from "@plane/decorators";
|
||||
import { env } from "@/env";
|
||||
|
||||
@Controller("/health")
|
||||
export class HealthController {
|
||||
@Get("/")
|
||||
async healthCheck(_req: Request, res: Response) {
|
||||
res.status(200).json({
|
||||
status: "OK",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: env.APP_VERSION,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PdfExportController } from "./pdf-export.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user