Skip to Content
File & Image Uploads

File & Image Uploads

Files enter the editor through three pipelines. All three run the same content validation:

PipelineTriggerUpload path
Drop / pasteAn image file dropped or pasted into the documentThe image upload strategy, else inline base64
Upload blockA file dropped on, or picked from, an image or document upload blockThe onFileUpload prop
Word importImages inside a .docx opened with the toolbar’s import buttononFileUpload, else inline base64

The @stesura/editor-react/drop entry point is unrelated to files: its useEditorDrop accepts react-dnd items dragged from your own UI onto the canvas.

Content validation

Every file is validated before it is uploaded or embedded — by magic bytes, never by file.type or the file extension, both of which are caller-controlled metadata:

  • Images must sniff as image/png, image/jpeg, image/gif or image/webp. SVG is rejected everywhere — it can carry scripts and would execute if served from your storage origin.
  • Documents (upload block) must sniff as one of three classes: a PDF (%PDF-), a ZIP container (.docx, .xlsx, .pptx by extension, anything else as application/zip), or plain text (stored as text/plain whatever the extension says, so an .html upload can never come back as a page).
  • Images over 10 MB and documents over 25 MB are rejected.

The sniffing primitives are exported from @stesura/core/utils if you need them in your own upload code: validateImageFile, validateDocumentFile, validatePdfFile, sniffImageType, sniffImageBytes, sniffDocumentType, sniffPdf, MAX_IMAGE_BYTES, MAX_DOCUMENT_BYTES.

onFileUpload

Upload blocks and DOCX import pass files to the onFileUpload prop on StesuraEditor. Resolve to the URL the editor should embed:

import type { OnFileUploadCallback } from "@stesura/editor-react/types"; const onFileUpload: OnFileUploadCallback = async (file, uploadType, contentType) => { const url = await myStorage.upload(file, { contentType }); return url; }; <StesuraEditor /* ... */ onFileUpload={onFileUpload} />;
  • contentType is the type sniffed from the file’s magic bytes. Store the object under it, never under file.type: a browser-supplied type lets an attacker persist e.g. text/html that your bucket then serves as a page.
  • uploadType is "image" or "document" from an upload block, and "image" from DOCX import. The type also allows "video", "audio" and "other", but the editor never sends them.
  • The URL is normalized before it is embedded, and http: is upgraded to https: (so a plain-HTTP local storage server will not load). Resolving to undefined or to a refused URL (javascript:, data:, file:), or throwing, shows “upload failed” in the block. DOCX import falls back to inline base64 instead, or leaves the image unresolved with a missing_image warning if the mount forbids inline images (see below).
  • The prop can change between renders; the editor always calls the latest one.

Without onFileUpload, upload blocks cannot complete: every attempt ends in the error state.

Before validation, the upload block filters by declared type: PNG, JPEG, GIF and WebP for images; .pdf, .docx, .xlsx, .pptx, .zip, .txt, .md and .csv for documents. It also rejects files under 1 KB. This filter is a convenience; the magic-byte check decides what is accepted.

Drop & paste: the image upload strategy

Dropped and pasted images do not go through onFileUpload. By default they are embedded inline as base64 data URIs — self-contained and infrastructure-free, the right default for local-only documents. To route them to real storage instead, install a strategy once at app startup:

import { setImageUploadStrategy } from "@stesura/core/helpers"; setImageUploadStrategy(async (file) => { const url = await myStorage.upload(file); return url; });

The file reaching the strategy has already passed content validation. Pass null to uninstall and restore the base64 fallback.

Inline images and collaboration

Inline base64 is wrong for documents that sync or persist server-side: the blob rides along in every commit and can exceed the backend’s payload cap. Mounts declare this via the imageUploadPolicy plugin from @stesura/core/plugins:

imageUploadPolicy({ allowInlineImages: false, onInlineImageRejected: () => toast.warning("Image uploads aren't configured"), });

useCollabEditor declares allowInlineImages: false automatically — on a collab mount with no upload strategy installed, dropped/pasted images are refused up front (no placeholder, no doc change) and the hook’s onInlineImageRejected argument is called so you can surface it:

