fix: Do not show embed option for unembeddable links (#11323)

* fix: Do not show embed option for unembeddable links

* test
This commit is contained in:
Tom Moor
2026-01-31 13:57:25 -05:00
committed by GitHub
parent af6eb6b6ec
commit 446a0e1071
13 changed files with 738 additions and 255 deletions
+52 -38
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { BrowserIcon, EmailIcon, LinkIcon } from "outline-icons";
import React, { useCallback } from "react";
import { EmailIcon, LinkIcon } from "outline-icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
@@ -10,6 +10,7 @@ import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
@@ -24,13 +25,16 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
interface EmbedCheckState {
loading: boolean;
embeddable?: boolean;
}
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
),
(item, _index, options) => <SuggestionsMenuItem {...options} {...item} />,
[]
);
@@ -57,6 +61,44 @@ function useItems({
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const [embedCheck, setEmbedCheck] = useState<EmbedCheckState>({
loading: false,
});
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
}
let cancelled = false;
setEmbedCheck({ loading: true });
client
.post<{ embeddable: boolean; reason?: string }>("/urls.checkEmbed", {
url: singleUrl,
})
.then((res) => {
if (!cancelled) {
setEmbedCheck({ loading: false, embeddable: res.embeddable });
}
})
.catch(() => {
if (!cancelled) {
// Optimistic on error - allow embedding attempt
setEmbedCheck({ loading: false, embeddable: true });
}
});
return () => {
cancelled = true;
};
}, [singleUrl, embed]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -73,8 +115,6 @@ function useItems({
: MentionType.URL;
}
const embed = getMatchingEmbed(embeds, pastedText)?.embed;
return [
{
name: "noop",
@@ -99,7 +139,9 @@ function useItems({
{
name: "embed",
title: t("Embed"),
visible: !!embed,
subtitle:
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
@@ -131,29 +173,8 @@ function useItems({
return !!mentionType;
});
// Check if the links can be converted to embeds.
let embedType: string | undefined = undefined;
const convertibleToEmbedList = pastedText.every((text) => {
const embed = getMatchingEmbed(embeds, text)?.embed;
if (!embed) {
return false;
}
embedType = !embedType || embedType === embed.title ? embed.title : "mixed";
return true;
});
const embedIcon =
embedType === "mixed" ? (
<BrowserIcon />
) : (
embeds.find((e) => e.title === embedType)?.icon
);
// don't render the menu when it can't be converted to other types.
if (!convertibleToMentionList && !convertibleToEmbedList) {
// don't render the menu when it can't be converted to mentions.
if (!convertibleToMentionList) {
return;
}
@@ -170,12 +191,5 @@ function useItems({
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},
{
name: "embed_list",
title: t("Embed"),
visible: !!convertibleToEmbedList,
icon: embedIcon,
attrs: { actorId: user?.id },
},
];
}
+37 -16
View File
@@ -292,6 +292,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleClickItem = React.useCallback(
(item) => {
if (item.disabled) {
return;
}
props.onSelect?.(item);
switch (item.name) {
@@ -578,12 +582,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
event.stopPropagation();
if (filtered.length) {
const prevIndex = selectedIndex - 1;
const prev = filtered[prevIndex];
setSelectedIndex(
Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex)
);
let prevIndex = selectedIndex - 1;
while (prevIndex >= 0) {
const item = filtered[prevIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
prevIndex--;
}
if (prevIndex >= 0) {
setSelectedIndex(prevIndex);
}
} else {
close();
}
@@ -599,15 +611,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
if (filtered.length) {
const total = filtered.length - 1;
const nextIndex = selectedIndex + 1;
const next = filtered[nextIndex];
setSelectedIndex(
Math.min(
next?.name === "separator" ? nextIndex + 1 : nextIndex,
total
)
);
let nextIndex = selectedIndex + 1;
while (nextIndex <= total) {
const item = filtered[nextIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
nextIndex++;
}
if (nextIndex <= total) {
setSelectedIndex(nextIndex);
}
} else {
close();
}
@@ -678,6 +695,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
@@ -693,7 +711,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
};
const handlePointerDown = () => {
if (selectedIndex !== index) {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index
) {
setSelectedIndex(index);
}
};
+1 -1
View File
@@ -82,7 +82,7 @@ import FileStorage from "@server/storage/files";
import type { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import ZipHelper from "@server/utils/ZipHelper";
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embedHelper";
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds";
import { getTeamFromContext } from "@server/utils/passport";
import { assertPresent } from "@server/validation";
import pagination from "../middlewares/pagination";
+11
View File
@@ -53,3 +53,14 @@ export const UrlsCheckCnameSchema = BaseSchema.extend({
});
export type UrlsCheckCnameReq = z.infer<typeof UrlsCheckCnameSchema>;
export const UrlsCheckEmbedSchema = BaseSchema.extend({
body: z.object({
url: z
.string()
.url()
.refine((val) => isUrl(val), { message: ValidateURL.message }),
}),
});
export type UrlsCheckEmbedReq = z.infer<typeof UrlsCheckEmbedSchema>;
+43
View File
@@ -233,6 +233,49 @@ describe("#urls.unfurl", () => {
});
});
describe("#urls.checkEmbed", () => {
let user: User;
beforeEach(async () => {
user = await buildUser();
});
it("should fail with status 400 bad request when url is missing", async () => {
const res = await server.post("/api/urls.checkEmbed", {
body: {
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(400);
});
it("should fail with status 400 bad request when url is not a valid URL", async () => {
const res = await server.post("/api/urls.checkEmbed", {
body: {
token: user.getJwtToken(),
url: "not-a-url",
},
});
expect(res.status).toEqual(400);
});
it("should return a result for valid URLs", async () => {
// Use a YouTube URL which matches a known embed pattern
const res = await server.post("/api/urls.checkEmbed", {
body: {
token: user.getJwtToken(),
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
// Result depends on actual HTTP response from YouTube (or network error)
expect(body).toHaveProperty("embeddable");
});
});
describe("#urls.validateCustomDomain", () => {
it("should succeed with custom domain pointing at server", async () => {
const user = await buildUser();
+25
View File
@@ -17,8 +17,13 @@ import type { APIContext, Unfurl } from "@server/types";
import { CacheHelper, type CacheResult } from "@server/utils/CacheHelper";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import {
checkEmbeddability,
type EmbedCheckResult,
} from "@server/utils/embeds";
import * as T from "./schema";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { Day } from "@shared/utils/time";
const router = new Router();
const plugins = PluginManager.getHooks(Hook.UnfurlProvider);
@@ -172,6 +177,26 @@ router.post(
}
);
router.post(
"urls.checkEmbed",
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
auth(),
validate(T.UrlsCheckEmbedSchema),
async (ctx: APIContext<T.UrlsCheckEmbedReq>) => {
const { url } = ctx.input.body;
const result = await CacheHelper.getDataOrSet<EmbedCheckResult>(
CacheHelper.getEmbedCheckKey(url),
() => checkEmbeddability(url),
Day.seconds
);
ctx.body = result
? { embeddable: result.embeddable, reason: result.reason }
: { embeddable: false, reason: "error" };
}
);
router.post(
"urls.validateCustomDomain",
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
+11
View File
@@ -157,4 +157,15 @@ export class CacheHelper {
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
/**
* Gets key for caching embed check results. This is a global cache key
* (not team-specific) since embed headers are the same for all users.
*
* @param url The URL to generate a cache key for.
* @returns the cache key string.
*/
public static getEmbedCheckKey(url: string) {
return `embed:${url}`;
}
}
+4
View File
@@ -56,4 +56,8 @@ export class CacheHelper {
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
public static getEmbedCheckKey(url: string) {
return `embed:${url}`;
}
}
-125
View File
@@ -1,125 +0,0 @@
import { convertBareUrlsToEmbedMarkdown } from "./embedHelper";
describe("embedHelper", () => {
describe("convertBareUrlsToEmbedMarkdown", () => {
it("should convert bare YouTube URL to embed format", () => {
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
const expected =
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert bare Vimeo URL to embed format", () => {
const input = "https://vimeo.com/123456789";
const expected =
"[https://vimeo.com/123456789](https://vimeo.com/123456789)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert bare youtu.be URL to embed format", () => {
const input = "https://youtu.be/dQw4w9WgXcQ";
const expected =
"[https://youtu.be/dQw4w9WgXcQ](https://youtu.be/dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should not convert URLs that do not match embed patterns", () => {
const input = "https://example.com/some-page";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should not convert URLs that are already in markdown link format", () => {
const input =
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should not convert URLs that have link text", () => {
const input =
"[Watch this video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should not convert URLs that are part of other text on the same line", () => {
const input =
"Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ video";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should handle multiple lines with mixed content", () => {
const input = `Here is some text.
https://www.youtube.com/watch?v=dQw4w9WgXcQ
And some more text.
https://example.com/not-an-embed
https://vimeo.com/987654321`;
const expected = `Here is some text.
[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)
And some more text.
https://example.com/not-an-embed
[https://vimeo.com/987654321](https://vimeo.com/987654321)`;
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should preserve leading whitespace", () => {
const input = " https://www.youtube.com/watch?v=dQw4w9WgXcQ";
const expected =
" [https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should handle empty string", () => {
expect(convertBareUrlsToEmbedMarkdown("")).toBe("");
});
it("should handle text with no URLs", () => {
const input = "This is just regular text with no URLs.";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should convert Spotify URLs", () => {
const input = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT";
const expected =
"[https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT](https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert Loom URLs", () => {
const input = "https://www.loom.com/share/abc123def456";
const expected =
"[https://www.loom.com/share/abc123def456](https://www.loom.com/share/abc123def456)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert Figma URLs", () => {
// Figma regex requires 22-128 character file IDs
const input =
"https://www.figma.com/file/abcdefghij1234567890AB/Design-File";
const expected =
"[https://www.figma.com/file/abcdefghij1234567890AB/Design-File](https://www.figma.com/file/abcdefghij1234567890AB/Design-File)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should handle trailing whitespace on lines", () => {
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ ";
// Trailing whitespace is trimmed, so the URL still gets converted
const expected =
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should not convert URLs with text before them", () => {
const input = "Video: https://www.youtube.com/watch?v=dQw4w9WgXcQ";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
});
});
-75
View File
@@ -1,75 +0,0 @@
import type { EmbedDescriptor } from "@shared/editor/embeds";
import embeds from "@shared/editor/embeds";
/**
* Checks if a URL matches any of the embed patterns.
*
* @param url - The URL to check.
* @param embedDescriptors - The list of embed descriptors to check against.
* @returns True if the URL matches an embed pattern with `matchOnInput` enabled.
*/
function isEmbedUrl(url: string, embedDescriptors: EmbedDescriptor[]): boolean {
for (const embed of embedDescriptors) {
if (!embed.matchOnInput) {
continue;
}
if (embed.matcher(url)) {
return true;
}
}
return false;
}
/**
* A regex pattern that matches URLs at the beginning of a line or as standalone content.
* Matches http:// and https:// URLs.
*/
const bareUrlPattern = /^(https?:\/\/[^\s]+)$/;
/**
* Converts bare URLs in markdown text to the embed-friendly link format `[url](url)`.
* This allows the markdown parser to recognize them as embeds when they match
* supported embed patterns (YouTube, Vimeo, etc.).
*
* Only URLs that match a known embed pattern with `matchOnInput` enabled will be converted.
*
* @param text - The markdown text to process.
* @param embedDescriptors - Optional custom list of embed descriptors. Defaults to built-in embeds.
* @returns The processed text with bare embed URLs converted to link format.
*
* @example
* // Input:
* "Check out this video:\n\nhttps://www.youtube.com/watch?v=dQw4w9WgXcQ\n\nPretty cool!"
*
* // Output:
* "Check out this video:\n\n[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)\n\nPretty cool!"
*/
export function convertBareUrlsToEmbedMarkdown(
text: string,
embedDescriptors: EmbedDescriptor[] = embeds
): string {
const lines = text.split("\n");
return lines
.map((line) => {
const trimmed = line.trim();
// Check if the line is a bare URL
const match = trimmed.match(bareUrlPattern);
if (!match) {
return line;
}
const url = match[1];
// Only convert if the URL matches a known embed pattern
if (isEmbedUrl(url, embedDescriptors)) {
// Preserve leading whitespace from the original line
const leadingWhitespace = line.match(/^(\s*)/)?.[1] ?? "";
return `${leadingWhitespace}[${url}](${url})`;
}
return line;
})
.join("\n");
}
+299
View File
@@ -0,0 +1,299 @@
import fetchMock from "jest-fetch-mock";
import { checkEmbeddability, convertBareUrlsToEmbedMarkdown } from "./embeds";
beforeEach(() => {
fetchMock.resetMocks();
});
describe("checkEmbeddability", () => {
describe("when URL doesn't match any embed pattern", () => {
it("should return embeddable: false with reason: no-match for non-http URLs", async () => {
// The generic embed only matches http/https URLs
const result = await checkEmbeddability("file:///local/path");
expect(result).toEqual({ embeddable: false, reason: "no-match" });
});
it("should return embeddable: false with reason: no-match for invalid URLs", async () => {
const result = await checkEmbeddability("not-a-valid-url");
expect(result).toEqual({ embeddable: false, reason: "no-match" });
});
});
describe("when URL matches an embed pattern", () => {
it("should return embeddable: true when no restrictive headers", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: {},
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: true });
});
it("should return embeddable: false when X-Frame-Options: DENY", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: { "X-Frame-Options": "DENY" },
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
});
it("should return embeddable: false when X-Frame-Options: SAMEORIGIN", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: { "X-Frame-Options": "SAMEORIGIN" },
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
});
it("should return embeddable: false when X-Frame-Options: ALLOW-FROM", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: { "X-Frame-Options": "ALLOW-FROM https://example.com" },
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
});
it("should return embeddable: false when CSP frame-ancestors is 'none'", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: {
"Content-Security-Policy":
"default-src 'self'; frame-ancestors 'none'",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({
embeddable: false,
reason: "csp-frame-ancestors",
});
});
it("should return embeddable: false when CSP frame-ancestors is 'self'", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: {
"Content-Security-Policy": "frame-ancestors 'self'",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({
embeddable: false,
reason: "csp-frame-ancestors",
});
});
it("should return embeddable: true when CSP frame-ancestors is *", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: {
"Content-Security-Policy": "frame-ancestors *",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: true });
});
it("should return embeddable: false when CSP frame-ancestors has specific origins", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: {
"Content-Security-Policy": "frame-ancestors https://allowed-site.com",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({
embeddable: false,
reason: "csp-frame-ancestors",
});
});
it("should return embeddable: false when COEP is require-corp", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: { "Cross-Origin-Embedder-Policy": "require-corp" },
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: false, reason: "coep" });
});
it("should return embeddable: true when COEP is unsafe-none", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: { "Cross-Origin-Embedder-Policy": "unsafe-none" },
});
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: true });
});
it("should return embeddable: false when server returns 403", async () => {
fetchMock.mockResponseOnce("", {
status: 403,
headers: {},
});
const result = await checkEmbeddability(
"https://www.example.com/forbiddenpage"
);
expect(result).toEqual({ embeddable: false, reason: "http-error" });
});
it("should return embeddable: false when server returns 404", async () => {
fetchMock.mockResponseOnce("", {
status: 404,
headers: {},
});
const result = await checkEmbeddability(
"https://www.example.com/nonexistentpage"
);
expect(result).toEqual({ embeddable: false, reason: "http-error" });
});
it("should return embeddable: true on timeout (optimistic)", async () => {
fetchMock.mockAbortOnce();
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: true, reason: "timeout" });
});
it("should return embeddable: true on network error (optimistic)", async () => {
fetchMock.mockRejectOnce(new Error("Network error"));
const result = await checkEmbeddability("https://www.example.com/embed");
expect(result).toEqual({ embeddable: true, reason: "timeout" });
});
});
describe("convertBareUrlsToEmbedMarkdown", () => {
it("should convert bare YouTube URL to embed format", () => {
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
const expected =
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert bare Vimeo URL to embed format", () => {
const input = "https://vimeo.com/123456789";
const expected =
"[https://vimeo.com/123456789](https://vimeo.com/123456789)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert bare youtu.be URL to embed format", () => {
const input = "https://youtu.be/dQw4w9WgXcQ";
const expected =
"[https://youtu.be/dQw4w9WgXcQ](https://youtu.be/dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should not convert URLs that do not match embed patterns", () => {
const input = "https://example.com/some-page";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should not convert URLs that are already in markdown link format", () => {
const input =
"[https://www.example.com/embed](https://www.example.com/embed)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should not convert URLs that have link text", () => {
const input = "[Watch this video](https://www.example.com/embed)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should not convert URLs that are part of other text on the same line", () => {
const input = "Check out https://www.example.com/embed video";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should handle multiple lines with mixed content", () => {
const input = `Here is some text.
https://www.youtube.com/watch?v=dQw4w9WgXcQ
And some more text.
https://example.com/not-an-embed
https://vimeo.com/987654321`;
const expected = `Here is some text.
[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)
And some more text.
https://example.com/not-an-embed
[https://vimeo.com/987654321](https://vimeo.com/987654321)`;
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should preserve leading whitespace", () => {
const input = " https://www.youtube.com/watch?v=dQw4w9WgXcQ";
const expected =
" [https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should handle empty string", () => {
expect(convertBareUrlsToEmbedMarkdown("")).toBe("");
});
it("should handle text with no URLs", () => {
const input = "This is just regular text with no URLs.";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
it("should convert Spotify URLs", () => {
const input = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT";
const expected =
"[https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT](https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert Loom URLs", () => {
const input = "https://www.loom.com/share/abc123def456";
const expected =
"[https://www.loom.com/share/abc123def456](https://www.loom.com/share/abc123def456)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should convert Figma URLs", () => {
// Figma regex requires 22-128 character file IDs
const input =
"https://www.figma.com/file/abcdefghij1234567890AB/Design-File";
const expected =
"[https://www.figma.com/file/abcdefghij1234567890AB/Design-File](https://www.figma.com/file/abcdefghij1234567890AB/Design-File)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should handle trailing whitespace on lines", () => {
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ ";
// Trailing whitespace is trimmed, so the URL still gets converted
const expected =
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
});
it("should not convert URLs with text before them", () => {
const input = "Video: https://www.youtube.com/watch?v=dQw4w9WgXcQ";
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
});
});
});
+254
View File
@@ -0,0 +1,254 @@
import type { EmbedDescriptor } from "@shared/editor/embeds";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
import embeds from "@shared/editor/embeds";
import fetch, { chromeUserAgent } from "./fetch";
import { Second } from "@shared/utils/time";
/**
* Result of an embed check operation.
*/
export interface EmbedCheckResult {
/** Whether the URL can be embedded in an iframe. */
embeddable: boolean;
/** The reason why the URL cannot be embedded, if applicable. */
reason?:
| "x-frame-options"
| "csp-frame-ancestors"
| "no-match"
| "coep"
| "http-error"
| "error"
| "timeout";
}
/**
* Parses X-Frame-Options header and determines if embedding is allowed.
*
* @param value The X-Frame-Options header value.
* @returns true if embedding is blocked, false otherwise.
*/
function isBlockedByXFrameOptions(value: string | null): boolean {
if (!value) {
return false;
}
const normalized = value.toUpperCase().trim();
// DENY - Cannot be embedded anywhere
// SAMEORIGIN - Can only be embedded on same origin (blocks us)
// ALLOW-FROM - Deprecated but treat as blocked
return (
normalized === "DENY" ||
normalized === "SAMEORIGIN" ||
normalized.startsWith("ALLOW-FROM")
);
}
/**
* Parses Content-Security-Policy header and checks if frame-ancestors blocks embedding.
*
* @param value The Content-Security-Policy header value.
* @returns true if embedding is blocked, false otherwise.
*/
function isBlockedByCSP(value: string | null): boolean {
if (!value) {
return false;
}
// Parse the CSP header to find frame-ancestors directive
const directives = value.split(";").map((d) => d.trim());
for (const directive of directives) {
const parts = directive.split(/\s+/);
if (parts[0]?.toLowerCase() === "frame-ancestors") {
const sources = parts.slice(1);
// 'none' - Cannot be embedded anywhere
if (sources.length === 1 && sources[0] === "'none'") {
return true;
}
// 'self' only - Same origin only (blocks us)
if (sources.length === 1 && sources[0] === "'self'") {
return true;
}
// If there are specific origins listed (not * or 'self'), we're probably not in the list
// Allow if * is present anywhere in the list
if (sources.includes("*")) {
return false;
}
// If specific origins are listed without *, treat as blocked (we're probably not in the list)
if (
sources.length > 0 &&
!sources.every((s) => s === "'self'" || s === "'none'")
) {
return true;
}
}
}
return false;
}
/**
* Checks Cross-Origin-Embedder-Policy header for embedding restrictions.
*
* @param value The Cross-Origin-Embedder-Policy header value.
* @returns true if embedding is blocked, false otherwise.
*/
function isBlockedByCOEP(value: string | null): boolean {
if (!value) {
return false;
}
const normalized = value.toLowerCase().trim();
// unsafe-none means no restrictions, anything else blocks cross-origin embedding
return normalized !== "unsafe-none";
}
/**
* Checks if a URL can be embedded in an iframe by verifying:
* 1. The URL matches a known embed pattern
* 2. The URL's response headers don't block iframe embedding
*
* @param url The URL to check for embeddability.
* @returns a promise resolving to the embed check result.
*/
export async function checkEmbeddability(
url: string
): Promise<EmbedCheckResult> {
const match = getMatchingEmbed(embeds, url);
if (!match) {
return { embeddable: false, reason: "no-match" };
}
if (match.embed.title !== "Embed") {
// Known safe embed type
return { embeddable: true };
}
// Make GET request to check headers (HEAD is unreliable for many servers)
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), Second.ms * 3);
const response = await fetch(url, {
method: "GET",
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": chromeUserAgent,
},
});
clearTimeout(timeoutId);
// Get headers then immediately close the connection - we don't need the body
const status = response.status;
const xFrameOptions = response.headers.get("x-frame-options");
const csp = response.headers.get("content-security-policy");
const coep = response.headers.get("cross-origin-embedder-policy");
controller.abort();
// Check for HTTP errors - if the server rejects the request, embedding won't work
if (status >= 400) {
return { embeddable: false, reason: "http-error" };
}
// Check X-Frame-Options header
if (isBlockedByXFrameOptions(xFrameOptions)) {
return { embeddable: false, reason: "x-frame-options" };
}
// Check Content-Security-Policy for frame-ancestors
if (isBlockedByCSP(csp)) {
return { embeddable: false, reason: "csp-frame-ancestors" };
}
// Check Cross-Origin-Embedder-Policy
if (isBlockedByCOEP(coep)) {
return { embeddable: false, reason: "coep" };
}
return { embeddable: true };
} catch {
// On timeout or network error, be optimistic and allow embedding
return { embeddable: true, reason: "timeout" };
}
}
/**
* Checks if a URL matches any of the embed patterns.
*
* @param url - The URL to check.
* @param embedDescriptors - The list of embed descriptors to check against.
* @returns True if the URL matches an embed pattern with `matchOnInput` enabled.
*/
function isEmbedUrl(url: string, embedDescriptors: EmbedDescriptor[]): boolean {
for (const embed of embedDescriptors) {
if (!embed.matchOnInput) {
continue;
}
if (embed.matcher(url)) {
return true;
}
}
return false;
}
/**
* A regex pattern that matches URLs at the beginning of a line or as standalone content.
* Matches http:// and https:// URLs.
*/
const bareUrlPattern = /^(https?:\/\/[^\s]+)$/;
/**
* Converts bare URLs in markdown text to the embed-friendly link format `[url](url)`.
* This allows the markdown parser to recognize them as embeds when they match
* supported embed patterns (YouTube, Vimeo, etc.).
*
* Only URLs that match a known embed pattern with `matchOnInput` enabled will be converted.
*
* @param text - The markdown text to process.
* @param embedDescriptors - Optional custom list of embed descriptors. Defaults to built-in embeds.
* @returns The processed text with bare embed URLs converted to link format.
*
* @example
* // Input:
* "Check out this video:\n\nhttps://www.youtube.com/watch?v=dQw4w9WgXcQ\n\nPretty cool!"
*
* // Output:
* "Check out this video:\n\n[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)\n\nPretty cool!"
*/
export function convertBareUrlsToEmbedMarkdown(
text: string,
embedDescriptors: EmbedDescriptor[] = embeds
): string {
const lines = text.split("\n");
return lines
.map((line) => {
const trimmed = line.trim();
// Check if the line is a bare URL
const match = trimmed.match(bareUrlPattern);
if (!match) {
return line;
}
const url = match[1];
// Only convert if the URL matches a known embed pattern
if (isEmbedUrl(url, embedDescriptors)) {
// Preserve leading whitespace from the original line
const leadingWhitespace = line.match(/^(\s*)/)?.[1] ?? "";
return `${leadingWhitespace}[${url}](${url})`;
}
return line;
})
.join("\n");
}
@@ -534,6 +534,7 @@
"Keep as link": "Keep as link",
"Mention": "Mention",
"Embed": "Embed",
"Not supported": "Not supported",
"More options": "More options",
"Rename": "Rename",
"Insert after": "Insert after",