From f80b69273f1ddd9995e5fa78735124be3ed78f47 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Fri, 27 Jun 2025 12:49:54 +0800 Subject: [PATCH] feat(editor): allow embedding any iframes (#12895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix BS-3606 #### PR Dependency Tree * **PR #12892** * **PR #12895** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Introduced support for embedding generic iframes with customizable dimensions and permissions, while ensuring only secure, non-AFFiNE URLs are allowed. * Enhanced embedding options by prioritizing custom embed blocks over iframe blocks for a richer embedding experience across toolbars and link actions. * Added URL validation to support secure and flexible embedding configurations. * **Bug Fixes** * Improved iframe embedding reliability by removing restrictive HTTP headers and certain Content Security Policy directives that could block iframe usage in the Electron app. --- .../blocks/bookmark/src/configs/toolbar.ts | 79 +++++++++---------- .../affine/blocks/embed/src/common/utils.ts | 7 ++ .../configs/providers/generic.ts | 74 +++++++++++++++++ .../configs/providers/index.ts | 2 + .../link/src/link-node/configs/toolbar.ts | 19 ++--- .../apps/electron/src/main/protocol.ts | 42 ++++++++++ 6 files changed, 171 insertions(+), 52 deletions(-) create mode 100644 blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts diff --git a/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts b/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts index 7d1ebc85b..fea47f1b5 100644 --- a/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts @@ -1,4 +1,5 @@ import { + canEmbedAsEmbedBlock, canEmbedAsIframe, EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE, EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE, @@ -149,13 +150,10 @@ const builtinToolbarConfig = { if (!model) return true; const url = model.props.url; - // check if the url can be embedded as iframe block or other embed blocks - const options = ctx.std - .get(EmbedOptionProvider) - .getEmbedBlockOptions(url); return ( - !canEmbedAsIframe(ctx.std, url) && options?.viewType !== 'embed' + !canEmbedAsIframe(ctx.std, url) && + !canEmbedAsEmbedBlock(ctx.std, url) ); }, run(ctx) { @@ -169,15 +167,8 @@ const builtinToolbarConfig = { let blockId: string | undefined; - // first try to embed as iframe block - if (canEmbedAsIframe(ctx.std, url)) { - const embedIframeService = ctx.std.get(EmbedIframeService); - blockId = embedIframeService.addEmbedIframeBlock( - { url, caption, title, description }, - parent.id, - index - ); - } else { + // first try to embed as a custom embed block + if (canEmbedAsEmbedBlock(ctx.std, url)) { const options = ctx.std .get(EmbedOptionProvider) .getEmbedBlockOptions(url); @@ -202,6 +193,13 @@ const builtinToolbarConfig = { parent, index ); + } else if (canEmbedAsIframe(ctx.std, url)) { + const embedIframeService = ctx.std.get(EmbedIframeService); + blockId = embedIframeService.addEmbedIframeBlock( + { url, caption, title, description }, + parent.id, + index + ); } if (!blockId) return; @@ -379,27 +377,8 @@ const builtinSurfaceToolbarConfig = { let newId: string | undefined; - // first try to embed as iframe block - if (canEmbedAsIframe(ctx.std, url)) { - const embedIframeService = ctx.std.get(EmbedIframeService); - const config = embedIframeService.getConfig(url); - if (!config) { - return; - } - - const bound = Bound.deserialize(xywh); - const options = config.options; - const { widthInSurface, heightInSurface } = options ?? {}; - bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE; - bound.h = - heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE; - - newId = ctx.store.addBlock( - 'affine:embed-iframe', - { url, caption, title, description, xywh: bound.serialize() }, - parent - ); - } else { + // first try to embed as a custom embed block + if (canEmbedAsEmbedBlock(ctx.std, url)) { const options = ctx.std .get(EmbedOptionProvider) .getEmbedBlockOptions(url); @@ -429,8 +408,29 @@ const builtinSurfaceToolbarConfig = { }, parent ); + } else if (canEmbedAsIframe(ctx.std, url)) { + const embedIframeService = ctx.std.get(EmbedIframeService); + const config = embedIframeService.getConfig(url); + if (!config) { + return; + } + + const bound = Bound.deserialize(xywh); + const options = config.options; + const { widthInSurface, heightInSurface } = options ?? {}; + bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE; + bound.h = + heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE; + + newId = ctx.store.addBlock( + 'affine:embed-iframe', + { url, caption, title, description, xywh: bound.serialize() }, + parent + ); } + if (!newId) return; + ctx.command.exec(reassociateConnectorsCommand, { oldId, newId }); ctx.store.deleteBlock(model); @@ -449,13 +449,10 @@ const builtinSurfaceToolbarConfig = { when(ctx) { const model = ctx.getCurrentModelByType(BookmarkBlockModel); if (!model) return false; - const { url } = model.props; - const options = ctx.std - .get(EmbedOptionProvider) - .getEmbedBlockOptions(url); - - return canEmbedAsIframe(ctx.std, url) || options?.viewType === 'embed'; + return ( + canEmbedAsIframe(ctx.std, url) || canEmbedAsEmbedBlock(ctx.std, url) + ); }, content(ctx) { const model = ctx.getCurrentModelByType(BookmarkBlockModel); diff --git a/blocksuite/affine/blocks/embed/src/common/utils.ts b/blocksuite/affine/blocks/embed/src/common/utils.ts index 1f65446d4..4c7d68f35 100644 --- a/blocksuite/affine/blocks/embed/src/common/utils.ts +++ b/blocksuite/affine/blocks/embed/src/common/utils.ts @@ -11,6 +11,8 @@ import { EmbedCardLightVerticalIcon, } from '@blocksuite/affine-components/icons'; import { ColorScheme } from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/std'; import type { TemplateResult } from 'lit'; type EmbedCardIcons = { @@ -40,3 +42,8 @@ export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons { }; } } + +export function canEmbedAsEmbedBlock(std: BlockStdScope, url: string) { + const options = std.get(EmbedOptionProvider).getEmbedBlockOptions(url); + return options?.viewType === 'embed'; +} diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts new file mode 100644 index 000000000..b40c547b0 --- /dev/null +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts @@ -0,0 +1,74 @@ +import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services'; + +const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800; +const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600; +const GENERIC_DEFAULT_WIDTH_PERCENT = 100; +const GENERIC_DEFAULT_HEIGHT_IN_NOTE = 400; + +/** + * AFFiNE domains that should be excluded from generic embedding + * These are based on the centralized cloud constants and known AFFiNE domains + */ +const AFFINE_DOMAINS = [ + 'affine.pro', // Main AFFiNE domain + 'app.affine.pro', // Stable cloud domain + 'insider.affine.pro', // Beta/internal cloud domain + 'affine.fail', // Canary cloud domain + 'toeverything.app', // Safety measure for potential future use +]; + +/** + * Validates if a URL is suitable for generic iframe embedding + * Allows HTTPS URLs but excludes AFFiNE domains + * @param url The URL to validate + * @returns Boolean indicating if the URL can be generically embedded + */ +function isValidGenericEmbedUrl(url: string): boolean { + try { + const parsedUrl = new URL(url); + + // Only allow HTTPS for security + if (parsedUrl.protocol !== 'https:') { + return false; + } + + // Exclude AFFiNE domains + const hostname = parsedUrl.hostname.toLowerCase(); + if ( + AFFINE_DOMAINS.some( + domain => hostname === domain || hostname.endsWith(`.${domain}`) + ) + ) { + return false; + } + + return true; + } catch { + // Invalid URL + return false; + } +} + +const genericConfig = { + name: 'generic', + match: (url: string) => isValidGenericEmbedUrl(url), + buildOEmbedUrl: (url: string) => { + if (!isValidGenericEmbedUrl(url)) { + return undefined; + } + return url; + }, + useOEmbedUrlDirectly: true, + options: { + widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE, + heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE, + widthPercent: GENERIC_DEFAULT_WIDTH_PERCENT, + heightInNote: GENERIC_DEFAULT_HEIGHT_IN_NOTE, + allowFullscreen: true, + style: 'border: none; border-radius: 8px;', + allow: 'clipboard-read; clipboard-write; picture-in-picture;', + referrerpolicy: 'no-referrer-when-downgrade', + }, +}; + +export const GenericEmbedConfig = EmbedIframeConfigExtension(genericConfig); diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts index d4363b3e7..343e90c32 100644 --- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts @@ -1,4 +1,5 @@ import { ExcalidrawEmbedConfig } from './excalidraw'; +import { GenericEmbedConfig } from './generic'; import { GoogleDocsEmbedConfig } from './google-docs'; import { GoogleDriveEmbedConfig } from './google-drive'; import { MiroEmbedConfig } from './miro'; @@ -10,4 +11,5 @@ export const EmbedIframeConfigExtensions = [ MiroEmbedConfig, ExcalidrawEmbedConfig, GoogleDocsEmbedConfig, + GenericEmbedConfig, ]; diff --git a/blocksuite/affine/inlines/link/src/link-node/configs/toolbar.ts b/blocksuite/affine/inlines/link/src/link-node/configs/toolbar.ts index 70a4841b5..845a1ac48 100644 --- a/blocksuite/affine/inlines/link/src/link-node/configs/toolbar.ts +++ b/blocksuite/affine/inlines/link/src/link-node/configs/toolbar.ts @@ -228,23 +228,20 @@ export const builtinInlineLinkToolbarConfig = { const props = { url }; let blockId: string | undefined; - // first try to embed as iframe block const embedIframeService = ctx.std.get(EmbedIframeService); - if (embedIframeService.canEmbed(url)) { + const embedOptions = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + if (embedOptions?.viewType === 'embed') { + const flavour = embedOptions.flavour; + blockId = ctx.store.addBlock(flavour, props, parent, index + 1); + } else if (embedIframeService.canEmbed(url)) { blockId = embedIframeService.addEmbedIframeBlock( props, parent.id, index + 1 ); - } else { - // if not, try to add as other embed link block - const options = ctx.std - .get(EmbedOptionProvider) - .getEmbedBlockOptions(url); - if (options?.viewType !== 'embed') return; - - const flavour = options.flavour; - blockId = ctx.store.addBlock(flavour, props, parent, index + 1); } if (!blockId) return; diff --git a/packages/frontend/apps/electron/src/main/protocol.ts b/packages/frontend/apps/electron/src/main/protocol.ts index 7b55c1fb0..e1ec02a7b 100644 --- a/packages/frontend/apps/electron/src/main/protocol.ts +++ b/packages/frontend/apps/electron/src/main/protocol.ts @@ -160,6 +160,48 @@ export function registerProtocol() { delete responseHeaders['Access-Control-Allow-Origin']; delete responseHeaders['Access-Control-Allow-Headers']; } + + // to allow url embedding, remove "x-frame-options", + // if response header contains "content-security-policy", remove "frame-ancestors/frame-src" + delete responseHeaders['x-frame-options']; + delete responseHeaders['X-Frame-Options']; + + // Handle Content Security Policy headers + const cspHeaders = [ + 'content-security-policy', + 'Content-Security-Policy', + ]; + for (const cspHeader of cspHeaders) { + const cspValues = responseHeaders[cspHeader]; + if (cspValues) { + // Remove frame-ancestors and frame-src directives from CSP + const modifiedCspValues = cspValues + .map(cspValue => { + if (typeof cspValue === 'string') { + return cspValue + .split(';') + .filter(directive => { + const trimmed = directive.trim().toLowerCase(); + return ( + !trimmed.startsWith('frame-ancestors') && + !trimmed.startsWith('frame-src') + ); + }) + .join(';'); + } + return cspValue; + }) + .filter( + value => value && typeof value === 'string' && value.trim() + ); + + if (modifiedCspValues.length > 0) { + responseHeaders[cspHeader] = modifiedCspValues; + } else { + delete responseHeaders[cspHeader]; + } + } + } } })() .catch(err => {