mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
feat(editor): allow embedding any iframes (#12895)
fix BS-3606 #### PR Dependency Tree * **PR #12892** * **PR #12895** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
canEmbedAsEmbedBlock,
|
||||||
canEmbedAsIframe,
|
canEmbedAsIframe,
|
||||||
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||||
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||||
@@ -149,13 +150,10 @@ const builtinToolbarConfig = {
|
|||||||
if (!model) return true;
|
if (!model) return true;
|
||||||
|
|
||||||
const url = model.props.url;
|
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 (
|
return (
|
||||||
!canEmbedAsIframe(ctx.std, url) && options?.viewType !== 'embed'
|
!canEmbedAsIframe(ctx.std, url) &&
|
||||||
|
!canEmbedAsEmbedBlock(ctx.std, url)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
run(ctx) {
|
run(ctx) {
|
||||||
@@ -169,15 +167,8 @@ const builtinToolbarConfig = {
|
|||||||
|
|
||||||
let blockId: string | undefined;
|
let blockId: string | undefined;
|
||||||
|
|
||||||
// first try to embed as iframe block
|
// first try to embed as a custom embed block
|
||||||
if (canEmbedAsIframe(ctx.std, url)) {
|
if (canEmbedAsEmbedBlock(ctx.std, url)) {
|
||||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
|
||||||
blockId = embedIframeService.addEmbedIframeBlock(
|
|
||||||
{ url, caption, title, description },
|
|
||||||
parent.id,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const options = ctx.std
|
const options = ctx.std
|
||||||
.get(EmbedOptionProvider)
|
.get(EmbedOptionProvider)
|
||||||
.getEmbedBlockOptions(url);
|
.getEmbedBlockOptions(url);
|
||||||
@@ -202,6 +193,13 @@ const builtinToolbarConfig = {
|
|||||||
parent,
|
parent,
|
||||||
index
|
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;
|
if (!blockId) return;
|
||||||
@@ -379,27 +377,8 @@ const builtinSurfaceToolbarConfig = {
|
|||||||
|
|
||||||
let newId: string | undefined;
|
let newId: string | undefined;
|
||||||
|
|
||||||
// first try to embed as iframe block
|
// first try to embed as a custom embed block
|
||||||
if (canEmbedAsIframe(ctx.std, url)) {
|
if (canEmbedAsEmbedBlock(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 {
|
|
||||||
const options = ctx.std
|
const options = ctx.std
|
||||||
.get(EmbedOptionProvider)
|
.get(EmbedOptionProvider)
|
||||||
.getEmbedBlockOptions(url);
|
.getEmbedBlockOptions(url);
|
||||||
@@ -429,8 +408,29 @@ const builtinSurfaceToolbarConfig = {
|
|||||||
},
|
},
|
||||||
parent
|
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.command.exec(reassociateConnectorsCommand, { oldId, newId });
|
||||||
|
|
||||||
ctx.store.deleteBlock(model);
|
ctx.store.deleteBlock(model);
|
||||||
@@ -449,13 +449,10 @@ const builtinSurfaceToolbarConfig = {
|
|||||||
when(ctx) {
|
when(ctx) {
|
||||||
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
||||||
if (!model) return false;
|
if (!model) return false;
|
||||||
|
|
||||||
const { url } = model.props;
|
const { url } = model.props;
|
||||||
const options = ctx.std
|
return (
|
||||||
.get(EmbedOptionProvider)
|
canEmbedAsIframe(ctx.std, url) || canEmbedAsEmbedBlock(ctx.std, url)
|
||||||
.getEmbedBlockOptions(url);
|
);
|
||||||
|
|
||||||
return canEmbedAsIframe(ctx.std, url) || options?.viewType === 'embed';
|
|
||||||
},
|
},
|
||||||
content(ctx) {
|
content(ctx) {
|
||||||
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
EmbedCardLightVerticalIcon,
|
EmbedCardLightVerticalIcon,
|
||||||
} from '@blocksuite/affine-components/icons';
|
} from '@blocksuite/affine-components/icons';
|
||||||
import { ColorScheme } from '@blocksuite/affine-model';
|
import { ColorScheme } from '@blocksuite/affine-model';
|
||||||
|
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
||||||
|
import type { BlockStdScope } from '@blocksuite/std';
|
||||||
import type { TemplateResult } from 'lit';
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
type EmbedCardIcons = {
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ExcalidrawEmbedConfig } from './excalidraw';
|
import { ExcalidrawEmbedConfig } from './excalidraw';
|
||||||
|
import { GenericEmbedConfig } from './generic';
|
||||||
import { GoogleDocsEmbedConfig } from './google-docs';
|
import { GoogleDocsEmbedConfig } from './google-docs';
|
||||||
import { GoogleDriveEmbedConfig } from './google-drive';
|
import { GoogleDriveEmbedConfig } from './google-drive';
|
||||||
import { MiroEmbedConfig } from './miro';
|
import { MiroEmbedConfig } from './miro';
|
||||||
@@ -10,4 +11,5 @@ export const EmbedIframeConfigExtensions = [
|
|||||||
MiroEmbedConfig,
|
MiroEmbedConfig,
|
||||||
ExcalidrawEmbedConfig,
|
ExcalidrawEmbedConfig,
|
||||||
GoogleDocsEmbedConfig,
|
GoogleDocsEmbedConfig,
|
||||||
|
GenericEmbedConfig,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -228,23 +228,20 @@ export const builtinInlineLinkToolbarConfig = {
|
|||||||
const props = { url };
|
const props = { url };
|
||||||
let blockId: string | undefined;
|
let blockId: string | undefined;
|
||||||
|
|
||||||
// first try to embed as iframe block
|
|
||||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
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(
|
blockId = embedIframeService.addEmbedIframeBlock(
|
||||||
props,
|
props,
|
||||||
parent.id,
|
parent.id,
|
||||||
index + 1
|
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;
|
if (!blockId) return;
|
||||||
|
|||||||
@@ -160,6 +160,48 @@ export function registerProtocol() {
|
|||||||
delete responseHeaders['Access-Control-Allow-Origin'];
|
delete responseHeaders['Access-Control-Allow-Origin'];
|
||||||
delete responseHeaders['Access-Control-Allow-Headers'];
|
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 => {
|
.catch(err => {
|
||||||
|
|||||||
Reference in New Issue
Block a user