From a1bc4fce9e7289eb5ca725dc73910dcc48973437 Mon Sep 17 00:00:00 2001 From: Janpot <2109932+Janpot@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:43:35 +0200 Subject: [PATCH 1/3] Add QR codes to deploy preview links in PR comments --- .../app/api/qr-code/route.test.ts | 81 ++++++++++++ .../app/api/qr-code/route.ts | 53 ++++++++ apps/code-infra-dashboard/package.json | 2 + .../lib/ciReports/deployPreviewReport.test.ts | 124 ++++++++++++++++++ .../src/lib/ciReports/deployPreviewReport.ts | 24 +++- .../src/lib/qrCode.test.ts | 101 ++++++++++++++ apps/code-infra-dashboard/src/lib/qrCode.ts | 55 ++++++++ pnpm-lock.yaml | 91 +++++++++++++ render.yaml | 2 + 9 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 apps/code-infra-dashboard/app/api/qr-code/route.test.ts create mode 100644 apps/code-infra-dashboard/app/api/qr-code/route.ts create mode 100644 apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts create mode 100644 apps/code-infra-dashboard/src/lib/qrCode.test.ts create mode 100644 apps/code-infra-dashboard/src/lib/qrCode.ts diff --git a/apps/code-infra-dashboard/app/api/qr-code/route.test.ts b/apps/code-infra-dashboard/app/api/qr-code/route.test.ts new file mode 100644 index 000000000..9cb83d8bd --- /dev/null +++ b/apps/code-infra-dashboard/app/api/qr-code/route.test.ts @@ -0,0 +1,81 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { signQrCodeUrl } from '@/lib/qrCode'; +import { GET } from './route'; + +describe('GET /api/qr-code', () => { + beforeEach(() => { + vi.stubEnv('QR_CODE_SECRET', 'test-secret'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should respond with a cacheable SVG for a validly signed URL', async () => { + const signedUrl = signQrCodeUrl('https://example.com/page'); + + const response = await GET(new NextRequest(signedUrl!)); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('image/svg+xml'); + expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable'); + expect(await response.text()).toContain(' { + const url = new URL('https://dashboard.test/api/qr-code'); + url.searchParams.set('url', 'https://example.com/page'); + url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); + + const response = await GET(new NextRequest(url)); + + expect(response.status).toBe(403); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('should respond with 403 when the signed URL is tampered with', async () => { + const signedUrl = new URL(signQrCodeUrl('https://example.com/page')!); + signedUrl.searchParams.set('url', 'https://example.com/other'); + + const response = await GET(new NextRequest(signedUrl)); + + expect(response.status).toBe(403); + }); + + it('should respond with 400 when query parameters are missing', async () => { + const response = await GET(new NextRequest('https://dashboard.test/api/qr-code')); + + expect(response.status).toBe(400); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('should respond with 400 for a non-https URL', async () => { + const url = new URL('https://dashboard.test/api/qr-code'); + url.searchParams.set('url', 'http://example.com/page'); + url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); + + const response = await GET(new NextRequest(url)); + + expect(response.status).toBe(400); + }); + + it('should respond with 400 for an overly long URL', async () => { + const url = new URL('https://dashboard.test/api/qr-code'); + url.searchParams.set('url', `https://example.com/${'a'.repeat(3000)}`); + url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); + + const response = await GET(new NextRequest(url)); + + expect(response.status).toBe(400); + }); + + it('should respond with 503 when no signing key is configured', async () => { + const signedUrl = signQrCodeUrl('https://example.com/page'); + vi.stubEnv('QR_CODE_SECRET', ''); + + const response = await GET(new NextRequest(signedUrl!)); + + expect(response.status).toBe(503); + }); +}); diff --git a/apps/code-infra-dashboard/app/api/qr-code/route.ts b/apps/code-infra-dashboard/app/api/qr-code/route.ts new file mode 100644 index 000000000..f1eaf5714 --- /dev/null +++ b/apps/code-infra-dashboard/app/api/qr-code/route.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { generateQrCodeSvg, verifyQrCodeSignature, QR_CODE_MAX_URL_LENGTH } from '@/lib/qrCode'; + +function errorResponse(message: string, status: number): NextResponse { + return NextResponse.json( + { error: message }, + { status, headers: { 'Cache-Control': 'no-store' } }, + ); +} + +export async function GET(request: NextRequest): Promise { + const url = request.nextUrl.searchParams.get('url'); + const signature = request.nextUrl.searchParams.get('sig'); + + if (!url || !signature) { + return errorResponse('Missing url or sig query parameter', 400); + } + + if (url.length > QR_CODE_MAX_URL_LENGTH) { + return errorResponse('url query parameter is too long', 400); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return errorResponse('url query parameter is not a valid URL', 400); + } + + if (parsedUrl.protocol !== 'https:') { + return errorResponse('Only https URLs are supported', 400); + } + + if (!process.env.QR_CODE_SECRET) { + return errorResponse('QR code signing is not configured', 503); + } + + if (!verifyQrCodeSignature(url, signature)) { + return errorResponse('Invalid signature', 403); + } + + const svg = await generateQrCodeSvg(url); + + return new NextResponse(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + // QR output for a given URL never changes, cacheable forever (incl. GitHub camo) + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'", + }, + }); +} diff --git a/apps/code-infra-dashboard/package.json b/apps/code-infra-dashboard/package.json index 27d989ac3..210a78d69 100644 --- a/apps/code-infra-dashboard/package.json +++ b/apps/code-infra-dashboard/package.json @@ -25,6 +25,7 @@ "mysql2": "^3.22.3", "next": "^16.2.6", "pako": "^2.1.0", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.0", @@ -35,6 +36,7 @@ "@types/etag": "1.8.4", "@types/node": "22.19.0", "@types/pako": "2.0.4", + "@types/qrcode": "^1.5.6", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", diff --git a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts new file mode 100644 index 000000000..d88548aa7 --- /dev/null +++ b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts @@ -0,0 +1,124 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getOctokit } from '@/lib/github'; +import { verifyQrCodeSignature } from '@/lib/qrCode'; +import { generateDeployPreviewReport } from './deployPreviewReport'; +import type { ReportOptions } from './types'; + +vi.mock('@/lib/github', () => ({ + getOctokit: vi.fn(), +})); + +const mockGetOctokit = vi.mocked(getOctokit); + +const mockOctokit = { + pulls: { + listFiles: vi.fn(), + }, +}; + +function reportOptions(repo: string, prNumber: number): ReportOptions { + return { + repo, + prNumber, + commitSha: 'abc123', + pr: { base: { sha: 'def456', ref: 'master' } }, + baseCandidates: ['def456'], + }; +} + +describe('generateDeployPreviewReport', () => { + beforeEach(() => { + vi.stubEnv('QR_CODE_SECRET', 'test-secret'); + mockGetOctokit.mockReturnValue(mockOctokit as any); + mockOctokit.pulls.listFiles.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('should return null for repos without netlify docs config', async () => { + const report = await generateDeployPreviewReport(reportOptions('mui/base-ui', 1)); + + expect(report).toBeNull(); + }); + + it('should link the preview root with a QR code when no doc files changed', async () => { + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + const previewUrl = 'https://deploy-preview-42--material-ui.netlify.app/'; + expect(report?.content).toContain('## Deploy preview'); + expect(report?.content).toContain( + `
${previewUrl}`, + ); + }); + + it('should wrap each changed doc page in a collapsible QR code link', async () => { + mockOctokit.pulls.listFiles.mockResolvedValue({ + data: [ + { filename: 'docs/data/material/components/buttons/buttons.md', status: 'modified' }, + { filename: 'packages/mui-material/src/Button/Button.tsx', status: 'modified' }, + ], + }); + + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + const pageUrl = + 'https://deploy-preview-42--material-ui.netlify.app/material-ui/components/buttons'; + expect(report?.content).toContain( + `-
docs/data/material/components/buttons/buttons.md`, + ); + expect(report?.content).not.toContain('Button.tsx'); + }); + + it('should embed a verifiable signed QR code URL', async () => { + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + const imgSrc = report?.content.match(/ { + mockOctokit.pulls.listFiles.mockResolvedValue({ + data: [{ filename: 'docs/data/material/components/buttons/buttons.md', status: 'removed' }], + }); + + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + expect(report?.content).not.toContain('buttons.md'); + }); + + it('should cap the number of doc links', async () => { + mockOctokit.pulls.listFiles.mockResolvedValue({ + data: Array.from({ length: 10 }, (unused, index) => ({ + filename: `docs/data/material/components/page-${index}/page-${index}.md`, + status: 'modified', + })), + }); + + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + expect(report?.content.match(/
/g)).toHaveLength(5); + }); + + it('should fall back to plain links when no signing key is configured', async () => { + vi.stubEnv('QR_CODE_SECRET', ''); + mockOctokit.pulls.listFiles.mockResolvedValue({ + data: [{ filename: 'docs/data/material/components/buttons/buttons.md', status: 'modified' }], + }); + + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + expect(report?.content).toContain( + '- [docs/data/material/components/buttons/buttons.md](https://deploy-preview-42--material-ui.netlify.app/material-ui/components/buttons)', + ); + expect(report?.content).not.toContain('
'); + }); +}); diff --git a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts index 98d279691..4b76cdf3b 100644 --- a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts +++ b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts @@ -1,11 +1,25 @@ import { getOctokit } from '@/lib/github'; import { repositories } from '@/constants'; +import { signQrCodeUrl } from '@/lib/qrCode'; import type { ReportOptions, ReportResult } from './types'; export const DEPLOY_PREVIEW_SECTION_TITLE = 'Deploy preview'; const MAX_DOC_LINKS = 5; +/** + * Formats a link with a collapsible QR code for opening it on a phone. + * Falls back to a plain markdown link when no signing key is configured. + * Single-line HTML so it renders correctly inside markdown list items. + */ +function formatLinkWithQr(label: string, url: string): string { + const qrCodeUrl = signQrCodeUrl(url); + if (!qrCodeUrl) { + return `[${label}](${url})`; + } + return `
${label}QR code for ${label}
`; +} + export async function generateDeployPreviewReport( options: ReportOptions, ): Promise { @@ -24,7 +38,9 @@ export async function generateDeployPreviewReport( const previewUrl = `https://deploy-preview-${prNumber}--${siteId}.netlify.app/`; if (!formatDocPath) { - return { content: `## ${DEPLOY_PREVIEW_SECTION_TITLE}\n\n${previewUrl}` }; + return { + content: `## ${DEPLOY_PREVIEW_SECTION_TITLE}\n\n${formatLinkWithQr(previewUrl, previewUrl)}`, + }; } const [owner, repoSegment] = repo.split('/'); @@ -44,7 +60,7 @@ export async function generateDeployPreviewReport( } const docPath = formatDocPath(file.filename); if (docPath) { - docLinks.push({ filePath: file.filename, url: `${previewUrl}${docPath}` }); + docLinks.push({ filePath: file.filename, url: new URL(docPath, previewUrl).toString() }); if (docLinks.length >= MAX_DOC_LINKS) { break; } @@ -54,9 +70,9 @@ export async function generateDeployPreviewReport( let markdown = `## ${DEPLOY_PREVIEW_SECTION_TITLE}\n\n`; if (docLinks.length > 0) { - markdown += docLinks.map((link) => `- [${link.filePath}](${link.url})`).join('\n'); + markdown += docLinks.map((link) => `- ${formatLinkWithQr(link.filePath, link.url)}`).join('\n'); } else { - markdown += previewUrl; + markdown += formatLinkWithQr(previewUrl, previewUrl); } return { content: markdown }; diff --git a/apps/code-infra-dashboard/src/lib/qrCode.test.ts b/apps/code-infra-dashboard/src/lib/qrCode.test.ts new file mode 100644 index 000000000..615afc6a5 --- /dev/null +++ b/apps/code-infra-dashboard/src/lib/qrCode.test.ts @@ -0,0 +1,101 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { DASHBOARD_ORIGIN } from '@/constants'; +import { signQrCodeUrl, verifyQrCodeSignature, generateQrCodeSvg } from './qrCode'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('signQrCodeUrl', () => { + beforeEach(() => { + vi.stubEnv('QR_CODE_SECRET', 'test-secret'); + }); + + it('should produce a URL to the qr-code endpoint on the dashboard origin', () => { + const signedUrl = signQrCodeUrl('https://example.com/page'); + + expect(signedUrl).not.toBeNull(); + const parsed = new URL(signedUrl!); + expect(parsed.origin).toBe(new URL(DASHBOARD_ORIGIN).origin); + expect(parsed.pathname).toBe('/api/qr-code'); + expect(parsed.searchParams.get('url')).toBe('https://example.com/page'); + expect(parsed.searchParams.get('sig')).toBeTruthy(); + }); + + it('should be deterministic for the same URL', () => { + expect(signQrCodeUrl('https://example.com/page')).toBe( + signQrCodeUrl('https://example.com/page'), + ); + }); + + it('should return null when no signing key is configured', () => { + vi.stubEnv('QR_CODE_SECRET', ''); + + expect(signQrCodeUrl('https://example.com/page')).toBeNull(); + }); +}); + +describe('verifyQrCodeSignature', () => { + beforeEach(() => { + vi.stubEnv('QR_CODE_SECRET', 'test-secret'); + }); + + function signAndExtractSignature(targetUrl: string): string { + const signedUrl = signQrCodeUrl(targetUrl); + return new URL(signedUrl!).searchParams.get('sig')!; + } + + it('should accept a signature produced by signQrCodeUrl', () => { + const signature = signAndExtractSignature('https://example.com/page'); + + expect(verifyQrCodeSignature('https://example.com/page', signature)).toBe(true); + }); + + it('should reject a signature for a different URL', () => { + const signature = signAndExtractSignature('https://example.com/page'); + + expect(verifyQrCodeSignature('https://example.com/other', signature)).toBe(false); + }); + + it('should reject a tampered signature', () => { + const signature = signAndExtractSignature('https://example.com/page'); + const tampered = (signature[0] === 'A' ? 'B' : 'A') + signature.slice(1); + + expect(verifyQrCodeSignature('https://example.com/page', tampered)).toBe(false); + }); + + it('should reject signatures with the wrong length', () => { + const signature = signAndExtractSignature('https://example.com/page'); + + expect(verifyQrCodeSignature('https://example.com/page', signature.slice(0, 10))).toBe(false); + expect(verifyQrCodeSignature('https://example.com/page', `${signature}AAAA`)).toBe(false); + }); + + it('should reject garbage input without throwing', () => { + expect(verifyQrCodeSignature('https://example.com/page', '!!!not-base64url!!!')).toBe(false); + expect(verifyQrCodeSignature('https://example.com/page', '')).toBe(false); + }); + + it('should reject signatures made with a different key', () => { + const signature = signAndExtractSignature('https://example.com/page'); + vi.stubEnv('QR_CODE_SECRET', 'other-secret'); + + expect(verifyQrCodeSignature('https://example.com/page', signature)).toBe(false); + }); + + it('should reject everything when no signing key is configured', () => { + const signature = signAndExtractSignature('https://example.com/page'); + vi.stubEnv('QR_CODE_SECRET', ''); + + expect(verifyQrCodeSignature('https://example.com/page', signature)).toBe(false); + }); +}); + +describe('generateQrCodeSvg', () => { + it('should render a small SVG image', async () => { + const svg = await generateQrCodeSvg('https://deploy-preview-1--material-ui.netlify.app/'); + + expect(svg).toContain(' { + return QRCode.toString(targetUrl, { type: 'svg', errorCorrectionLevel: 'L', margin: 2 }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbff558d2..dc3b92208 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: pako: specifier: ^2.1.0 version: 2.1.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.2.6 version: 19.2.6 @@ -206,6 +209,9 @@ importers: '@types/pako': specifier: 2.0.4 version: 2.0.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: 19.2.14 version: 19.2.14 @@ -5212,6 +5218,9 @@ packages: '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -6352,6 +6361,9 @@ packages: resolution: {integrity: sha512-fPWgBqpp9ctiOQCkE5yjYGzv11ZU55g6ahEgr3COiio6dXdt1mbchCPXQrSR2Y9sZqfi8L7QD3+UosgXVIuPdg==} engines: {node: '>=20'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -6844,6 +6856,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -9984,6 +9999,10 @@ packages: pluralize@3.1.0: resolution: {integrity: sha512-2wcybwjwXOzGI1rlxWtlcs0/nSYK0OzNPqsg35TKxJFQlGhFu3cZ1x7EHS4r4bubQlhzyF4YxxlJqQnIhkUQCw==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -10174,6 +10193,11 @@ packages: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} engines: {node: '>=20'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -10507,6 +10531,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -10693,6 +10720,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -11825,6 +11855,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -11969,6 +12002,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -12002,6 +12038,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -12014,6 +12054,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -16669,6 +16713,10 @@ snapshots: dependencies: '@types/retry': 0.12.2 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.0 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -18017,6 +18065,12 @@ snapshots: is64bit: 2.0.0 powershell-utils: 0.2.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -18512,6 +18566,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -22587,6 +22643,8 @@ snapshots: pluralize@3.1.0: {} + pngjs@5.0.0: {} + pngjs@7.0.0: {} possible-typed-array-names@1.1.0: {} @@ -22742,6 +22800,12 @@ snapshots: dependencies: hookified: 1.15.1 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -23295,6 +23359,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} reselect@5.2.0: {} @@ -23539,6 +23605,8 @@ snapshots: server-only@0.0.1: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -24883,6 +24951,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -25000,6 +25070,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -25020,12 +25092,31 @@ snapshots: yaml@2.8.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/render.yaml b/render.yaml index 0f84f75fc..4da39903a 100644 --- a/render.yaml +++ b/render.yaml @@ -60,6 +60,8 @@ services: envVars: - key: NODE_VERSION value: 22 + - key: QR_CODE_SECRET + generateValue: true - fromGroup: code-infra-dashboard AWS user - fromGroup: MUI X License - fromGroup: CircleCI API token From 06a3166e5143eea87180cc22d489badfe365f9c1 Mon Sep 17 00:00:00 2001 From: Janpot <2109932+Janpot@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:25:13 +0200 Subject: [PATCH 2/3] Address review: escape HTML, framework-agnostic handler, bound sig length --- .../app/api/qr-code/route.test.ts | 25 +++++++++++++------ .../app/api/qr-code/route.ts | 24 ++++++++++-------- .../lib/ciReports/deployPreviewReport.test.ts | 14 ++++++++++- .../src/lib/ciReports/deployPreviewReport.ts | 4 ++- apps/code-infra-dashboard/src/utils/dom.ts | 8 ++++++ 5 files changed, 55 insertions(+), 20 deletions(-) diff --git a/apps/code-infra-dashboard/app/api/qr-code/route.test.ts b/apps/code-infra-dashboard/app/api/qr-code/route.test.ts index 9cb83d8bd..1935f1944 100644 --- a/apps/code-infra-dashboard/app/api/qr-code/route.test.ts +++ b/apps/code-infra-dashboard/app/api/qr-code/route.test.ts @@ -1,5 +1,4 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { NextRequest } from 'next/server'; import { signQrCodeUrl } from '@/lib/qrCode'; import { GET } from './route'; @@ -15,7 +14,7 @@ describe('GET /api/qr-code', () => { it('should respond with a cacheable SVG for a validly signed URL', async () => { const signedUrl = signQrCodeUrl('https://example.com/page'); - const response = await GET(new NextRequest(signedUrl!)); + const response = await GET(new Request(signedUrl!)); expect(response.status).toBe(200); expect(response.headers.get('Content-Type')).toBe('image/svg+xml'); @@ -28,7 +27,7 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', 'https://example.com/page'); url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); - const response = await GET(new NextRequest(url)); + const response = await GET(new Request(url)); expect(response.status).toBe(403); expect(response.headers.get('Cache-Control')).toBe('no-store'); @@ -38,13 +37,13 @@ describe('GET /api/qr-code', () => { const signedUrl = new URL(signQrCodeUrl('https://example.com/page')!); signedUrl.searchParams.set('url', 'https://example.com/other'); - const response = await GET(new NextRequest(signedUrl)); + const response = await GET(new Request(signedUrl)); expect(response.status).toBe(403); }); it('should respond with 400 when query parameters are missing', async () => { - const response = await GET(new NextRequest('https://dashboard.test/api/qr-code')); + const response = await GET(new Request('https://dashboard.test/api/qr-code')); expect(response.status).toBe(400); expect(response.headers.get('Cache-Control')).toBe('no-store'); @@ -55,7 +54,7 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', 'http://example.com/page'); url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); - const response = await GET(new NextRequest(url)); + const response = await GET(new Request(url)); expect(response.status).toBe(400); }); @@ -65,7 +64,17 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', `https://example.com/${'a'.repeat(3000)}`); url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); - const response = await GET(new NextRequest(url)); + const response = await GET(new Request(url)); + + expect(response.status).toBe(400); + }); + + it('should respond with 400 for an overly long signature', async () => { + const url = new URL('https://dashboard.test/api/qr-code'); + url.searchParams.set('url', 'https://example.com/page'); + url.searchParams.set('sig', 'A'.repeat(100)); + + const response = await GET(new Request(url)); expect(response.status).toBe(400); }); @@ -74,7 +83,7 @@ describe('GET /api/qr-code', () => { const signedUrl = signQrCodeUrl('https://example.com/page'); vi.stubEnv('QR_CODE_SECRET', ''); - const response = await GET(new NextRequest(signedUrl!)); + const response = await GET(new Request(signedUrl!)); expect(response.status).toBe(503); }); diff --git a/apps/code-infra-dashboard/app/api/qr-code/route.ts b/apps/code-infra-dashboard/app/api/qr-code/route.ts index f1eaf5714..a0c72ff73 100644 --- a/apps/code-infra-dashboard/app/api/qr-code/route.ts +++ b/apps/code-infra-dashboard/app/api/qr-code/route.ts @@ -1,16 +1,16 @@ -import { type NextRequest, NextResponse } from 'next/server'; import { generateQrCodeSvg, verifyQrCodeSignature, QR_CODE_MAX_URL_LENGTH } from '@/lib/qrCode'; -function errorResponse(message: string, status: number): NextResponse { - return NextResponse.json( - { error: message }, - { status, headers: { 'Cache-Control': 'no-store' } }, - ); +// A valid base64url signature is 22 chars; allow some slack but reject obvious abuse. +const MAX_SIGNATURE_LENGTH = 64; + +function errorResponse(message: string, status: number): Response { + return Response.json({ error: message }, { status, headers: { 'Cache-Control': 'no-store' } }); } -export async function GET(request: NextRequest): Promise { - const url = request.nextUrl.searchParams.get('url'); - const signature = request.nextUrl.searchParams.get('sig'); +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + const signature = searchParams.get('sig'); if (!url || !signature) { return errorResponse('Missing url or sig query parameter', 400); @@ -20,6 +20,10 @@ export async function GET(request: NextRequest): Promise { return errorResponse('url query parameter is too long', 400); } + if (signature.length > MAX_SIGNATURE_LENGTH) { + return errorResponse('sig query parameter is too long', 400); + } + let parsedUrl: URL; try { parsedUrl = new URL(url); @@ -41,7 +45,7 @@ export async function GET(request: NextRequest): Promise { const svg = await generateQrCodeSvg(url); - return new NextResponse(svg, { + return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml', // QR output for a given URL never changes, cacheable forever (incl. GitHub camo) diff --git a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts index d88548aa7..38d131bd7 100644 --- a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts +++ b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.test.ts @@ -77,7 +77,8 @@ describe('generateDeployPreviewReport', () => { const imgSrc = report?.content.match(/ { expect(report?.content.match(/
/g)).toHaveLength(5); }); + it('should escape HTML-special characters in the file path', async () => { + mockOctokit.pulls.listFiles.mockResolvedValue({ + data: [{ filename: 'docs/data/material/components/a&"c/page.md', status: 'modified' }], + }); + + const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42)); + + expect(report?.content).toContain('a<b>&"c'); + expect(report?.content).not.toContain('a'); + }); + it('should fall back to plain links when no signing key is configured', async () => { vi.stubEnv('QR_CODE_SECRET', ''); mockOctokit.pulls.listFiles.mockResolvedValue({ diff --git a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts index 4b76cdf3b..0103b8d28 100644 --- a/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts +++ b/apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts @@ -1,6 +1,7 @@ import { getOctokit } from '@/lib/github'; import { repositories } from '@/constants'; import { signQrCodeUrl } from '@/lib/qrCode'; +import { escapeHtml } from '@/utils/dom'; import type { ReportOptions, ReportResult } from './types'; export const DEPLOY_PREVIEW_SECTION_TITLE = 'Deploy preview'; @@ -17,7 +18,8 @@ function formatLinkWithQr(label: string, url: string): string { if (!qrCodeUrl) { return `[${label}](${url})`; } - return `
${label}QR code for ${label}
`; + const safeLabel = escapeHtml(label); + return `
${safeLabel}QR code for ${safeLabel}
`; } export async function generateDeployPreviewReport( diff --git a/apps/code-infra-dashboard/src/utils/dom.ts b/apps/code-infra-dashboard/src/utils/dom.ts index 814490bc9..231037dab 100644 --- a/apps/code-infra-dashboard/src/utils/dom.ts +++ b/apps/code-infra-dashboard/src/utils/dom.ts @@ -5,6 +5,14 @@ export function escapeHtmlId(str: string): string { .replace(/^-|-$/g, ''); } +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + export function scrollToHash(): void { const { hash } = window.location; if (hash) { From e69ba9ece4dc82b2f5234bdc0175782b80a4cec3 Mon Sep 17 00:00:00 2001 From: Janpot <2109932+Janpot@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:34:48 +0200 Subject: [PATCH 3/3] Revert framework-agnostic handler change, keep sig length guard --- .../app/api/qr-code/route.test.ts | 17 +++++++++-------- .../app/api/qr-code/route.ts | 17 ++++++++++------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/code-infra-dashboard/app/api/qr-code/route.test.ts b/apps/code-infra-dashboard/app/api/qr-code/route.test.ts index 1935f1944..b50a7beaf 100644 --- a/apps/code-infra-dashboard/app/api/qr-code/route.test.ts +++ b/apps/code-infra-dashboard/app/api/qr-code/route.test.ts @@ -1,4 +1,5 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; import { signQrCodeUrl } from '@/lib/qrCode'; import { GET } from './route'; @@ -14,7 +15,7 @@ describe('GET /api/qr-code', () => { it('should respond with a cacheable SVG for a validly signed URL', async () => { const signedUrl = signQrCodeUrl('https://example.com/page'); - const response = await GET(new Request(signedUrl!)); + const response = await GET(new NextRequest(signedUrl!)); expect(response.status).toBe(200); expect(response.headers.get('Content-Type')).toBe('image/svg+xml'); @@ -27,7 +28,7 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', 'https://example.com/page'); url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); - const response = await GET(new Request(url)); + const response = await GET(new NextRequest(url)); expect(response.status).toBe(403); expect(response.headers.get('Cache-Control')).toBe('no-store'); @@ -37,13 +38,13 @@ describe('GET /api/qr-code', () => { const signedUrl = new URL(signQrCodeUrl('https://example.com/page')!); signedUrl.searchParams.set('url', 'https://example.com/other'); - const response = await GET(new Request(signedUrl)); + const response = await GET(new NextRequest(signedUrl)); expect(response.status).toBe(403); }); it('should respond with 400 when query parameters are missing', async () => { - const response = await GET(new Request('https://dashboard.test/api/qr-code')); + const response = await GET(new NextRequest('https://dashboard.test/api/qr-code')); expect(response.status).toBe(400); expect(response.headers.get('Cache-Control')).toBe('no-store'); @@ -54,7 +55,7 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', 'http://example.com/page'); url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); - const response = await GET(new Request(url)); + const response = await GET(new NextRequest(url)); expect(response.status).toBe(400); }); @@ -64,7 +65,7 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', `https://example.com/${'a'.repeat(3000)}`); url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA'); - const response = await GET(new Request(url)); + const response = await GET(new NextRequest(url)); expect(response.status).toBe(400); }); @@ -74,7 +75,7 @@ describe('GET /api/qr-code', () => { url.searchParams.set('url', 'https://example.com/page'); url.searchParams.set('sig', 'A'.repeat(100)); - const response = await GET(new Request(url)); + const response = await GET(new NextRequest(url)); expect(response.status).toBe(400); }); @@ -83,7 +84,7 @@ describe('GET /api/qr-code', () => { const signedUrl = signQrCodeUrl('https://example.com/page'); vi.stubEnv('QR_CODE_SECRET', ''); - const response = await GET(new Request(signedUrl!)); + const response = await GET(new NextRequest(signedUrl!)); expect(response.status).toBe(503); }); diff --git a/apps/code-infra-dashboard/app/api/qr-code/route.ts b/apps/code-infra-dashboard/app/api/qr-code/route.ts index a0c72ff73..580676489 100644 --- a/apps/code-infra-dashboard/app/api/qr-code/route.ts +++ b/apps/code-infra-dashboard/app/api/qr-code/route.ts @@ -1,16 +1,19 @@ +import { type NextRequest, NextResponse } from 'next/server'; import { generateQrCodeSvg, verifyQrCodeSignature, QR_CODE_MAX_URL_LENGTH } from '@/lib/qrCode'; // A valid base64url signature is 22 chars; allow some slack but reject obvious abuse. const MAX_SIGNATURE_LENGTH = 64; -function errorResponse(message: string, status: number): Response { - return Response.json({ error: message }, { status, headers: { 'Cache-Control': 'no-store' } }); +function errorResponse(message: string, status: number): NextResponse { + return NextResponse.json( + { error: message }, + { status, headers: { 'Cache-Control': 'no-store' } }, + ); } -export async function GET(request: Request): Promise { - const { searchParams } = new URL(request.url); - const url = searchParams.get('url'); - const signature = searchParams.get('sig'); +export async function GET(request: NextRequest): Promise { + const url = request.nextUrl.searchParams.get('url'); + const signature = request.nextUrl.searchParams.get('sig'); if (!url || !signature) { return errorResponse('Missing url or sig query parameter', 400); @@ -45,7 +48,7 @@ export async function GET(request: Request): Promise { const svg = await generateQrCodeSvg(url); - return new Response(svg, { + return new NextResponse(svg, { headers: { 'Content-Type': 'image/svg+xml', // QR output for a given URL never changes, cacheable forever (incl. GitHub camo)