Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/live/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
"start": "node --env-file=.env .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=160",
"check:types": "tsc --noEmit",
"check:format": "prettier . --cache --check",
Expand All @@ -25,6 +28,9 @@
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "catalog:",
"@effect/platform": "^0.94.0",
"@effect/platform-node": "^0.104.0",
"@fontsource/inter": "5.2.8",
Comment on lines +31 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's see the structure and locate the relevant files
git ls-files | grep -E "(apps/live/package.json|pnpm-workspace.yaml)" | head -20

Repository: makeplane/plane

Length of output: 101


🏁 Script executed:

# Read the apps/live/package.json file to see the actual dependencies at the mentioned lines
cat -n apps/live/package.json

Repository: makeplane/plane

Length of output: 3319


🏁 Script executed:

# Also check the pnpm-workspace.yaml to see if catalog is defined
cat pnpm-workspace.yaml

Repository: makeplane/plane

Length of output: 994


Add packages to catalog: in both files.

Lines 31–33, 43–45, 52, 58, and 73–80 introduce external packages with version ranges. Per repo policy, external dependencies must use catalog: with versions declared in pnpm-workspace.yaml. These packages are missing from the catalog and need to be added there before updating package.json references.

Update pnpm-workspace.yaml to include:

"@effect/platform": ^0.94.0
"@effect/platform-node": ^0.104.0
"@fontsource/inter": 5.2.8
"@react-pdf/renderer": ^4.3.0
"@react-pdf/types": ^2.9.2
"effect": ^3.16.3
"sharp": ^0.34.3
"@types/pdf-parse": ^1.1.5
"@vitest/coverage-v8": ^4.0.8
"pdf-parse": ^2.4.5
"vitest": ^4.0.8

Then update apps/live/package.json to use catalog: for these entries, as shown in the original diff.

🤖 Prompt for AI Agents
In `@apps/live/package.json` around lines 31 - 33, Add the listed external
packages into the pnpm workspace catalog with the exact versions provided
(entries for "@effect/platform", "@effect/platform-node", "@fontsource/inter",
"@react-pdf/renderer", "@react-pdf/types", "effect", "sharp",
"@types/pdf-parse", "@vitest/coverage-v8", "pdf-parse", and "vitest" using the
versions in the review), then update apps/live/package.json dependency entries
for those same package names to reference the catalog: versions instead of
direct semver ranges; ensure you modify the pnpm-workspace.yaml catalog section
to include each package/version and replace the corresponding dependency strings
in package.json with catalog:package@version tokens.

"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
Expand All @@ -34,17 +40,22 @@
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
"@plane/types": "workspace:*",
"@react-pdf/renderer": "^4.3.0",
"@react-pdf/types": "^2.9.2",
"@sentry/node": "catalog:",
"@sentry/profiling-node": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/html": "catalog:",
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"effect": "^3.16.3",
"express": "catalog:",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "5.7.0",
"react": "catalog:",
"sharp": "^0.34.3",
"uuid": "catalog:",
"ws": "^8.18.3",
"y-prosemirror": "^1.3.7",
Expand All @@ -59,8 +70,13 @@
"@types/express": "4.17.23",
"@types/express-ws": "^3.0.5",
"@types/node": "catalog:",
"@types/pdf-parse": "^1.1.5",
"@types/react": "catalog:",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.8",
"pdf-parse": "^2.4.5",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^4.0.8"
}
}
3 changes: 2 additions & 1 deletion apps/live/src/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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];
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
136 changes: 136 additions & 0 deletions apps/live/src/controllers/pdf-export.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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);
}
}
Loading
Loading