|
|
|
@ -12,11 +12,13 @@ import {
|
|
|
|
|
NonDeletedExcalidrawElement,
|
|
|
|
|
Theme,
|
|
|
|
|
} from "./types";
|
|
|
|
|
import { sanitizeHTMLAttribute } from "../data/url";
|
|
|
|
|
|
|
|
|
|
type EmbeddedLink =
|
|
|
|
|
| ({
|
|
|
|
|
aspectRatio: { w: number; h: number };
|
|
|
|
|
warning?: string;
|
|
|
|
|
sandbox: { allowSameOrigin?: boolean };
|
|
|
|
|
} & (
|
|
|
|
|
| { type: "video" | "generic"; link: string }
|
|
|
|
|
| { type: "document"; srcdoc: (theme: Theme) => string }
|
|
|
|
@ -28,20 +30,21 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
|
|
|
|
|
const RE_YOUTUBE =
|
|
|
|
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
|
|
|
|
const RE_VIMEO =
|
|
|
|
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
|
|
|
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
|
|
|
|
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
|
|
|
|
|
|
|
|
|
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
|
|
|
|
|
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
|
|
|
|
const RE_GH_GIST_EMBED =
|
|
|
|
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
|
|
|
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
|
|
|
|
|
|
|
|
|
// not anchored to start to allow <blockquote> twitter embeds
|
|
|
|
|
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
|
|
|
|
|
const RE_TWITTER =
|
|
|
|
|
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
|
|
|
|
const RE_TWITTER_EMBED =
|
|
|
|
|
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
|
|
|
|
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
|
|
|
|
|
|
|
|
|
|
const RE_VALTOWN =
|
|
|
|
|
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
|
|
|
|
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
|
|
|
|
|
|
|
|
|
const RE_GENERIC_EMBED =
|
|
|
|
|
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
|
|
|
@ -60,6 +63,18 @@ const ALLOWED_DOMAINS = new Set([
|
|
|
|
|
"val.town",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const ALLOW_SAME_ORIGIN = new Set([
|
|
|
|
|
"youtube.com",
|
|
|
|
|
"youtu.be",
|
|
|
|
|
"vimeo.com",
|
|
|
|
|
"player.vimeo.com",
|
|
|
|
|
"figma.com",
|
|
|
|
|
"twitter.com",
|
|
|
|
|
"x.com",
|
|
|
|
|
"*.simplepdf.eu",
|
|
|
|
|
"stackblitz.com",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const createSrcDoc = (body: string) => {
|
|
|
|
|
return `<html><body>${body}</body></html>`;
|
|
|
|
|
};
|
|
|
|
@ -75,6 +90,10 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
|
|
|
|
|
|
|
|
|
const originalLink = link;
|
|
|
|
|
|
|
|
|
|
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
|
|
|
|
|
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let type: "video" | "generic" = "generic";
|
|
|
|
|
let aspectRatio = { w: 560, h: 840 };
|
|
|
|
|
const ytLink = link.match(RE_YOUTUBE);
|
|
|
|
@ -97,8 +116,13 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
|
|
|
|
|
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
|
|
|
|
return { link, aspectRatio, type };
|
|
|
|
|
embeddedLinkCache.set(originalLink, {
|
|
|
|
|
link,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
type,
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
});
|
|
|
|
|
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const vimeoLink = link.match(RE_VIMEO);
|
|
|
|
@ -112,8 +136,13 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
|
|
|
|
aspectRatio = { w: 560, h: 315 };
|
|
|
|
|
//warning deliberately ommited so it is displayed only once per link
|
|
|
|
|
//same link next time will be served from cache
|
|
|
|
|
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
|
|
|
|
return { link, aspectRatio, type, warning };
|
|
|
|
|
embeddedLinkCache.set(originalLink, {
|
|
|
|
|
link,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
type,
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
});
|
|
|
|
|
return { link, aspectRatio, type, warning, sandbox: { allowSameOrigin } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const figmaLink = link.match(RE_FIGMA);
|
|
|
|
@ -123,60 +152,61 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
|
|
|
|
link,
|
|
|
|
|
)}`;
|
|
|
|
|
aspectRatio = { w: 550, h: 550 };
|
|
|
|
|
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
|
|
|
|
return { link, aspectRatio, type };
|
|
|
|
|
embeddedLinkCache.set(originalLink, {
|
|
|
|
|
link,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
type,
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
});
|
|
|
|
|
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const valLink = link.match(RE_VALTOWN);
|
|
|
|
|
if (valLink) {
|
|
|
|
|
link =
|
|
|
|
|
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
|
|
|
|
|
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
|
|
|
|
return { link, aspectRatio, type };
|
|
|
|
|
embeddedLinkCache.set(originalLink, {
|
|
|
|
|
link,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
type,
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
});
|
|
|
|
|
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (RE_TWITTER.test(link)) {
|
|
|
|
|
let ret: EmbeddedLink;
|
|
|
|
|
// assume embed code
|
|
|
|
|
if (/<blockquote/.test(link)) {
|
|
|
|
|
const srcDoc = createSrcDoc(link);
|
|
|
|
|
ret = {
|
|
|
|
|
type: "document",
|
|
|
|
|
srcdoc: () => srcDoc,
|
|
|
|
|
aspectRatio: { w: 480, h: 480 },
|
|
|
|
|
};
|
|
|
|
|
// assume regular tweet url
|
|
|
|
|
} else {
|
|
|
|
|
ret = {
|
|
|
|
|
const postId = link.match(RE_TWITTER)![1];
|
|
|
|
|
// the embed srcdoc still supports twitter.com domain only.
|
|
|
|
|
// Note that we don't attempt to parse the username as it can consist of
|
|
|
|
|
// non-latin1 characters, and the username in the url can be set to anything
|
|
|
|
|
// without affecting the embed.
|
|
|
|
|
const safeURL = sanitizeHTMLAttribute(
|
|
|
|
|
`https://twitter.com/x/status/${postId}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const ret: EmbeddedLink = {
|
|
|
|
|
type: "document",
|
|
|
|
|
srcdoc: (theme: string) =>
|
|
|
|
|
createSrcDoc(
|
|
|
|
|
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
|
|
|
|
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
|
|
|
|
),
|
|
|
|
|
aspectRatio: { w: 480, h: 480 },
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
embeddedLinkCache.set(originalLink, ret);
|
|
|
|
|
return ret;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (RE_GH_GIST.test(link)) {
|
|
|
|
|
let ret: EmbeddedLink;
|
|
|
|
|
// assume embed code
|
|
|
|
|
if (/<script>/.test(link)) {
|
|
|
|
|
const srcDoc = createSrcDoc(link);
|
|
|
|
|
ret = {
|
|
|
|
|
type: "document",
|
|
|
|
|
srcdoc: () => srcDoc,
|
|
|
|
|
aspectRatio: { w: 550, h: 720 },
|
|
|
|
|
};
|
|
|
|
|
// assume regular url
|
|
|
|
|
} else {
|
|
|
|
|
ret = {
|
|
|
|
|
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
|
|
|
|
const safeURL = sanitizeHTMLAttribute(
|
|
|
|
|
`https://gist.github.com/${user}/${gistId}`,
|
|
|
|
|
);
|
|
|
|
|
const ret: EmbeddedLink = {
|
|
|
|
|
type: "document",
|
|
|
|
|
srcdoc: () =>
|
|
|
|
|
createSrcDoc(`
|
|
|
|
|
<script src="${link}.js"></script>
|
|
|
|
|
<script src="${safeURL}.js"></script>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
* { margin: 0px; }
|
|
|
|
|
table, .gist { height: 100%; }
|
|
|
|
@ -184,14 +214,19 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
|
|
|
|
</style>
|
|
|
|
|
`),
|
|
|
|
|
aspectRatio: { w: 550, h: 720 },
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
embeddedLinkCache.set(link, ret);
|
|
|
|
|
return ret;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
embeddedLinkCache.set(link, { link, aspectRatio, type });
|
|
|
|
|
return { link, aspectRatio, type };
|
|
|
|
|
embeddedLinkCache.set(link, {
|
|
|
|
|
link,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
type,
|
|
|
|
|
sandbox: { allowSameOrigin },
|
|
|
|
|
});
|
|
|
|
|
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const isEmbeddableOrFrameLabel = (
|
|
|
|
@ -266,34 +301,39 @@ export const actionSetEmbeddableAsActiveTool = register({
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const validateHostname = (
|
|
|
|
|
const matchHostname = (
|
|
|
|
|
url: string,
|
|
|
|
|
/** using a Set assumes it already contains normalized bare domains */
|
|
|
|
|
allowedHostnames: Set<string> | string,
|
|
|
|
|
): boolean => {
|
|
|
|
|
): string | null => {
|
|
|
|
|
try {
|
|
|
|
|
const { hostname } = new URL(url);
|
|
|
|
|
|
|
|
|
|
const bareDomain = hostname.replace(/^www\./, "");
|
|
|
|
|
|
|
|
|
|
if (allowedHostnames instanceof Set) {
|
|
|
|
|
if (ALLOWED_DOMAINS.has(bareDomain)) {
|
|
|
|
|
return bareDomain;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
|
|
|
|
/^([^.]+)/,
|
|
|
|
|
"*",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (allowedHostnames instanceof Set) {
|
|
|
|
|
return (
|
|
|
|
|
ALLOWED_DOMAINS.has(bareDomain) ||
|
|
|
|
|
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
|
|
|
|
|
);
|
|
|
|
|
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
|
|
|
|
|
return bareDomainWithFirstSubdomainWildcarded;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
|
|
|
|
|
return true;
|
|
|
|
|
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
|
|
|
|
|
if (bareDomain === bareAllowedHostname) {
|
|
|
|
|
return bareAllowedHostname;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const extractSrc = (htmlString: string): string => {
|
|
|
|
@ -338,7 +378,7 @@ export const embeddableURLValidator = (
|
|
|
|
|
if (url.match(domain)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
} else if (validateHostname(url, domain)) {
|
|
|
|
|
} else if (matchHostname(url, domain)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@ -346,5 +386,5 @@ export const embeddableURLValidator = (
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return validateHostname(url, ALLOWED_DOMAINS);
|
|
|
|
|
return !!matchHostname(url, ALLOWED_DOMAINS);
|
|
|
|
|
};
|
|
|
|
|