diff --git a/README.md b/README.md index 6a51cff..7af0c22 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Bundlers that do not analyse `new URL(..., import.meta.url)`, esbuild among them - The value is a directory URL, relative to the page or absolute. A trailing slash is added when missing. - Individual runtime URL attributes take precedence over `assets-base` for their respective files, including `worker-src`. - The attribute is read when the viewer is (re)initialized, so set it before the element is attached to the document. +- All runtime files must come from the same package release as the element. The viewer HTML is built into the element, and mixing PDF.js versions breaks the viewer. PDF.js annotation icons are a separate case: the viewer loads them at runtime as `imageResourcesPath + 'annotation-' + name + '.svg'`, a dynamic name no bundler can emit. If `assets-base` is set, `imageResourcesPath` defaults to `assets-base + 'images/'`. Otherwise serve the package `dist/images` folder and set `image-resources-path` explicitly. diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index b557e34..9fe0183 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -29,24 +29,17 @@ const DEFAULT_PAPER_AND_INK_THEME_CSS_SRC = import.meta.env.DEV ? new URL('./themes/paper-and-ink.css', import.meta.url).href : new URL(/* @vite-ignore */ './paper-and-ink.css', import.meta.url).href -// File names inside the package `dist` folder, used when `assets-base` is set. -const DIST_FILE_NAMES = { - workerSrc: 'pdf.worker.min.mjs', - pdfjsLibSrc: 'pdf.mjs', - viewerSrc: 'viewer.mjs', - viewerCssSrc: 'viewer.css', - themeCssSrc: 'paper-and-ink.css' +// Runtime files of the viewer: the attribute that overrides each URL, the file name inside the +// package `dist` folder (used when `assets-base` is set) and the bundled default. +const RUNTIME_FILES = { + workerSrc: { attribute: 'worker-src', fileName: 'pdf.worker.min.mjs', defaultUrl: DEFAULT_BUILT_IN_WORKER_SRC }, + pdfjsLibSrc: { attribute: 'pdfjs-lib-src', fileName: 'pdf.mjs', defaultUrl: DEFAULT_PDF_SRC }, + viewerSrc: { attribute: 'viewer-src', fileName: 'viewer.mjs', defaultUrl: DEFAULT_VIEWER_SRC }, + viewerCssSrc: { attribute: 'viewer-css-src', fileName: 'viewer.css', defaultUrl: DEFAULT_VIEWER_CSS_SRC }, + themeCssSrc: { attribute: 'theme-css-src', fileName: 'paper-and-ink.css', defaultUrl: DEFAULT_PAPER_AND_INK_THEME_CSS_SRC } } as const -type RuntimeFileUrls = Record - -const DEFAULT_RUNTIME_FILE_URLS: RuntimeFileUrls = { - workerSrc: DEFAULT_BUILT_IN_WORKER_SRC, - pdfjsLibSrc: DEFAULT_PDF_SRC, - viewerSrc: DEFAULT_VIEWER_SRC, - viewerCssSrc: DEFAULT_VIEWER_CSS_SRC, - themeCssSrc: DEFAULT_PAPER_AND_INK_THEME_CSS_SRC -} +type RuntimeFileUrls = Record const DEFAULTS = { src: '', @@ -141,12 +134,12 @@ export class PdfjsViewerElement extends HTMLElement { private getRuntimeFileUrls(): RuntimeFileUrls { const assetsBase = this.getAssetsBase() - const resolveFile = (field: keyof typeof DIST_FILE_NAMES) => { - const attr = this.getAttribute(field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`))?.trim() + const resolveFile = (field: keyof typeof RUNTIME_FILES) => { + const { attribute, fileName, defaultUrl } = RUNTIME_FILES[field] + const attr = this.getAttribute(attribute)?.trim() if (attr) return this.getFullPath(attr) - if (!assetsBase) return DEFAULT_RUNTIME_FILE_URLS[field] + if (!assetsBase) return defaultUrl - const fileName = DIST_FILE_NAMES[field] try { return new URL(fileName, assetsBase).href } catch { @@ -414,7 +407,7 @@ export class PdfjsViewerElement extends HTMLElement { return '#' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&') } - private buildViewerEntry = async () => { + private buildViewerEntry = async (runtimeFileUrls: RuntimeFileUrls) => { return new Promise(async (resolve) => { const viewerEntry = await import('virtual:pdfjs-viewer-html') const resolveHttpOrigin = (url: string) => { @@ -432,7 +425,11 @@ export class PdfjsViewerElement extends HTMLElement { const toSourceList = (sources: string[]) => Array.from(new Set(sources.filter(Boolean))).join(' ') - const { pdfjsLibSrc, viewerSrc, viewerCssSrc, themeCssSrc, workerSrc } = this.getRuntimeFileUrls() + // Attribute values that are not valid URLs reach the markup as written, so escape them. + const escapeAttribute = (value: string) => + value.replace(/&/g, '&').replace(/"/g, '"') + + const { pdfjsLibSrc, viewerSrc, viewerCssSrc, themeCssSrc, workerSrc } = runtimeFileUrls const workerSrcOrigin = resolveHttpOrigin(workerSrc) const scriptSources = toSourceList([ @@ -478,8 +475,8 @@ export class PdfjsViewerElement extends HTMLElement { const completeHtml = viewerHtmlWithSrcdocCsp .replace('', ` - - + + ${Array.from(this.viewerStyles).map(style => ``).join('\n')} `) this.iframe.addEventListener('load', () => resolve(), { once: true }) @@ -500,7 +497,7 @@ export class PdfjsViewerElement extends HTMLElement { } } - private buildViewerApp = async () => { + private buildViewerApp = async (runtimeFileUrls: RuntimeFileUrls) => { await this.applyIframeHash() // Set viewer options as soon as PDFViewerApplication is created. @@ -508,20 +505,23 @@ export class PdfjsViewerElement extends HTMLElement { await this.injectLocaleData() - const { pdfjsLibSrc, viewerSrc } = this.getRuntimeFileUrls() - await this.injectScript(pdfjsLibSrc) - await this.injectScript(viewerSrc) + await this.injectScript(runtimeFileUrls.pdfjsLibSrc) + await this.injectScript(runtimeFileUrls.viewerSrc) return await setupPromise } + // Runtime file URLs are read once per build, so the srcdoc CSP always allows the files it loads. + private buildViewer = async () => { + const runtimeFileUrls = this.getRuntimeFileUrls() + await this.buildViewerEntry(runtimeFileUrls) + return await this.buildViewerApp(runtimeFileUrls) + } + async connectedCallback() { this.iframe = this.shadowRoot?.querySelector('iframe') as PdfjsViewerElementIframe this.iframe.setAttribute('title', this.getAttribute('iframe-title') || DEFAULTS.iframeTitle) - this.initPromise = (async () => { - await this.buildViewerEntry() - return await this.buildViewerApp() - })() + this.initPromise = this.buildViewer() } disconnectedCallback() { @@ -584,10 +584,7 @@ export class PdfjsViewerElement extends HTMLElement { await this.injectLocaleData() const didApplyRuntimeLocale = await this.applyLocaleAtRuntime() if (!didApplyRuntimeLocale) { - this.initPromise = (async () => { - await this.buildViewerEntry() - return await this.buildViewerApp() - })() + this.initPromise = this.buildViewer() await this.initPromise } await this.applyIframeHash() diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 7127e97..8c9a096 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -282,6 +282,51 @@ describe('Basic tests', async () => { expect(getViewerElement()).exist }) + it('should include individual runtime URL origins in CSP', async () => { + // Hosts under `.invalid` never resolve: the CSP is written before the scripts fail to load. + document.body.innerHTML = ` + + ` + const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement + await viewer.initPromise.catch(() => {}) + + const cspMeta = getIframe().contentDocument?.querySelector('meta[http-equiv="Content-Security-Policy"]') + const csp = cspMeta?.getAttribute('content') || '' + expect(csp).toMatch(/script-src-elem[^;]*https:\/\/pdfjs-lib\.invalid/) + expect(csp).toMatch(/script-src-elem[^;]*https:\/\/viewer\.invalid/) + expect(csp).toMatch(/style-src[^;]*https:\/\/viewer-css\.invalid/) + expect(csp).toMatch(/style-src[^;]*https:\/\/theme-css\.invalid/) + }) + + it('should keep the runtime URLs read at connection for the whole build', async () => { + document.body.innerHTML = `` + const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement + viewer.setAttribute('pdfjs-lib-src', 'https://pdfjs-lib.invalid/pdf.mjs') + await viewer.initPromise + + expect(getIframe().contentDocument!.querySelector('script[src^="https://pdfjs-lib.invalid"]')).not.exist + expect(getViewerElement()).exist + }) + + it('should not inject markup from an invalid stylesheet URL', async () => { + // The theme stylesheet is optional, so the viewer still initializes without it. + const invalidUrl = 'http://[invalid">' + await mountViewer(` + ` + ) + + const doc = getIframe().contentDocument! + const stylesheets = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')) + .map((link) => link.getAttribute('href')) + expect(stylesheets).toContain(invalidUrl) + expect(stylesheets).not.toContain('/injected.css') + expect(getViewerElement()).exist + }) + it('should open a new document when src changes at runtime', async () => { const viewerApp = await mountViewer(` ` diff --git a/types/pdfjs-viewer-element.d.ts b/types/pdfjs-viewer-element.d.ts index ec81124..b24b067 100644 --- a/types/pdfjs-viewer-element.d.ts +++ b/types/pdfjs-viewer-element.d.ts @@ -48,6 +48,7 @@ export declare class PdfjsViewerElement extends HTMLElement { private buildViewerEntry; private setupViewerApp; private buildViewerApp; + private buildViewer; connectedCallback(): Promise; disconnectedCallback(): void; attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): Promise;