Documents: one previewer for every file — PDFs drawn with a vendored pdf.js, thumbnails, pictures and text inline, as paperless-ngx does #942
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#942
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Postulo keeps many kinds of document: CVs and letters it writes, files people upload, PDFs it froze when something was sent, and the renderings a capture kept. Each is shown in its own way, and most are not shown at all: they are downloaded. This issue asks for one previewer used everywhere a document or file appears. It is modelled on what paperless-ngx does, kept within the security rules Postulo already has.
Read against
mainat56fc9d531(2026-10-16).What exists today
documents/views.py:394CVPreviewView,:606CoverLetterPreviewView; framed bytemplates/documents/cv_detail.html:210andletter_detail.html:41<iframe class="document-frame">. The theme stylesheet is inlined under a CSP nonce (#232). The frame is allowed byFRAMEABLE_BY_OURSELVES(views.py:389, #293:frame-ancestors 'self'+X-Frame-Options: SAMEORIGINon these two views only).DraftMixin,views.py:150(the note at:153: "The preview is HTML, and HTML cannot show where a page ends")handed_over()(views.py:67). To see a page break you have to download a file and open it elsewhere.UploadedDocument,documents/models.py:662; listtemplates/documents/upload_list.html;UploadDownloadView,views.py:809as_attachment=True). There is no thumbnail and no inline view. Allowed types areintegrity.EXTENSIONS= pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg (documents/integrity.py:30). For Word and ODT files the text is extracted for search (UploadedDocument.text, #877).RenderedDocument,models.py:977;rendered_list.html,rendered_compare.html,cotton/sent_versions.html,application_detail.html:186;RenderedDownloadView,views.py:830FILE_POLICY=default-src 'none'; sandbox, so what you see depends on the browser's own PDF viewer under a sandboxing CSP, and some engines refuse to run their viewer there (to be checked per browser). Comparing two versions (rendered_compare.html) compares only the text; for the layout you download both.CapturedPage/RenderingKind(PNG, JPEG, WebP, PDF),jobs/models.py:1880–1960;jobs/page_views.py:121CapturedRenderingView;templates/jobs/capture_page.html:83<img>inside a scrolling box. A PDF is always an attachment, because it is a stranger's document. The kept source is never served as a page: it is text in a<pre>or atext/plaindownload (serve_private_text,core/files.py:211).serve_stored_picture,core/files.py:189(stored in the database, #662)<img>. Sanitised on the way in bycore/pictures.py(Pillow; see #777).api/routers/documents.py:335download_document/documents/{upload,rendered}/{pk}/downloadreturns an attachment. There is no preview or thumbnail endpoint.Constraints a previewer must keep. All of these are already in place:
OwnedObjectMixinmakes another account's file a 404.serve_private_file(core/files.py:121) checks thewithinowner folder (#663, #773), uses the type from the extension and never from a title (#771), and sendsnosniff,Cache-Control: private, no-storeandFILE_POLICYon every file. Only the inert types inHAND_OVER_TYPES(files.py:45) are handed to nginx or Apache (#813).config/settings/base.py:818):script-src 'self'with no inline scripts orstyle=attributes,img-src 'self' data:,connect-src 'self',worker-src 'self',frame-src 'self',frame-ancestors 'none',object-src 'none'. The browser suite runs under this policy and fails on a violation (#232).src/postulo/static/js/vendor/, which holds htmx 2.0.10, zxcvbn-ts, Leaflet 1.9.4 and Leaflet.markercluster 1.5.3. Each is pinned inpackage.json, copied bynpm run sync:vendor(scripts/sync-vendor.mjs) with itsLICENSE.txtbeside it, and listed inTHIRD-PARTY.md.tests/test_trademarks.pyholds the table to the tree. All other JavaScript isstatic/js/app.js.core/pictures.py. The Europass reader deliberately reads PDFs without a PDF library (plugins/europass/pdf.py).What paperless-ngx does
paperless-ngx is GPL-3.0. Its ideas are free to use. Its code could legally be combined into AGPL-3.0 Postulo (GPLv3 §13), but copying it would bring its notices and an Angular-shaped design, so take the ideas rather than the code.
PAPERLESS_OCR_OUTPUT_TYPE,PAPERLESS_ARCHIVE_FILE_GENERATION=auto|always|never). The viewer shows the archive version when one exists. See configuration.md and parsers/tesseract.py.PAPERLESS_TIKA_ENABLED, Word, Excel, PowerPoint, OpenDocument and RTF files go through Tika for their text and through Gotenberg's LibreOffice route to a PDF/A (parsers/tika.py). Mail is rendered through Gotenberg's Chromium route (parsers/mail.py). Without those services, these formats are not accepted at all. Plain text (.txt,.csv) gets no archive; it is shown as text (parsers/text.py).document.webpis used (documents/parsers.pymake_thumbnail_from_pdf). A text file's thumbnail is its first characters drawn with Pillow on a 500×700 image./api/documents/<id>/preview/(inline, the archive version unless?original=true),/thumb/(image/webp), and/download/(attachment, same?originalswitch). Preview and thumbnail carry ETag and Last-Modified withno-cache, so the browser revalidates and does not download again (documents/views.py).pdfjs-dist(6.3.289 insrc-ui/package.jsontoday), using pdf.js's ownPDFViewer/PDFSinglePageViewer,EventBus,PDFLinkServiceandPDFFindController. The text layer is on so text can be selected, and the worker is served from its own assets (GlobalWorkerOptions.workerSrc = 'assets/js/pdf.worker.min.mjs', with cMaps, ICC and wasm beside it). See pdf-viewer.component.ts.<img>.pre-wrapbox.<object>.About pdf.js. It is Apache-2.0, which is compatible with AGPL-3.0. Its notice goes beside the files, as for Leaflet. The current release is v6.4.299 (2026-10-03).
In v6,
src/display/api.jshas noisEvalSupportedoption any more. CVE-2024-4367 (eval in font rendering) was fixed in 4.2.67 by removingevalentirely, so no'unsafe-eval'is needed.Under Postulo's CSP it should fit as follows:
'self'is created directly as a module worker. pdf.js wraps a worker in ablob:URL only whenworkerSrcis on another origin, soworker-src 'self'is enough.'wasm-unsafe-eval'inscript-src. WithuseWasm: false, pdf.js uses its JavaScript fallbacks (openjpeg_nowasm_fallback.js), so the policy can stay unchanged.'self'. Styles are set through CSSOM, whichstyle-srcdoes not block.fetch()/XHR underconnect-src 'self', with credentials.All of this is to be confirmed by the browser suite running under the policy, as #232 does for every page.
Proposed design
1. One component,
<c-document-viewer>A single Cotton component, used everywhere a document or file is shown:
It takes the file's preview address, its kind (
pdf | image | text | html | none), a title, and the download address. It chooses the renderer. With no script, it falls back to a plain link: "Open" or "Download", plus the thumbnail where there is one.2. PDF through a vendored pdf.js
pdfjs-distat an exact version topackage.json("pdfjs-dist": "6.4.299").sync:vendorcopiesbuild/pdf.min.mjs,build/pdf.worker.min.mjs,web/pdf_viewer.mjs+pdf_viewer.css,cmaps/,standard_fonts/(andwasm/only if decision D4 says so) intostatic/js/vendor/pdfjs/withLICENSE.txt. Add a row toTHIRD-PARTY.md. Load it only on pages that show a viewer, as Leaflet is (#898).GlobalWorkerOptions.workerSrc= the static URL;getDocument({url, withCredentials: true, useWasm: false, enableXfa: false, isOffscreenCanvasSupported: true, cMapUrl, standardFontDataUrl}). Do not build aPDFScriptingManager: no PDF JavaScript runs. SetannotationModeto draw annotations but not forms. External links in the PDF open withrel="noopener noreferrer"in a new tab (LinkTarget.BLANK), and onlyhttp/https/mailtolinks are allowed.btn+data-variant="ghost",data-size="icon-sm", Lucide icons with ansr-onlyname):+/-zoom,Home/End. These keys work only while the viewer has focus, never globally.PDFFindController) is optional, a nice-to-have.renderers.draft_pdf(it already caches per address). A render takes seconds where HTML takes milliseconds, so the viewer shows the last PDF, with Redraw or an automatic redraw after a save, debounced. See decision D1.3. Preview and thumbnail addresses
Every viewable record gets
…/preview/and…/thumb/next to…/download/. These are web views; API equivalentsGET /documents/{source}/{pk}/previewand/thumbwould mirror paperless.OwnedObjectMixin/owned_or_404andserve_private_file(..., within=…), with no new route around ownership.Content-Disposition: inlineonly for a fixed allow-list keyed by what the record is (#771):application/pdf,image/png,image/jpeg,image/webp, andtext/plain; charset=utf-8. Everything else stays an attachment.nosniff,no-storeandFILE_POLICYstay. pdf.js reads the bytes withfetch, so the sandboxing policy on the file response does not get in its way, unlike the browser's built-in viewer.checksum,rendering_checksum) and Last-Modified, as paperless does, so moving between pages does not download the file again.Cache-Controlstaysprivate, no-storeunless D5 decides otherwise.4. Thumbnails, made on the server
What and when. The first page as WebP, at most 400–500 px wide, written once when a file arrives or a snapshot is frozen. The work is an errand, so an upload does not wait for it. The thumbnail is kept beside the file under the owner's folder, counted in the account's quota, deleted with the row, included in the export and archive (or regenerated on import), and swept like other media (#775).
How, for PDFs. No current dependency can rasterise a PDF. Candidates:
Proposed: pypdfium2, as an optional extra (
[preview]) included in the container image. Rasterising a PDF someone else wrote is a parser on hostile input, so it runs in the errand worker under a time and memory limit, never in a request. Captured renderings sent by a client are thumbnailed only if D3 says so. See D2.Pictures: Pillow (already present), through the same restricted decode path as
core/pictures.py(#777).Text: no thumbnail. Show a kind icon, rather than paperless's drawn text.
Word, ODT and RTF: a kind icon, unless a converter exists (see section 5).
Where shown: the Files list and Sent documents (a grid or a leading thumbnail per row), application documents, and the proof attached to a career entry (#669). Each
<img>getsloading="lazy", a width and height to avoid layout shift, andalt=""because the title beside it carries the name.5. Other kinds
<img>with fit or actual size, a zoom toggle, and the same Download and Open buttons.<pre>withwhite-space: pre-wrap, in the file's declaredlanguage/dir(#283). Never served as HTML.postulo-gotenbergor LibreOffice plugin, following paperless's split. The converted PDF would be kept as a derivative of the original, like paperless's archive version, never replacing it. See D3. The extracted search text (#877) could be shown as a Text only fallback.sandboxattribute (withoutallow-scripts) to the<iframe>as a second barrier: the preview has no script, and a theme from a plugin (#343) should not gain one.tests/security/test_captured_pages.py.6. Accessibility and phones
prefers-reduced-motion.mdthe viewer opens full screen, as a drawer or dialog, from the thumbnail or a Preview button. Paperless uses a tab of its own. The page layout is held at 320 px by the reflow browser test in every language.7. Text, translation and tests
gettext; translated in Weblate only (#706). Tooltips andsr-onlynames on every icon button.data:-like title cannot change the type),withinguard,tests/security/: a captured PDF is never answered inline; an HTML or SVG upload can never reach a preview.test_trademarks.pymust see the pdf.js notice. The vendored file sizes are worth stating in the PR (pdf.js plus worker is about 1.5 MB minified, loaded only where used).Decisions for the maintainer
useWasm: falsewith the JavaScript fallbacks (no CSP change; recommended), or add'wasm-unsafe-eval'toscript-srcon viewer pages for faster JPEG 2000 and JBIG2 decoding.no-storeon previews and thumbnails (safe on shared browsers; consistent with #787) or allowprivate, max-agefor thumbnails, with an address that changes when the file changes.FILE_POLICYwould need checking per browser.Related: #232, #236, #293 (the framed preview), #343 (theme files in the preview), #344 (paper size), #662, #663, #669, #771, #773, #777, #787, #813, #877, #878.
From the status review of 2026-10-30: of the gaps the core records about itself, this is the one a person meets most often. Every uploaded file and every sent PDF is a download today. Suggest taking it first among the 0.6.0 interface work.
Decided by the maintainer (2026-11-12)
🤖 Generated with Claude Code