From dd08f901aedd7f2a694e377cefdd954956f8b006 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 14 Aug 2026 09:51:11 +0800 Subject: [PATCH] feat: add inline PDF viewer for Custom_PDF objects Add a /pdf proxy route that forces Content-Disposition inline so PDFs render instead of downloading, and a web viewer that embeds the document in an iframe with a standalone open-in-new-tab link. --- apps/proxy/src/index.ts | 2 + apps/proxy/src/routes/pdf.test.ts | 65 +++++++++++++++++++ apps/proxy/src/routes/pdf.ts | 56 ++++++++++++++++ apps/web/src/components/viewers/PdfViewer.tsx | 43 ++++++++++++ apps/web/src/components/viewers/register.ts | 4 +- apps/web/vite.config.ts | 1 + packages/http/src/asset.ts | 5 ++ packages/http/src/index.ts | 2 +- 8 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 apps/proxy/src/routes/pdf.test.ts create mode 100644 apps/proxy/src/routes/pdf.ts create mode 100644 apps/web/src/components/viewers/PdfViewer.tsx diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts index b7fb197..bd58050 100644 --- a/apps/proxy/src/index.ts +++ b/apps/proxy/src/index.ts @@ -8,6 +8,7 @@ import { setGamesRoot } from './config.js'; import asset from './routes/asset.js'; import health from './routes/health.js'; import items from './routes/items.js'; +import pdf from './routes/pdf.js'; import search from './routes/search.js'; import trace from './routes/trace.js'; @@ -27,6 +28,7 @@ app.route('/health', health); app.route('/search', search); app.route('/items', items); app.route('/asset', asset); +app.route('/pdf', pdf); app.route('/trace', trace); serve( diff --git a/apps/proxy/src/routes/pdf.test.ts b/apps/proxy/src/routes/pdf.test.ts new file mode 100644 index 0000000..dd47541 --- /dev/null +++ b/apps/proxy/src/routes/pdf.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'node:path'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import pdf from './pdf.js'; +import { setGamesRoot } from '../config.js'; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'games-')); + setGamesRoot(dir); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + setGamesRoot(undefined); +}); + +describe('pdf route', () => { + it('rejects a missing url', async () => { + const res = await pdf.request('/'); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Missing url query param' }); + }); + + it('rejects a non-http url', async () => { + const res = await pdf.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd'); + expect(res.status).toBe(400); + }); + + it('streams the fetched pdf inline with a pdf content-type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(new Uint8Array([1, 2, 3]), { + headers: { 'content-type': 'application/pdf' }, + }), + ), + ); + const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/pdf'); + expect(res.headers.get('content-disposition')).toBe('inline'); + expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer); + }); + + it('returns 502 when the upstream fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('error', { status: 500 })), + ); + const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf'); + expect(res.status).toBe(502); + }); + + it('serves a local game asset from GAMES_ROOT', async () => { + writeFileSync(path.join(dir, 'rules.pdf'), new Uint8Array([1, 2, 3])); + const res = await pdf.request('/?url=rules.pdf'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/pdf'); + expect(res.headers.get('content-disposition')).toBe('inline'); + expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer); + }); +}); \ No newline at end of file diff --git a/apps/proxy/src/routes/pdf.ts b/apps/proxy/src/routes/pdf.ts new file mode 100644 index 0000000..beb64cc --- /dev/null +++ b/apps/proxy/src/routes/pdf.ts @@ -0,0 +1,56 @@ +import { Hono } from 'hono'; +import { GAMES_ROOT } from '../config.js'; +import { resolveAsset } from './resolveAsset.js'; + +const app = new Hono(); + +/** + * Fetch a PDF and stream it back inline so the browser renders it instead of + * downloading. PDF hosts often send `Content-Disposition: attachment`, which + * forces a download even when the URL is opened in a tab or iframe. This route + * strips that header and forces `inline` so the PDF can be embedded in the web + * app's viewer. Like `/asset`, a relative URL (no scheme) is treated as a game + * asset path relative to `GAMES_ROOT`. + */ +app.get('/', async (c) => { + const raw = c.req.query('url'); + if (!raw) { + return c.json({ error: 'Missing url query param' }, 400); + } + + const result = await resolveAsset(raw, GAMES_ROOT); + if (!result.ok) { + return c.json( + { error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' }, + result.reason === 'not-found' ? 404 : 400, + ); + } + const asset = result.asset; + + // A local game asset is streamed from disk; a remote one is fetched. + let body: BodyInit; + if (asset.stream) { + body = asset.stream as unknown as BodyInit; + } else { + let res: Response; + try { + res = await fetch(asset.url!); + } catch (err) { + return c.json({ error: `Failed to fetch pdf: ${String(err)}` }, 502); + } + if (!res.ok) { + return c.json({ error: `Pdf responded ${res.status}` }, 502); + } + body = res.body!; + } + + return new Response(body, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'inline', + 'Cache-Control': 'public, max-age=86400', + }, + }); +}); + +export default app; \ No newline at end of file diff --git a/apps/web/src/components/viewers/PdfViewer.tsx b/apps/web/src/components/viewers/PdfViewer.tsx new file mode 100644 index 0000000..a635764 --- /dev/null +++ b/apps/web/src/components/viewers/PdfViewer.tsx @@ -0,0 +1,43 @@ +import type { TTSObject } from '@tts/shared'; +import { pdfUrl } from '@tts/http'; + +/** + * A PDF document (`Custom_PDF`). Renders the document inline in an iframe and + * offers a standalone link to open it in a new tab. The URL is routed through + * the proxy `/pdf` endpoint, which forces `Content-Disposition: inline` so the + * browser displays the PDF instead of downloading it. + */ +export default function PdfViewer({ object }: { object: TTSObject }) { + const url = object.CustomPDF?.PDFUrl; + + if (!url) { + return ( +

+ This object has no PDF URL. +

+ ); + } + + const proxied = pdfUrl(url); + + return ( +
+
+

{url}

+ + Open PDF in new tab + +
+