useCollabEditor({ // ... onInlineImageRejected: () => toast.warning("Image uploads aren't configured"), });

The Word import respects the same policy: when the editor forbids inline images, embedded images whose upload fails (or that have no onFileUpload to go to) are not embedded as data URLs: they keep an unresolved src and raise a missing_image warning. Programmatic imports control this directly via importDocx’s inlineImageFallback option.

Already-uploaded images inserted by URL are never affected by the policy.

Server-side enforcement

The client-side policy is UX — any caller can speak the collab protocol directly. The authority is the real gate: createCollabBackend inspects every incoming commit’s steps (inserted content, src attr writes, doc-attr payloads) and rejects commits that would introduce a data: src, throwing CommitRejectedError — the Next.js route factory maps it to 422 so clients don’t retry. Nothing to configure; it’s always on.

For documents that predate the gate, the collab mount’s normalize pass heals on load: createNormalizeTransaction(state, { removeInlineImages: true }) deletes inline-src nodes as an ordinary converging edit (zero steps on a clean doc). Local mounts don’t pass the flag — inline images are their legitimate default.

The step-inspection primitives are exported from @stesura/core/guards for custom transports: commitStepsContainInlineSrc, stepContainsInlineSrc, fragmentContainsInlineSrc, applyInlineImageCorrections.

Embedding files

The file node carries src, name, mimeType (the sniffed type), size, height, rotation and displayPreview. What it renders depends on its kind, derived from mimeType (fileKind in @stesura/core/helpers):

  • PDF — a preview: the file’s own pages, rendered by @stesura/pdf-viewer-react. Only PDFs are ever previewed. A mimeType of null (documents that predate the attr) counts as PDF, the only kind that could be uploaded then.
  • Anything else — a card: icon, name, type, size, and an open-in-new-tab link. Under pagination it flows like any small block.

Without the viewer package every PDF is a card too. The card is not a degradation to be fixed later: a link is the serialized form of the node everywhere — the clipboard, static HTML, markdown — because it is what a consumer with no node view can carry.

Mounting the viewer

createPdfViewer returns a single StesuraUIExtension; there is no plugin to add. The node view, pagination and the PDF export all read it from the extension list, so they agree from the first layout.

import { createPdfViewer } from "@stesura/pdf-viewer-react"; const pdfViewer = createPdfViewer({ assets: { workerSrc: "/pdfjs/pdf.worker.min.mjs", cMapUrl: "/pdfjs/cmaps/", standardFontDataUrl: "/pdfjs/standard_fonts/", wasmUrl: "/pdfjs/wasm/", }, // Optional; these are the defaults. limits: { maxPages: 500, maxBytes: 50 * 1024 * 1024 }, }); // Module scope, like every other extension — a new array identity per render // re-mounts every node view. const uiExtensions = [paginationUiExtension, pdfViewer];

Import the stylesheet once, in your root layout or global CSS. Without it the preview doesn’t scroll and the text layer shows over the pages:

import "@stesura/pdf-viewer-react/styles";

assets is required. pdf.js loads its worker, cMaps, standard fonts and WASM modules by URL, so your app has to serve them. The package ships a script that copies them:

npx pdf-viewer-assets --out public/pdfjs

Run it from predev/prebuild and keep the output out of version control: the worker must match the installed pdfjs-dist version, or loading fails. The URLs you pass must include your base path, if any.

Range loading is yours to enable. Partial loading needs the file host to answer Accept-Ranges: bytes and, cross-origin, to expose Accept-Ranges, Content-Range and Content-Length through Access-Control-Expose-Headers. Without them pdf.js downloads the whole file — still correct, just not incremental. (S3 and Supabase Storage both serve ranges; the CORS exposure is the part usually missing.)

Rotation and card/preview, per file

Two attrs, both reachable from the selected file’s controls and from commands in @stesura/core/commands:

  • rotation (0 | 90 | 180 | 270, default 0) — setFileRotation(pos, deg) and rotateFile(pos, ±90). It is added to each page’s own rotation, so a natively landscape page keeps its orientation when you turn the file.
  • displayPreview (default true) — setFileDisplayPreview(pos, value). false is a card even with the package mounted.

What you see is what you export: a card prints as a card with its link; a preview prints as the file’s pages, at the angle you turned them to.

Where the controls live. A selected preview shows a rail in its left gutter: rotate, show as card, download, open in a new tab, move up and down. A card keeps only its grip and delete button. Every action is also in the context menu, for both. Moving a preview doesn’t reload it: the move uses preserveNodeViewKey from @stesura/editor-react, which you can reuse for any node you move by delete + insert.

PDFs under pagination and in exports

A previewed PDF sits on the canvas between two pages, not on a page: the page it follows ends there, the preview spans the section’s full width and is exactly one page tall with the file’s own pages scrolling inside it, and the next block opens the page after. It is not a page, so nothing has to skip it: #page, #pages, odd/even parity and the TOC’s numbers are untouched. A file that opens a section sits above its first page; adjacent files stack. Unpaginated, the preview is a resizable box at the node’s height.

In the PDF export the preview box is replaced by the file’s real pages, fetched by the server and spliced in after the page the preview follows, at the node’s rotation. A file the server can’t use (refused, too large, encrypted, over maxPages) is left out with a warning in the server log; the export still succeeds.

The export page must mount the same extension, like pagination, or its layout differs from the editor’s. No preview renders there; the server fetches the file itself.

DOCX export drops file nodes and pushes a warning naming the file — Word has no element for them. Markdown writes the link form, [name](src), with a warning that the preview, its rotation and its page layout are not expressible.

Previewing is opt-in: allowedFileHosts

By default no PDF is previewed. A mount that has not configured allowedFileHosts renders every PDF as the card, with a link and the note “Preview off — this editor has no allowed file hosts configured”. To preview PDFs, name the hosts you trust to serve nothing but PDFs:

const editor = useLocalEditor({ // ... configuration: { config: { allowedFileHosts: ["files.example.com"] } }, });

Leaving it undefined is not “allow everything” — it is “no previews at all”. The renderer fetches document-supplied URLs from the viewer’s browser, so the allowlist is the statement that these hosts are yours and serve the content types you set (see the checklist below — bucket-level MIME enforcement is what makes that statement true). Documents can come from untrusted authors.

Two layers decide whether an allowlisted mount previews a given file:

  • allowedFileHosts: only these host[:port] values are previewed.
  • A never-allow class, which no configuration can widen: non-http(s) schemes, loopback, private ranges (10/8, 172.16/12, 192.168/16), link-local (169.254/16 — the cloud-metadata endpoint) and fe80::/10, CGNAT (100.64/10), IPv6 ULA (fc00::/7), .local / .internal hostnames, and anything unparseable.

These are hostname checks: the viewer’s browser can’t see what a name resolves to, so DNS rebinding remains a residual risk.

What the renderer does, and does not

  • PostScript function evaluation runs without eval, image size is capped (~16 megapixels — a decompression bomb is a huge image in a small file), and the file is fetched with no credentials and no custom headers.
  • The annotation layer is off: a PDF’s own links must never navigate the editor. The text layer is on, so selection, copy and a screen reader all see the page’s text. react-pdf is not a viewer application, so document scripting never runs.
  • Ceilings. A preview stops after maxPages and says how many pages are left (the export still includes them all). A file whose Content-Length exceeds maxBytes isn’t loaded; the size is checked with a HEAD request, so a host that doesn’t report it is previewed anyway. These messages, like load errors, show inside the box, which never changes size.
  • Virtualised. The file isn’t fetched until the node nears the editor viewport, and only pages near the scroll position are drawn. So neither browser Find nor the editor’s search covers the PDF; download and open in a new tab are the way to search it. (download only works same-origin; cross-origin it opens the file.)
  • Accessible like any region. The scroll box is focusable and named “PDF preview: name”, so a keyboard user can scroll it; the chrome buttons are labelled; the card stays the fallback. Both the preview and the card are contentEditable={false} islands — inside the editable a browser answers a click on a control by placing a caret rather than acting on it.
  • It does not look like a page. The file’s pages float, rounded, on a dark surround with a gutter down the left that the node’s controls sit in, so a reader never mistakes an attachment for a page of the document. Three variables theme it: --stesura-pdf-surround, --stesura-pdf-surround-foreground and --stesura-pdf-scrollbar.

Anything not previewed renders as the card instead — the file icon, its name, its hostname and the reason, and an open-in-new-tab link. The decision is “not rendered inside this document”, never “unreachable”.

Hardened deployments (hostile authors, multi-tenant): serve embedded files through a route on your own origin that forces Content-Type: application/pdf and X-Content-Type-Options: nosniff, and allowlist that host. The same never-allow class is applied in the node’s toDOM, which has no access to your config, so a hostile document cannot point a viewer at their intranet even on a mount that configured nothing.

The helpers are exported from @stesura/core/utils if you need the same decision in your own code: isForbiddenEmbedUrl, isEmbedUrlAllowed.

PDF export makes the same requests

The PDF exporter renders documents on your server, so every image, font and file URL in a caller-supplied doc becomes a request from your server. It refuses the same never-allow class on every render, checked against the resolved addresses too, and allowedHosts narrows it further. One consequence: a storage service on loopback (e.g. local Supabase on 127.0.0.1:54321) is refused during export.

Security checklist

Client-side validation is UX, not enforcement — anyone with your public storage credentials can bypass the editor entirely. For a hardened setup:

  1. Enforce limits in your storage service (bucket-level allowed MIME types and max object size). This is the only layer an attacker cannot skip — and it is what guarantees an allowlisted host can never answer a “PDF” URL with something else.
  2. Set the Content-Type explicitly from the sniffed type on every upload, and disable upserts.
  3. Cap request bodies on any server route that persists documents — a doc full of base64 is the easiest way to bloat a database.
  4. If you render caller-supplied docs server-side (e.g. PDF export in a headless browser), restrict which hosts it may fetch — image srcs are attacker-controlled and will otherwise SSRF into your internal network.
Last updated on