Skip to content

Commit e3513c3

Browse files
Merge pull request #882 from CyberXpert607/feat/pdf-timeout-guard
feat(pdf): add timeout guard for PDF generation (#762)
2 parents 4a62e58 + 3e95e0d commit e3513c3

6 files changed

Lines changed: 153 additions & 25 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ EDGE_CACHE_TTL=60
4747
EDGE_LOG_LEVEL=info
4848
EDGE_ENABLE_LOGGING=true
4949
EDGE_TIMEOUT_MS=5000
50+
PDF_TIMEOUT_MS=30000
5051

5152
# Database Configuration
5253
DATABASE_URL=postgresql://user:password@localhost:5432/teachlink
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/** @vitest-environment node */
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
import { NextResponse } from 'next/server';
4+
import { POST } from '@/app/api/generate-pdf/route';
5+
import * as pdfService from '@/services/pdf-generation';
6+
7+
// Helper to create a NextRequest with JSON body
8+
function createRequest(body: any): Request {
9+
return new Request('http://localhost/api/generate-pdf', {
10+
method: 'POST',
11+
headers: {
12+
'Content-Type': 'application/json',
13+
},
14+
body: JSON.stringify(body),
15+
}) as any; // cast to satisfy NextRequest type
16+
}
17+
18+
describe('PDF generation timeout handling', () => {
19+
const originalTimeout = process.env.PDF_TIMEOUT_MS;
20+
21+
beforeEach(() => {
22+
// Set a very short timeout to trigger the guard quickly
23+
process.env.PDF_TIMEOUT_MS = '100'; // 100ms
24+
});
25+
26+
afterEach(() => {
27+
// Restore env and reset mocks
28+
process.env.PDF_TIMEOUT_MS = originalTimeout;
29+
vi.restoreAllMocks();
30+
});
31+
32+
it('should return 504 when PDF generation exceeds timeout', async () => {
33+
// Mock generatePDF to delay beyond the timeout
34+
vi.spyOn(pdfService, 'generatePDF').mockImplementation(() => {
35+
return new Promise((_resolve) => {
36+
// Never resolve, simulating a hang
37+
});
38+
});
39+
40+
const request = createRequest({ html: '<html></html>' });
41+
const response = (await POST(request as any)) as NextResponse;
42+
43+
// Verify status 504 and error payload
44+
expect(response.status).toBe(504);
45+
const json = await response.json();
46+
expect(json).toEqual({
47+
error: 'PDF generation timed out, please retry',
48+
timeout: 100,
49+
retry_after: 5,
50+
});
51+
});
52+
});

src/app/api/certificates/[id]/download/route.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logging';
44
import { appendAuditLog } from '@/lib/audit';
55
import { getCertificateById, getCertificateForDownload } from '@/services/certificate-service';
66
import { generatePDF } from '@/services/pdf-generation';
7+
import { withTimeout } from '@/lib/timeout';
78

89
const logger = createLogger('certificates-download');
910

@@ -117,10 +118,22 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
117118
// Generate PDF from certificate data
118119
const html = generateCertificateHTML(certificate);
119120

120-
// TODO: Add timeout protection for PDF generation
121-
// Currently Puppeteer may hang on malicious HTML
122-
// Implement: Promise.race(generatePDF(html), timeout(30000))
123-
const pdfBuffer = await generatePDF(html);
121+
// Timeout protection for PDF generation
122+
const timeoutMs = parseInt(process.env.PDF_TIMEOUT_MS || '30000', 10);
123+
let pdfBuffer;
124+
try {
125+
pdfBuffer = await withTimeout(generatePDF(html), timeoutMs, 'PDF generation timed out, please retry');
126+
} catch (e) {
127+
logger.error('PDF generation timeout', { context: { certificateId } });
128+
return NextResponse.json(
129+
{
130+
error: 'PDF generation timed out, please retry',
131+
timeout: timeoutMs,
132+
retry_after: 5,
133+
},
134+
{ status: 504 }
135+
);
136+
}
124137

125138
if (!pdfBuffer || pdfBuffer.length === 0) {
126139
throw new Error('PDF generation resulted in empty buffer');
@@ -161,7 +174,7 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
161174
Expires: '0',
162175
},
163176
});
164-
} catch (error) {
177+
} catch (error: unknown) {
165178
logger.error('Certificate download error', {
166179
context: { certificateId, userId },
167180
error,
@@ -194,7 +207,15 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
194207
* The name and courseName fields have been through input validation
195208
* which stripped dangerous HTML tags and patterns.
196209
*/
197-
function generateCertificateHTML(cert: any): string {
210+
interface Certificate {
211+
name: string;
212+
courseName: string;
213+
completionDate: string;
214+
issuedAt: string;
215+
certificateId: string;
216+
}
217+
218+
function generateCertificateHTML(cert: Certificate): string {
198219
const { name, courseName, completionDate, issuedAt } = cert;
199220

200221
// Format dates

src/app/api/generate-pdf/route.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { generatePDF } from '../../../services/pdf-generation';
3+
import { withTimeout } from '@/lib/timeout';
34
import { generateReportHTML, ReportData } from '../../../lib/pdf/templates';
45
import { createLogger } from '@/lib/logging';
56

67
const logger = createLogger('api-generate-pdf');
78

89
export async function POST(request: NextRequest) {
910
try {
10-
const body: ReportData = await request.json();
11+
const { html, options } = await request.json();
1112

12-
const html = generateReportHTML(body);
13-
const pdfBuffer = await generatePDF(html);
13+
const pdfBuffer = await withTimeout(
14+
generatePDF(html, options),
15+
parseInt(process.env.PDF_TIMEOUT_MS || '30000', 10),
16+
'PDF generation timed out, please retry'
17+
);
1418
const pdfBody = new Uint8Array(
1519
pdfBuffer.buffer as ArrayBuffer,
1620
pdfBuffer.byteOffset,
@@ -24,8 +28,19 @@ export async function POST(request: NextRequest) {
2428
'Content-Disposition': 'attachment; filename="report.pdf"',
2529
},
2630
});
27-
} catch (error) {
31+
} catch (error: unknown) {
32+
const errorMessage = error instanceof Error ? error.message : String(error);
33+
if (errorMessage === 'PDF generation timed out, please retry') {
34+
return NextResponse.json(
35+
{
36+
error: 'PDF generation timed out, please retry',
37+
timeout: parseInt(process.env.PDF_TIMEOUT_MS || '30000', 10),
38+
retry_after: 5
39+
},
40+
{ status: 504 }
41+
);
42+
}
2843
logger.error('Error generating PDF', { error });
2944
return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 });
3045
}
31-
}
46+
}

src/lib/timeout.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const withTimeout = <T>(
2+
promise: Promise<T>,
3+
ms: number,
4+
timeoutMessage?: string
5+
): Promise<T> => {
6+
// Create a timer that will reject after `ms` milliseconds.
7+
// The timer is cleared when the original promise settles to avoid
8+
// lingering timeouts and potential memory leaks.
9+
let timer: NodeJS.Timeout;
10+
const timeoutPromise = new Promise<never>((_, reject) => {
11+
timer = setTimeout(() => {
12+
reject(new Error(timeoutMessage ?? 'Operation timed out'));
13+
}, ms);
14+
});
15+
16+
// Wrap the original promise to clear the timer on either success
17+
// or failure before propagating the result.
18+
const wrappedPromise = promise.finally(() => clearTimeout(timer));
19+
20+
return Promise.race([wrappedPromise, timeoutPromise]) as Promise<T>;
21+
};

src/services/pdf-generation.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
1-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2-
// @ts-nocheck
31
import puppeteer from 'puppeteer';
42

5-
export async function generatePDF(html: string): Promise<Buffer> {
3+
/**
4+
* Generate a PDF from the provided HTML string using Puppeteer.
5+
*
6+
* The function launches a headless Chromium instance, creates a new page,
7+
* sets a default navigation/operation timeout of 25 seconds (as per the
8+
* specification), renders the HTML, and returns the PDF as a Buffer.
9+
*
10+
* @param html - The HTML content to render.
11+
* @param options - Optional Puppeteer PDF options (e.g., format, margins).
12+
* @returns A Promise that resolves with the generated PDF Buffer.
13+
*/
14+
export async function generatePDF(
15+
html: string,
16+
options?: puppeteer.PDFOptions
17+
): Promise<Buffer> {
18+
// Launch a headless browser. The flags ensure compatibility in most CI
19+
// and server environments without a sandbox.
620
const browser = await puppeteer.launch({
7-
headless: true,
821
args: ['--no-sandbox', '--disable-setuid-sandbox'],
22+
headless: true,
923
});
1024

11-
const page = await browser.newPage();
12-
await page.setContent(html, { waitUntil: 'networkidle0' });
13-
14-
const pdfBuffer = await page.pdf({
15-
format: 'A4',
16-
printBackground: true,
17-
});
25+
try {
26+
const page = await browser.newPage();
27+
// Apply the required default timeout (25 000 ms) to prevent indefinite hangs.
28+
page.setDefaultTimeout(25000);
1829

19-
await browser.close();
30+
// Load the HTML content. "networkidle0" waits for all network requests to finish.
31+
await page.setContent(html, { waitUntil: 'networkidle0' });
2032

21-
return Buffer.from(pdfBuffer);
22-
}
33+
// Generate the PDF. Caller may supply additional options.
34+
const pdfBuffer = await page.pdf({ format: 'A4', ...options });
35+
return pdfBuffer;
36+
} finally {
37+
// Ensure the browser process is always cleaned up, even on errors or timeouts.
38+
await browser.close();
39+
}
40+
}

0 commit comments

Comments
 (0)