0007. An uploaded file is stored as file:<uuid> and resolved when the page is built¶
Status: Accepted
Date: 2026-09-13
Context¶
BlockNote ships four blocks that hold a file: image, video, audio and file. Their properties are a closed set, and the set is upstream's. ProseMirror drops any attribute a node specification does not declare, so those blocks cannot be made to carry a Drupal identifier next to their URL. They carry url, name, caption, showPreview and previewWidth, and url is the only one that could hold an identity.
uploadFile is upstream's seam for putting a file into a document: an editor option taking a File and answering with a string or a partial block, called from the file panel's Upload tab, from a paste and from a drop. resolveFileUrl is its counterpart, called on every render of a file block to turn what is stored into something a browser can load. Both are ordinary editor options, which means an extension can replace either.
The two call sites are not symmetrical, and the difference decides how a refusal is reported. The file panel awaits uploadFile() inside a try, so a rejection there becomes the panel's own error message. The paste and drop path does not: pasteExtension.ts and fileDropExtension.ts both call void handleFileInsertion(event, editor), and handleFileInsertion() awaits editor.uploadFile() with no catch around it. A rejection on that path is an unhandled promise rejection, which reaches the console as an error, and WebDriverTestBase::$failOnJavascriptConsoleErrors defaults to TRUE.
On the Drupal side, core's CKEditor5ImageController::upload() is the reference endpoint: a POST route with a CSRF token, a custom access check, the file upload handler, and validators built from field settings. Its settings live on the text editor because CKEditor 5's image upload is an editor feature. That is not available here: Editor::getJSSettings() runs once per text format and has no field in scope, so anything that varies per field cannot travel that way.
Core's editor module shows how a file referenced from inside text is accounted for: usage rows written under the module's own name on entity insert, update, delete and revision delete, a temporary file made permanent the first time a saved document names it, and a hook_file_download() implementation answering for files on a private scheme.
Decision¶
The identity is file:<uuid>, spelled exactly. UrlSanitizer::fileUuid() accepts a trimmed value matching /^file:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/ and nothing else: a lower-case scheme, a lower-case RFC 4122 UUID, and nothing after it. No path, no query, no fragment, no uppercase, no entity-encoded spelling. The grammar is exact rather than forgiving because this module writes the value and nothing else does, so every other spelling is either drift or a hand-written payload, and the module refuses both rather than guessing. The UUID is stored rather than a path because it survives the file being moved, renamed, or served from a different scheme, and because it is the only durable thing about a file that fits in a string property.
The resolver runs before the sanitiser, never instead of it. FileUrlResolver::resolve() answers a ResolvedFileUrl, and the URL it produces then goes through UrlSanitizer::mediaSrc() exactly as a URL an author typed does. mediaSrc() refuses the file: scheme permanently. That refusal is what makes the arrangement safe in both directions: a file: value reaching the sanitiser is one no resolver claimed, and a document rendered through a BlockRenderContext built without a resolver puts no identity on a page. BlockRenderContext::resolveMediaUrl() returns NULL when the URL is not an identity, which tells the caller to sanitise it as before, and a missing answer when it is an identity but no resolver is present.
There are three answers, and each carries its own cacheability. ResolvedFileUrl is resolved (the root-relative URL plus the file's own cache tags), missing (no file carries the UUID; the file_list tag, so the page recovers when a file is next saved) or denied (the file exists and this account may not see it; the file's tags, the cacheability of both access results that withheld it, the entities the module's usage rows named, and the user.permissions and user contexts). The user context is not decoration: FileAccessControlHandler answers a non-owner with a bare neutral carrying no contexts at all, so without it two accounts on one permissions hash share a render cache entry that only one of them was entitled to. A missing or denied file renders the same blocknote-block--unavailable shape a refused URL renders: the name and the caption survive, the URL does not.
The route is blocknote.file_upload, POST /blocknote/upload/{editor}/{entity_type_id}/{bundle}/{field_name}. It has five requirements: _entity_access: editor.use, _permission: upload files to blocknote documents, _custom_access on FileUploadController::access, _csrf_token: TRUE, and _format: json. The multipart field is file. Success is 201 with {"url": "file:<uuid>", "uuid", "name", "src"}, where name is the filename after the upload handler's security and collision renames, and src is the root-relative URL, sent only so the editor can show the file it just uploaded without waiting for a save. Failure is a 4xx with {"message": "<translated reason>"}.
_format: json is not decoration. Only the _format query parameter reaches Drupal's negotiation middleware, so without the requirement the request format would negotiate to html and a 403 from the permission, the entity access, the CSRF token or the custom check would come back as an HTML error page, breaking the one thing the client's error handling relies on. With it, every refusal the route reaches is JSON. The 405 on GET is the exception and is HTML: core tags method_filter at route filter priority 10 and request_format_route_filter at the default priority, so the method filter throws before anything has read _format off the route. Nothing the client does reaches that path, because the module only ever posts, and FileUploadRouteTest::testGetIsNotAllowed() pins it so the next reader is not surprised by it.
The custom access check adds six checks and no more. The route already answers for the permission, use access to the format and the CSRF token. What is left is: the field exists on that bundle, it is a blocknote_document field, file_upload.status is on for it, its extension list is not empty, the format is a BlockNote format, and the format enables at least one of image, video, audio and file. Amended 2026-09-15. The extension list is the sixth: a field whose list is empty takes no file at all, and without the check the request would reach core's extension validator and come back as a message naming no extension. The format check is there because _entity_access: editor.use accepts any editor entity, and a format that is not a BlockNote format but happens to enable the blocknote filter would otherwise be an upload target: FormatRegistry::getFilter() answers for any format with the filter on, while isBlockNoteFormat() also requires the BlockNote editor and no other filter. The widget asks it implicitly, because it only ever offers ids from FormatRegistry::getFormatIds(). Host entity create access and field edit access are deliberately not added on top, even though core's JSON:API file upload checks them. The widget decides whether to offer uploading at all by asking the same six questions, so a further check here would make the endpoint refuse requests the widget was told to make, and the failure would be invisible until an author tried. The host entity is checked where it exists: on the form that renders the widget, and on the save that validates the document. An upload that is never referenced by a saved document is collected by cron.
Upload settings are on the field instance, not on the storage. file_upload is a mapping under field.field_settings.blocknote_document with status, uri_scheme, file_directory, file_extensions, max_filesize and max_resolution, and the form is core's own controls with core's own element validators. uri_scheme is the one that differs from core's file and image fields, which make it a storage setting. They can, because their storage owns a file column. A blocknote_document storage owns one text column and no file column, so there is nothing storage-wide to decide, and one storage can serve a public field beside a private one. Everything is read through BlockNoteDocumentItem::fileUploadSettings(), which fills a stored, possibly partial mapping in against the defaults, so a field configuration saved before the mapping existed reads correctly without anything rewriting it. fileUploadMaxSize() answers the byte limit, or PHP's own when the field names none.
One extension list serves all four file blocks in 1.0, and no FileIsImage validator is applied. Narrowing by block type would mean the client posting which block type it is uploading into, which is a forgeable claim, and the server has no other way to know. So an extension list that permits png and mp4 permits both into either block. A site that wants images-only sets an image-only extension list. This is a stated boundary of the first release rather than a thing that was overlooked, and it is the item to revisit if a later release finds a non-forgeable way to say what the file is for.
A file becomes permanent when the host entity is saved, through file_usage rows written the way editor module writes them. The upload saves a temporary file owned by the uploader. BlockNoteEntityHooks implements entity insert and update: it parses each blocknote_document item of every translation, asks ReferenceExtractor for the entities the document references (which asks each block plugin's getReferencedEntities(), so a third-party block is accounted for exactly as the shipped ones are), and hands the files to FileUsageRecorder. The recorder makes a temporary file permanent and adds a usage row under the module name blocknote, with the host entity type and id. A new revision adds everything and removes nothing; a save into the same revision is a difference per field, so moving a file between two documents on one entity leaves the count where it was. Removal belongs to the field: BlockNoteDocumentItemList, the field type's list class, removes this module's usage of the files its values name, all of it in delete() and one use per revision in deleteRevision(), acting only on the default translation, the way core's FileFieldItemList does. Core's field purge calls delete() and fires no entity hook, so a field that is deleted and purged removes its usage too. During a purge the list keeps the usage of any file another document field of the same entity still names, because a usage row does not record which field wrote it; the count can stay too high and never drops too low. Every translation is read rather than the active one, because a document and its format translate together and a file named only in a translation is as referenced as one named in the original.
hook_file_download() answers for what the module has rows for, and never calls $file->access(). Core's file_file_download() reaches FileAccessControlHandler through FileReferenceResolver, which understands file fields, and a file named inside a document is in no file field. So the hook answers the question it can: headers for a permanent file whose blocknote usage names an entity this account may view, in a field of that entity the account may view too, headers for a temporary file owned by the current account (the author's own upload, before the host is saved), and NULL otherwise, so that another module's file keeps that module's headers.
The field is checked as well as the entity, which is stricter than core's editor module, and a refusal is NULL rather than -1. EditorHooks::fileDownload() checks only $referencing_entity->access('view'), and core's file_usage table carries no field name; the field name is derived at read time by FileReferenceResolver rescanning the entity's file fields. This module can do the same for a document, because the document itself says which field holds the file, so the check costs one field access call on an entity that is already loaded, and a site that withholds a field has an obvious expectation that the files inside it go with it. The refusal is silence rather than -1 because FileDownloadController::download() treats any -1 in the merged result as an outright denial: a -1 here would cancel the headers another module gives for its own reference to the same file, while buying nothing, since a file only a document names is refused anyway when no module answers at all.
The page and the download callback answer from one rule, in FileReferenceAccess. FileUrlResolver asks $file->access('view') first for any scheme but public, which is core's answer and covers the account that owns the file. When that answer is not allowed it asks FileReferenceAccess::viewAccess(), which is the rule the download hook answers by: allowed when a blocknote usage row names an entity the account may view, in a field it may view, carrying those entities and both access results as cacheability. Otherwise the resolution is denied and the page renders the unavailable shape. Before this the resolver stopped at core's answer, so a private file named only in a document rendered as unavailable for every account but its uploader while the download callback served it to any viewer of the host: fail-safe, never a leak, and two answers to one question. Amended 2026-09-14. A usage row names an entity and not a revision, so viewAccess() reads two revisions of every entity a row names: the default one, asked under view, and the latest one when that is another revision, asked under view revision, which is the operation core's FileAccessControlHandler uses for a revision that is not the default one. That is what answers for a private file an author names in a draft saved for review, which the default revision does not name. Amended 2026-09-15. view revision is a revision permission, which for a node means view all revisions or view <type> revisions. The permissions that open the Latest version tab, view latest version with view any unpublished content, are not among them, so a content moderation reviewer holding those alone reads the draft and is refused the files only the draft names, until the site grants a revision permission as well. That is the rule core applies to a file field a draft alone references, so a site has one rule for both; asking plain view on a non-default revision instead would serve a draft-only file to any account with access content whenever a pending revision carries published status, and matching content moderation's own grant would mean depending on a module this one does not depend on. ModeratedDraftFileAccessTest holds the boundary on a node under the editorial workflow. No older revision is read. A document names its files inside its stored value, where no query reaches them, so core's own way of finding one, a single indexed query for the most recent revision whose file-reference column holds the file, has no equivalent here, and an older revision could be found only by loading and parsing every revision of the host on every render and every download of the file. A file that only such a revision names resolves as denied, which is a refusal and never a leak, and a kernel test holds that boundary so it cannot move unnoticed.
The shared rule lives in a class of its own rather than on FileUsageRecorder, which is where an earlier draft of this record expected it. The recorder writes rows and needs nothing but file.usage; the rule reads rows, loads entities, parses each document to learn which field names the file, and asks entity and field access, which is a second job and a second set of dependencies. FileReferenceAccess also owns filesIn() and filesByField(), so the accounting hooks, the field's item list and the access answer read the files a document names through the same code.
Resolved URLs in rendered markup are root-relative. Amended 2026-09-14. FileUrlResolver::resolve() uses FileUrlGeneratorInterface::generateString(), and the upload response's src uses $file->createFileUrl(), which is how core's EditorFileReference filter and CKEditor 5's image upload write file URLs. This record first made them absolute and left the change until there was a reason for it. The reason is that generateAbsoluteString() writes the request's scheme and host into render-cached markup that carries no url.site cache context: a site serving several domains from one render cache served one domain's base URL to the others, and on a site without trusted host patterns a forged Host header could reach the cache. A feed still gets absolute URLs, because core's RssResponseRelativeUrlFilter rewrites root-relative src and href values in RSS item descriptions. FileUrlResolverTest::testTwoHostsRenderTheSameMarkup renders one document under two hosts and asserts the same markup.
The client refuses what the field will refuse, before anything goes on the wire. js/src/upload.tsx reads the field's extension list and size limit off the widget's data-blocknote-upload attribute and checks a chosen file against both, using Drupal's own wording for the refusal, because it is the same refusal the server would make and only spares the round trip. The attribute is a map, {"formats": {"<format id>": {url, maxSize, extensions}}}, with an entry and a signed upload URL for every format the field allows that the account may use and that enables a file block, and the client reads the entry for the format it attaches in. Core's editor attaches again to the same textarea when an author switches formats, so an attribute naming one format could not follow the switch. What it never does is decide anything the server does not: the server checks again, and the server's answer is the one that counts.
A refused paste or drop reports; a refused file-panel upload rejects. On the file panel path the client rejects and upstream's Upload tab shows its own error string. Amended 2026-09-15. The client also sets the report above the editor before it rejects, so the reason is shown and announced there, because upstream's own string names none. On the paste and drop path the client catches, resolves with {props: {name}} so the inserted block stays in the editor's own "no file yet" state, and reports the message into .blocknote-editor__upload-error above the editor and announces it assertively through Drupal.announce(). Rejecting there would be an unhandled rejection and a console error, which would fail every browser test on the page. The two paths are told apart by the event that is running rather than by the call, because both call sites pass the same arguments: a capture-phase paste and drop listener on the window raises a flag, and an upload still in flight keeps it up, because handleFileInsertion() reaches the second file of a paste long after the dispatch ended. The flag is the page's, since the listener is; the count of uploads in flight is each editor's own, kept inside its file client, so one editor's slow upload does not make another editor's file-panel failure report instead of reject. Amended 2026-10-30. The paths are told apart by the file panel's state at the time of the call, and no longer by the event. createEditor() hands the file client its editor once the editor exists, and a call is the panel's when its block id is the one the store of BlockNote's FilePanelExtension names. The Upload tab passes the id of the block its panel is open on, while the paste and drop path inserts or updates its block before it calls, and any document change clears the store, so on that path the store is empty, also when a panel was open before the paste. The window listeners, the flag and the per-editor count are gone. A call the client cannot place, such as one made before an editor is attached, takes the paste and drop path, which reports and resolves, because a missing panel message is a smaller fault than an unhandled rejection. A success no longer takes the notice down. An upload takes down the notice when it starts, so the only report standing when one succeeds is one that another upload made while it was in flight, and clearing that one would hide a reason the author has not read. The store is BlockNote's own state in a pre-1.0 release, so a BlockNote upgrade has to check FilePanel.ts and UploadTab.tsx again, as it was in 0.54.2.
An unresolvable identity renders a transparent pixel and is counted. Upstream's useResolveUrl() throws on a falsy answer and an empty src makes the browser re-request the page, so resolveFileUrl answers a 1x1 transparent GIF data URI, which costs no request and logs no console error. The author is told by the notice above the editor, .blocknote-editor__missing, which carries the count in data-blocknote-missing. A file: value that is not a well-formed identity is treated as unresolvable rather than passed through, because the public renderer refuses it too.
An options alter that replaces uploadFile takes over the error reporting. Both options are set into the editor options before applyEditorOptionAlters() runs, so an extension can replace either. Replacing uploadFile replaces the refusals made before sending, the paste and drop guard and the widget notice along with it. That is the honest consequence of making them ordinary options, and it is what docs/extending.md says.
Alternatives considered¶
A custom block carrying a file reference as a proper property. Rejected. It would mean not using upstream's four file blocks, so a pasted image, a dropped file and everything upstream builds on those blocks would land in a block this module owns and has to reimplement, and a document written by a plain BlockNote editor would not load. The url property is the seam upstream offers, and uploadFile returning a partial block is upstream's own way of writing into it.
Storing the file's path or its public URL. Rejected. It goes stale when the file directory changes, when a site moves domain, or when a field is switched from the public to the private scheme, and it makes the stored document a statement about this deployment rather than about the content. The identity is resolved on every render precisely so none of those matter.
Refusing an identity at save time when no file carries the UUID. Rejected. A well-formed identity is accepted on its form alone. If storage were consulted, a file deleted months later would make every entity holding that document unsaveable until an author had edited each block by hand, and the answer to a deleted file already exists on the render path. The mirror of this is that a forged file:<uuid> typed into the Embed tab references any file on the site by UUID; that is the same boundary core accepts for editor_file_reference's data-entity-uuid, and it is named for the security review rather than silently relied on.
Putting upload settings on the text editor, as CKEditor 5 does. Rejected because it is not possible here. Editor::getJSSettings() is per format with no field in scope, and the plan settled on field settings mirroring core's image field. A per-format setting also cannot express "this field stores privately and that one publicly", which the uri_scheme decision above depends on.
Adding entity and field access to the upload endpoint. Rejected. See the access decision: it would refuse requests the widget was told to make, and it authorises no write that the document constraint does not already validate.
Throwing UnprocessableEntityHttpException for validation violations, as core's image controller does. Rejected. The message would then depend on the negotiated format, and this client reads exactly one shape. The violations are written into the JSON body here instead.
Consequences¶
A file that has been uploaded but whose host entity was never saved is temporary, and the file module's cron deletes it. That is the intended behaviour and it means an abandoned form leaves nothing behind. It also means an author who uploads, copies the block's JSON out by hand, and pastes it into another entity a week later finds nothing there.
The file: scheme is now a permanently refused media source in the public renderer, and it has to stay refused. Widening that allowlist would let a hand-written document put an unresolved identity on a page.
Every distinct file identity in a document costs one uuid lookup and one file load when the page is built, and a repeat of the same identity costs nothing, because core's entity storage keeps a uuid lookup in its memory cache for the rest of the request (EntityStorageBase::loadByProperties()). There is no batch resolver. DocumentPerformanceTest pins the count: three public files named five times cost three lookups. A multiple() method on the resolver is not written, because a lookup costs well under a millisecond and core's EditorFileReference also resolves one uuid at a time.
A file on a scheme a download callback serves costs more than that when core's access answer is not allowed: one usage query, then the referencing entities loaded and their documents parsed to find the field that names the file, and on a revisionable host one query for the latest revision id and, when that is not the default revision, that revision loaded and parsed as well. Amended 2026-09-15. Which field names the file is read from the identities in the stored value, so no other file the document names is loaded to answer for this one; the block plugins are still asked when no identity matches, for a block that references a file without naming one. It is the price of the page and the download callback agreeing, it is paid on the private scheme only, and the answer carries the entities' cacheability, so a render-cached page pays it once. DocumentPerformanceTest pins that too: a private file's first resolution costs seven queries, and each later one costs one.
The page is more permissive than the download callback in one place, and by core's rule rather than this one. FileUrlResolver takes core's view answer whenever it is allowed, and FileAccessControlHandler allows the account that owns a private file for as long as no file field references it, and a reference from inside a document is not a file field. So the uploader of a file that only a document names is shown it on a page, while hook_file_download(), which answers for a permanent file from FileReferenceAccess alone, refuses the fetch. It is a link that is refused for one account, never a file served to an account the rule withholds it from, and the README says so where it describes private files.
One extension list for four block types means an author can choose a file in the file panel that the field will refuse, because the panel's accept attribute comes from each upstream block specification and is not narrowed by the field's list. The refusal is then the client's, with the server's own wording, before anything is sent.
A file above PHP's post_max_size never reaches the controller at all: the browser sends the request and PHP discards the body, so the client sees an empty or unexpected response rather than the field's own message. The field's max_filesize covers everything below that ceiling and nothing above it.
The upload route keeps CKEditor 5's shape, and that leaves a residual. A token is bound to the path and the session, and not to the account's access at the moment of the request, so an account that loses create or update access to the host while its session lasts can still place a temporary file in that field's directory. Nothing references such a file unless a document the account may still save names it, and cron collects it otherwise.
A file-panel upload started while the same editor's own paste or drop upload is still in flight is taken for part of the paste, so its refusal appears in the notice above the editor instead of in the panel. Telling the two apart needs the file panel's own state, which upstream keeps in an extension store. Amended 2026-10-30. That is no longer so: the client reads the file panel's store when it is called, and a panel upload started during the same editor's paste or drop upload is refused in the panel as it would be alone, while the paste's success leaves the reason above the editor standing. The cost is a dependence on an extension store that upstream does not document, which a BlockNote upgrade has to recheck.
The module declares drupal:file in its info file, which editor already requires, so the file services are required constructor arguments and nothing guards against a site without them.
References¶
- BlockNote 0.54.2:
@blocknote/coresrc/api/clipboard/fromClipboard/handleFileInsertion.ts,pasteExtension.tsandfileDropExtension.tsbeside it;@blocknote/reactsrc/components/FilePanel/FilePanel.tsx,src/components/FilePanel/DefaultTabs/UploadTab.tsxandsrc/blocks/File/useResolveUrl.tsx;@blocknote/coresrc/extensions/FilePanel/FilePanel.ts. - Drupal core 11.4.6:
core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php,core/modules/editor/src/Hook/EditorHooks.php,core/modules/file/src/Upload/FileUploadHandler.php,core/modules/file/src/FileAccessControlHandler.php,core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php. src/Controller/FileUploadController.php,src/File/FileUrlResolver.php,src/File/ResolvedFileUrl.php,src/File/ReferenceExtractor.php,src/File/FileUsageRecorder.php,src/File/FileReferenceAccess.php,src/Hook/BlockNoteEntityHooks.php,src/Render/UrlSanitizer.php,js/src/upload.tsx.- 0004, 0005, 0006.