feat(editor): admin-only raw HTML/CSS/JS embed node

Adds an htmlEmbed block node that renders and executes raw HTML/CSS/JS in the
wiki origin (e.g. an analytics tracker) — the owner-chosen variant C. Because
this is stored-XSS by design, only workspace admins/owners may get such a node
persisted; everyone executes it when reading.

- Node (editor-ext): htmlEmbed atom/isolating block; source stored base64 in
  data-source for lossless HTML<->JSON round-trip. renderHTML emits only the
  encoded marker (never inlines raw markup), so generateHTML/export/search are
  not themselves injection vectors. Registered in BOTH client extensions and
  server tiptapExtensions. Markdown round-trip via an <!--html-embed:b64-->
  comment (turndown) + a marked rule.
- Client NodeView: injects source and re-creates <script> elements so they
  actually run; edit modal; renders in read-only/share too. Slash item is
  admin-gated (adminOnly filtered by the user's workspace role).
- SERVER ENFORCEMENT (the real control — UI gating alone is insufficient):
  stripHtmlEmbedNodes() removes htmlEmbed from any document persisted by a
  non-admin, applied at every write path that introduces content from an
  untrusted author: collab onStoreDocument, REST/MCP/AI updatePageContent,
  single-file import, zip/multi-file import, page duplication, and transclusion
  unsync. Page restore introduces no new content. Public share/readonly viewers
  render fetched (already-stripped) content and do NOT open a collab socket, so
  the only residual is a transient broadcast window to concurrent authenticated
  editors (documented).

Implements docs/arbitrary-html-embed-plan.md (variant C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 08:54:54 +03:00
parent c8af637654
commit bd28dbfe2b
19 changed files with 941 additions and 9 deletions

View File

@@ -8,6 +8,11 @@ import {
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../common/helpers/prosemirror/html-embed.util';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
@@ -83,8 +88,25 @@ export class CollaborationHandler {
user: User;
},
) => {
const { prosemirrorJson, operation, user } = payload;
const { operation, user } = payload;
let { prosemirrorJson } = payload;
this.logger.debug('Updating page content via yjs', documentName);
// SECURITY (Variant C admin gate, REST/MCP/AI write path):
// updatePageContent is the server-side entrypoint used by the REST page
// update endpoint and by the MCP/AI agent. Raw `htmlEmbed` nodes execute
// arbitrary JS in every reader's browser, so a NON-admin caller must not
// be able to persist them here. If the editing user is not a workspace
// admin/owner, strip every htmlEmbed node before it reaches the ydoc.
if (!canAuthorHtmlEmbed(user?.role)) {
if (hasHtmlEmbedNode(prosemirrorJson)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin update by user ${user?.id} on ${documentName}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
}
await this.withYdocConnection(
hocuspocus,
documentName,

View File

@@ -32,6 +32,7 @@ import {
Drawio,
Excalidraw,
Embed,
HtmlEmbed,
Mention,
Subpages,
Highlight,
@@ -102,6 +103,10 @@ export const tiptapExtensions = [
Drawio,
Excalidraw,
Embed,
// Registered server-side so the node survives schema parsing/serialization.
// Authoring is gated to admins at the document WRITE paths (see
// stripHtmlEmbedNodes usage in persistence/page services), NOT here.
HtmlEmbed,
Mention,
Subpages,
Columns,

View File

@@ -39,6 +39,11 @@ import {
HISTORY_INTERVAL,
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../common/helpers/prosemirror/html-embed.util';
@Injectable()
export class PersistenceExtension implements Extension {
@@ -112,7 +117,56 @@ export class PersistenceExtension implements Extension {
const pageId = getPageId(documentName);
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
let tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
// SECURITY (Variant C admin gate, collab WebSocket write path):
// The persisted snapshot is the merged ydoc, which may contain an htmlEmbed
// node inserted by ANY connected editor. htmlEmbed renders raw, unsanitized
// JS in every reader's browser, so only workspace admins/owners may author
// it. When the user whose store triggers this persist is not an admin, strip
// every htmlEmbed node before it is written to the page row AND before the
// ydoc state is re-encoded, so the node cannot be reintroduced by a
// non-admin via the collab socket.
// NOTE (residual risk): the gate is keyed to the storing connection's user.
// If an admin already authored an htmlEmbed and a non-admin's later store
// does not touch it, this strip would remove the admin's embed on that
// non-admin store. This is intentionally conservative (fail closed): the
// admin re-adds/keeps the node on their own next edit. A future refinement
// could diff against the previously persisted admin-authored embeds.
//
// ACCEPTED RESIDUAL RISK (pre-persist broadcast window): this strip runs in
// the debounced onStoreDocument, but hocuspocus broadcasts each inbound Yjs
// update to connected clients immediately, so a non-admin's transient
// htmlEmbed can execute in OTHER open editors' browsers in the brief window
// before this persist strips it. The exposure is limited to concurrent
// AUTHENTICATED space members who have the doc open with Edit rights
// (semi-trusted) — anonymous public-share/readonly viewers do NOT open a
// collab socket (ReadonlyPageEditor renders fetched, already-stripped
// content; HocuspocusProvider is only used by the authenticated editable
// page-editor), and the PERSISTED page row plus every share/readonly read
// path are protected by this strip. The window is therefore accepted rather
// than mitigated with an inbound beforeBroadcast strip.
if (!canAuthorHtmlEmbed(context?.user?.role)) {
if (hasHtmlEmbedNode(tiptapJson)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin collab store by user ${context?.user?.id} on ${documentName}`,
);
tiptapJson = stripHtmlEmbedNodes(tiptapJson);
// Reflect the stripped content back into the shared ydoc so the node is
// removed for all connected clients, not just the persisted row.
const fragment = document.getXmlFragment('default');
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
const cleanDoc = TiptapTransformer.toYdoc(
tiptapJson,
'default',
tiptapExtensions,
);
Y.applyUpdate(document, Y.encodeStateAsUpdate(cleanDoc));
}
}
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
let textContent = null;

View File

@@ -0,0 +1,229 @@
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from './html-embed.util';
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util';
import {
decodeHtmlEmbedSource,
encodeHtmlEmbedSource,
} from '@docmost/editor-ext';
const findFirstChild = (json: any, type: string): any | undefined => {
if (!json || typeof json !== 'object') return undefined;
if (json.type === type) return json;
if (Array.isArray(json.content)) {
for (const child of json.content) {
const found = findFirstChild(child, type);
if (found) return found;
}
}
return undefined;
};
describe('stripHtmlEmbedNodes', () => {
it('removes a top-level htmlEmbed node', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>alert(1)</script>' } },
{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
],
};
const result = stripHtmlEmbedNodes(doc);
expect(hasHtmlEmbedNode(result)).toBe(false);
// Other nodes are preserved.
expect(result.content).toHaveLength(2);
expect(result.content[0].content[0].text).toBe('before');
expect(result.content[1].content[0].text).toBe('after');
});
it('removes nested htmlEmbed nodes (e.g. inside columns)', () => {
const doc = {
type: 'doc',
content: [
{
type: 'columns',
content: [
{
type: 'column',
content: [
{ type: 'htmlEmbed', attrs: { source: '<b>x</b>' } },
{
type: 'paragraph',
content: [{ type: 'text', text: 'keep' }],
},
],
},
],
},
],
};
const result = stripHtmlEmbedNodes(doc);
expect(hasHtmlEmbedNode(result)).toBe(false);
const col = findFirstChild(result, 'column');
expect(col.content).toHaveLength(1);
expect(col.content[0].type).toBe('paragraph');
});
it('does not mutate the input document', () => {
const doc = {
type: 'doc',
content: [{ type: 'htmlEmbed', attrs: { source: 'x' } }],
};
stripHtmlEmbedNodes(doc);
expect(doc.content).toHaveLength(1);
expect(doc.content[0].type).toBe('htmlEmbed');
});
it('leaves documents without htmlEmbed untouched', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
],
};
expect(hasHtmlEmbedNode(doc)).toBe(false);
const result = stripHtmlEmbedNodes(doc);
expect(result).toEqual(doc);
});
});
describe('canAuthorHtmlEmbed', () => {
it('allows owner and admin', () => {
expect(canAuthorHtmlEmbed('owner')).toBe(true);
expect(canAuthorHtmlEmbed('admin')).toBe(true);
});
it('denies member and unknown/empty roles', () => {
expect(canAuthorHtmlEmbed('member')).toBe(false);
expect(canAuthorHtmlEmbed(null)).toBe(false);
expect(canAuthorHtmlEmbed(undefined)).toBe(false);
expect(canAuthorHtmlEmbed('viewer')).toBe(false);
});
});
// Replicates the write-path decision used by every non-admin persistence guard
// (collab store, single import, zip import, duplication, transclusion unsync):
// if !canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json) -> strip, else keep.
const applyAdminGate = (json: any, role: string | null | undefined) => {
if (!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)) {
return stripHtmlEmbedNodes(json);
}
return json;
};
describe('admin-gate write-path decision (duplication / import / unsync)', () => {
const docWithEmbed = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>alert(1)</script>' } },
],
};
it('strips the embed for a non-admin (member) author', () => {
const result = applyAdminGate(docWithEmbed, 'member');
expect(hasHtmlEmbedNode(result)).toBe(false);
expect(result.content).toHaveLength(1);
expect(result.content[0].content[0].text).toBe('keep');
});
it('strips the embed for unknown/empty roles', () => {
expect(hasHtmlEmbedNode(applyAdminGate(docWithEmbed, null))).toBe(false);
expect(hasHtmlEmbedNode(applyAdminGate(docWithEmbed, undefined))).toBe(
false,
);
expect(hasHtmlEmbedNode(applyAdminGate(docWithEmbed, 'viewer'))).toBe(
false,
);
});
it('keeps the embed for an admin author', () => {
const result = applyAdminGate(docWithEmbed, 'admin');
expect(hasHtmlEmbedNode(result)).toBe(true);
expect(result).toBe(docWithEmbed);
});
it('keeps the embed for an owner author', () => {
const result = applyAdminGate(docWithEmbed, 'owner');
expect(hasHtmlEmbedNode(result)).toBe(true);
});
it('strips nested embeds (subtree/column duplication) for a non-admin', () => {
const nested = {
type: 'doc',
content: [
{
type: 'columns',
content: [
{
type: 'column',
content: [
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
{ type: 'paragraph', content: [{ type: 'text', text: 'ok' }] },
],
},
],
},
],
};
const result = applyAdminGate(nested, 'member');
expect(hasHtmlEmbedNode(result)).toBe(false);
const col = findFirstChild(result, 'column');
expect(col.content).toHaveLength(1);
expect(col.content[0].type).toBe('paragraph');
});
it('leaves a non-admin doc without embeds untouched (no needless rewrite)', () => {
const clean = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
};
const result = applyAdminGate(clean, 'member');
expect(result).toBe(clean);
});
});
describe('htmlEmbed source base64 codec', () => {
it('round-trips arbitrary source including UTF-8', () => {
const source = '<script>console.log("héllo → 世界")</script>';
const encoded = encodeHtmlEmbedSource(source);
expect(encoded).not.toContain('<');
expect(decodeHtmlEmbedSource(encoded)).toBe(source);
});
});
describe('htmlEmbed node HTML <-> JSON round-trip', () => {
it('preserves the raw source through HTML -> JSON', () => {
const source = '<script>track("page")</script><style>.a{color:red}</style>';
const encoded = encodeHtmlEmbedSource(source);
const html = `<div data-type="htmlEmbed" data-source="${encoded}"></div>`;
const json = htmlToJson(html);
const node = findFirstChild(json, 'htmlEmbed');
expect(node).toBeDefined();
expect(node.attrs.source).toBe(source);
});
it('round-trips JSON -> HTML -> JSON keeping the source', () => {
const source = '<div onclick="x()">raw &amp; markup</div>';
const json = {
type: 'doc',
content: [{ type: 'htmlEmbed', attrs: { source } }],
};
const html = jsonToHtml(json);
// The static HTML carries the encoded source but does NOT inline the raw
// markup (it must not be an injection vector by itself).
expect(html).toContain('data-type="htmlEmbed"');
expect(html).not.toContain('onclick');
const back = htmlToJson(html);
const node = findFirstChild(back, 'htmlEmbed');
expect(node).toBeDefined();
expect(node.attrs.source).toBe(source);
});
});

View File

@@ -0,0 +1,68 @@
import { JSONContent } from '@tiptap/core';
export const HTML_EMBED_NODE_NAME = 'htmlEmbed';
/**
* Recursively remove every `htmlEmbed` node from a ProseMirror JSON document.
*
* SECURITY: `htmlEmbed` renders raw, unsanitized HTML/CSS/JS in the wiki origin
* (stored-XSS by design, Variant C). Only workspace admins/owners are allowed to
* author it. This helper is the server-side enforcement primitive: every WRITE
* path that may persist content from a NON-admin caller must run the incoming
* document through this function so a non-admin cannot smuggle the node in via
* the collab socket, the REST/MCP/AI content-update path, paste, or import.
*
* Returns a NEW document; the input is not mutated. If the input is not a valid
* doc object it is returned unchanged (callers persist what they were given).
*/
export function stripHtmlEmbedNodes<T = JSONContent>(pmJson: T): T {
if (!pmJson || typeof pmJson !== 'object') {
return pmJson;
}
const node = pmJson as unknown as JSONContent;
if (Array.isArray(node.content)) {
const filtered: JSONContent[] = [];
for (const child of node.content) {
// Drop any htmlEmbed child outright.
if (child && child.type === HTML_EMBED_NODE_NAME) {
continue;
}
// Recurse so nested htmlEmbed nodes (e.g. inside columns/callouts) are
// also removed.
filtered.push(stripHtmlEmbedNodes(child));
}
return { ...node, content: filtered } as unknown as T;
}
return { ...node } as unknown as T;
}
/**
* Returns true if the document contains at least one `htmlEmbed` node anywhere
* in its tree. Useful to decide whether a strip pass actually changed anything
* (e.g. for logging a rejected non-admin embed attempt).
*/
export function hasHtmlEmbedNode(pmJson: unknown): boolean {
if (!pmJson || typeof pmJson !== 'object') {
return false;
}
const node = pmJson as JSONContent;
if (node.type === HTML_EMBED_NODE_NAME) {
return true;
}
if (Array.isArray(node.content)) {
return node.content.some((child) => hasHtmlEmbedNode(child));
}
return false;
}
/**
* Map the workspace user role to whether it may author `htmlEmbed` nodes.
* Owners and admins are trusted; everyone else (member, and any unknown role)
* is not. Kept here so every write path shares one definition of "trusted".
*/
export function canAuthorHtmlEmbed(role: string | null | undefined): boolean {
return role === 'owner' || role === 'admin';
}

View File

@@ -30,6 +30,11 @@ import {
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import {
htmlToJson,
jsonToNode,
@@ -688,7 +693,25 @@ export class PageService {
}
});
const prosemirrorJson = prosemirrorDoc.toJSON();
let prosemirrorJson = prosemirrorDoc.toJSON();
// SECURITY (Variant C admin gate, duplication write path):
// Duplication builds the ydoc directly and bypasses the collab
// onStoreDocument strip. htmlEmbed renders raw, unsanitized JS in
// readers' browsers, so only workspace admins/owners may author it. A
// non-admin with space Edit could otherwise duplicate an admin page
// that contains an embed into a new page authored by them. Strip every
// htmlEmbed node from each duplicated page when the duplicating user is
// not an admin, BEFORE computing textContent/ydoc/insert.
if (
!canAuthorHtmlEmbed(authUser.role) &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin page duplication by user ${authUser.id} (source page ${page.id})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
// Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title;

View File

@@ -23,6 +23,11 @@ import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
import { TransclusionLookup } from './transclusion.types';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
type ReferencingPageInfo = {
id: string;
@@ -461,10 +466,12 @@ export class TransclusionService {
throw new NotFoundException('Sync block not found');
}
const { content, copies } = rewriteAttachmentsForUnsync(
let content: unknown;
let copies: ReturnType<typeof rewriteAttachmentsForUnsync>['copies'];
({ content, copies } = rewriteAttachmentsForUnsync(
transclusion.content,
() => uuid7(),
);
));
if (copies.length > 0) {
const oldIds = copies.map((c) => c.oldAttachmentId);
@@ -513,6 +520,21 @@ export class TransclusionService {
transclusionId,
);
// SECURITY (Variant C admin gate, transclusion unsync write path):
// The returned content is a source snapshot that the client materializes
// into the reference page via insertContentAt. The snapshot keeps any
// htmlEmbed verbatim, and unsync requires only space Edit/View. If the
// requesting user is not a workspace admin/owner, strip htmlEmbed nodes so a
// non-admin can never receive an embed payload to re-persist (the collab
// strip on the subsequent save is debounced/race-prone and must not be the
// only guard). Admin behavior is unchanged.
if (!canAuthorHtmlEmbed(user.role) && hasHtmlEmbedNode(content)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
);
content = stripHtmlEmbedNodes(content);
}
return { content };
}
}

View File

@@ -20,6 +20,12 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
import { markdownToHtml } from '@docmost/editor-ext';
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { formatImportHtml } from '../utils/import-formatter';
import {
buildAttachmentCandidates,
@@ -53,6 +59,7 @@ export class FileImportTaskService {
private readonly backlinkRepo: BacklinkRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly importAttachmentService: ImportAttachmentService,
private readonly userRepo: UserRepo,
private eventEmitter: EventEmitter2,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -149,6 +156,20 @@ export class FileImportTaskService {
.where('id', '=', fileTask.spaceId)
.executeTakeFirst();
// SECURITY (Variant C admin gate, zip/multi-file import write path):
// An imported .html/.md file can carry an htmlEmbed marker (the node's
// serialized form), which would execute raw, unsanitized JS in readers'
// browsers. Only workspace admins/owners may author it. Resolve the
// importer's role ONCE here; each page's prosemirror JSON is run through the
// strip below before textContent/ydoc/insert when the importer is not an
// admin, so a non-admin cannot smuggle the node in via a zip import (which
// requires only space Edit).
const importingUser = await this.userRepo.findById(
fileTask.creatorId,
fileTask.workspaceId,
);
const importerCanAuthorHtmlEmbed = canAuthorHtmlEmbed(importingUser?.role);
const pagesMap = new Map<string, ImportPageNode>();
for (const absPath of allFiles) {
@@ -496,9 +517,21 @@ export class FileImportTaskService {
await this.importService.processHTML(html),
);
const { title, prosemirrorJson } =
let { title, prosemirrorJson } =
this.importService.extractTitleAndRemoveHeading(pmState);
// SECURITY (Variant C admin gate): strip htmlEmbed nodes from pages
// imported by a non-admin BEFORE computing textContent/ydoc/insert.
if (
!importerCanAuthorHtmlEmbed &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin import by user ${fileTask.creatorId} (page ${page.id}, file ${filePath})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
const insertablePage: InsertablePage = {
id: page.id,
slugId: page.slugId,

View File

@@ -1,5 +1,11 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import {
@@ -37,6 +43,7 @@ export class ImportService {
constructor(
private readonly pageRepo: PageRepo,
private readonly userRepo: UserRepo,
private readonly storageService: StorageService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE)
@@ -83,8 +90,24 @@ export class ImportService {
throw new BadRequestException(message);
}
const { title, prosemirrorJson } =
this.extractTitleAndRemoveHeading(prosemirrorState);
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
const title = extracted.title;
let prosemirrorJson = extracted.prosemirrorJson;
// SECURITY (Variant C admin gate, import write path):
// An imported .html/.md file can carry an htmlEmbed marker (the node's
// serialized form), which would execute raw JS in readers' browsers. Only
// workspace admins/owners may author it, so strip htmlEmbed nodes from
// imports performed by a non-admin user.
if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) {
const importingUser = await this.userRepo.findById(userId, workspaceId);
if (!canAuthorHtmlEmbed(importingUser?.role)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin import by user ${userId}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
}
const pageTitle = title || fileName;