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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ EDGE_CACHE_TTL=60
EDGE_LOG_LEVEL=info
EDGE_ENABLE_LOGGING=true
EDGE_TIMEOUT_MS=5000
PDF_TIMEOUT_MS=30000

# Database Configuration
DATABASE_URL=postgresql://user:password@localhost:5432/teachlink
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/pdf-generation-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** @vitest-environment node */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextResponse } from 'next/server';
import { POST } from '@/app/api/generate-pdf/route';
import * as pdfService from '@/services/pdf-generation';

// Helper to create a NextRequest with JSON body
function createRequest(body: any): Request {
return new Request('http://localhost/api/generate-pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}) as any; // cast to satisfy NextRequest type
}

describe('PDF generation timeout handling', () => {
const originalTimeout = process.env.PDF_TIMEOUT_MS;

beforeEach(() => {
// Set a very short timeout to trigger the guard quickly
process.env.PDF_TIMEOUT_MS = '100'; // 100ms
});

afterEach(() => {
// Restore env and reset mocks
process.env.PDF_TIMEOUT_MS = originalTimeout;
vi.restoreAllMocks();
});

it('should return 504 when PDF generation exceeds timeout', async () => {
// Mock generatePDF to delay beyond the timeout
vi.spyOn(pdfService, 'generatePDF').mockImplementation(() => {
return new Promise((_resolve) => {
// Never resolve, simulating a hang
});
});

const request = createRequest({ html: '<html></html>' });
const response = (await POST(request as any)) as NextResponse;

// Verify status 504 and error payload
expect(response.status).toBe(504);
const json = await response.json();
expect(json).toEqual({
error: 'PDF generation timed out, please retry',
timeout: 100,
retry_after: 5,
});
});
});
33 changes: 27 additions & 6 deletions src/app/api/certificates/[id]/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logging';
import { appendAuditLog } from '@/lib/audit';
import { getCertificateById, getCertificateForDownload } from '@/services/certificate-service';
import { generatePDF } from '@/services/pdf-generation';
import { withTimeout } from '@/lib/timeout';

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

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

// TODO: Add timeout protection for PDF generation
// Currently Puppeteer may hang on malicious HTML
// Implement: Promise.race(generatePDF(html), timeout(30000))
const pdfBuffer = await generatePDF(html);
// Timeout protection for PDF generation
const timeoutMs = parseInt(process.env.PDF_TIMEOUT_MS || '30000', 10);
let pdfBuffer;
try {
pdfBuffer = await withTimeout(generatePDF(html), timeoutMs, 'PDF generation timed out, please retry');
} catch (e) {
logger.error('PDF generation timeout', { context: { certificateId } });
return NextResponse.json(
{
error: 'PDF generation timed out, please retry',
timeout: timeoutMs,
retry_after: 5,
},
{ status: 504 }
);
}

if (!pdfBuffer || pdfBuffer.length === 0) {
throw new Error('PDF generation resulted in empty buffer');
Expand Down Expand Up @@ -161,7 +174,7 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
Expires: '0',
},
});
} catch (error) {
} catch (error: unknown) {
logger.error('Certificate download error', {
context: { certificateId, userId },
error,
Expand Down Expand Up @@ -194,7 +207,15 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
* The name and courseName fields have been through input validation
* which stripped dangerous HTML tags and patterns.
*/
function generateCertificateHTML(cert: any): string {
interface Certificate {
name: string;
courseName: string;
completionDate: string;
issuedAt: string;
certificateId: string;
}

function generateCertificateHTML(cert: Certificate): string {
const { name, courseName, completionDate, issuedAt } = cert;

// Format dates
Expand Down
25 changes: 20 additions & 5 deletions src/app/api/generate-pdf/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { generatePDF } from '../../../services/pdf-generation';
import { withTimeout } from '@/lib/timeout';
import { generateReportHTML, ReportData } from '../../../lib/pdf/templates';
import { createLogger } from '@/lib/logging';

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

export async function POST(request: NextRequest) {
try {
const body: ReportData = await request.json();
const { html, options } = await request.json();

const html = generateReportHTML(body);
const pdfBuffer = await generatePDF(html);
const pdfBuffer = await withTimeout(
generatePDF(html, options),
parseInt(process.env.PDF_TIMEOUT_MS || '30000', 10),
'PDF generation timed out, please retry'
);
const pdfBody = new Uint8Array(
pdfBuffer.buffer as ArrayBuffer,
pdfBuffer.byteOffset,
Expand All @@ -24,8 +28,19 @@ export async function POST(request: NextRequest) {
'Content-Disposition': 'attachment; filename="report.pdf"',
},
});
} catch (error) {
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === 'PDF generation timed out, please retry') {
return NextResponse.json(
{
error: 'PDF generation timed out, please retry',
timeout: parseInt(process.env.PDF_TIMEOUT_MS || '30000', 10),
retry_after: 5
},
{ status: 504 }
);
}
logger.error('Error generating PDF', { error });
return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 });
}
}
}
21 changes: 21 additions & 0 deletions src/lib/timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const withTimeout = <T>(
promise: Promise<T>,
ms: number,
timeoutMessage?: string
): Promise<T> => {
// Create a timer that will reject after `ms` milliseconds.
// The timer is cleared when the original promise settles to avoid
// lingering timeouts and potential memory leaks.
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(timeoutMessage ?? 'Operation timed out'));
}, ms);
});

// Wrap the original promise to clear the timer on either success
// or failure before propagating the result.
const wrappedPromise = promise.finally(() => clearTimeout(timer));

return Promise.race([wrappedPromise, timeoutPromise]) as Promise<T>;
};
46 changes: 32 additions & 14 deletions src/services/pdf-generation.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import puppeteer from 'puppeteer';

export async function generatePDF(html: string): Promise<Buffer> {
/**
* Generate a PDF from the provided HTML string using Puppeteer.
*
* The function launches a headless Chromium instance, creates a new page,
* sets a default navigation/operation timeout of 25 seconds (as per the
* specification), renders the HTML, and returns the PDF as a Buffer.
*
* @param html - The HTML content to render.
* @param options - Optional Puppeteer PDF options (e.g., format, margins).
* @returns A Promise that resolves with the generated PDF Buffer.
*/
export async function generatePDF(
html: string,
options?: puppeteer.PDFOptions

Check failure on line 16 in src/services/pdf-generation.ts

View workflow job for this annotation

GitHub Actions / type-check

Cannot find namespace 'puppeteer'.
): Promise<Buffer> {
// Launch a headless browser. The flags ensure compatibility in most CI
// and server environments without a sandbox.
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
headless: true,
});

const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });

const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
});
try {
const page = await browser.newPage();
// Apply the required default timeout (25 000 ms) to prevent indefinite hangs.
page.setDefaultTimeout(25000);

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

Check failure on line 31 in src/services/pdf-generation.ts

View workflow job for this annotation

GitHub Actions / type-check

Type '"networkidle0"' is not assignable to type '"load" | "domcontentloaded" | ("load" | "domcontentloaded")[] | undefined'.

return Buffer.from(pdfBuffer);
}
// Generate the PDF. Caller may supply additional options.
const pdfBuffer = await page.pdf({ format: 'A4', ...options });
return pdfBuffer;

Check failure on line 35 in src/services/pdf-generation.ts

View workflow job for this annotation

GitHub Actions / type-check

Type 'Uint8Array<ArrayBufferLike>' is missing the following properties from type 'Buffer<ArrayBufferLike>': write, toJSON, equals, compare, and 66 more.
} finally {
// Ensure the browser process is always cleaned up, even on errors or timeouts.
await browser.close();
}
}
Loading