mirror of https://github.com/buster-so/buster.git
embed parsing
This commit is contained in:
parent
3b4394874e
commit
709c3bf627
|
@ -29,10 +29,14 @@ const createCspHeader = (isEmbed = false) => {
|
||||||
"img-src 'self' blob: data: https: http:",
|
"img-src 'self' blob: data: https: http:",
|
||||||
// Fonts
|
// Fonts
|
||||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net",
|
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net",
|
||||||
// Frame ancestors
|
// Frame ancestors - allow framing in development, restrict in production
|
||||||
isEmbed ? `frame-ancestors 'self' *` : "frame-ancestors 'none'",
|
isEmbed
|
||||||
// Frame sources
|
? `frame-ancestors 'self' *`
|
||||||
"frame-src 'self' https://vercel.live",
|
: isDev
|
||||||
|
? `frame-ancestors 'self' *`
|
||||||
|
: "frame-ancestors 'none'",
|
||||||
|
// Frame sources - allow embeds from accepted domains
|
||||||
|
"frame-src 'self' https://vercel.live https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com",
|
||||||
// Connect sources for API calls
|
// Connect sources for API calls
|
||||||
(() => {
|
(() => {
|
||||||
const connectSources = [
|
const connectSources = [
|
||||||
|
@ -44,6 +48,19 @@ const createCspHeader = (isEmbed = false) => {
|
||||||
'wss://*.supabase.co',
|
'wss://*.supabase.co',
|
||||||
'https://*.posthog.com',
|
'https://*.posthog.com',
|
||||||
'https://*.slack.com',
|
'https://*.slack.com',
|
||||||
|
// Social media and video platform APIs for embeds
|
||||||
|
'https://*.twitter.com',
|
||||||
|
'https://twitter.com',
|
||||||
|
'https://*.x.com',
|
||||||
|
'https://x.com',
|
||||||
|
'https://*.youtube.com',
|
||||||
|
'https://youtube.com',
|
||||||
|
'https://*.youtube-nocookie.com',
|
||||||
|
'https://youtube-nocookie.com',
|
||||||
|
'https://*.youtu.be',
|
||||||
|
'https://youtu.be',
|
||||||
|
'https://*.vimeo.com',
|
||||||
|
'https://vimeo.com',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
api2Url,
|
api2Url,
|
||||||
profilePictureURL
|
profilePictureURL
|
||||||
|
@ -53,8 +70,8 @@ const createCspHeader = (isEmbed = false) => {
|
||||||
|
|
||||||
return `connect-src ${connectSources.join(' ')}`;
|
return `connect-src ${connectSources.join(' ')}`;
|
||||||
})(),
|
})(),
|
||||||
// Media
|
// Media - allow media content from accepted domains
|
||||||
"media-src 'self'",
|
"media-src 'self' https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com",
|
||||||
// Object
|
// Object
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
// Form actions
|
// Form actions
|
||||||
|
|
|
@ -38,7 +38,7 @@ const sizeVariants = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
'inline-flex whitespace-nowrap items-center overflow-hidden text-base justify-center gap-1.5 shadow rounded transition-all duration-300 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed data-[loading=true]:cursor-progress',
|
'inline-flex cursor-pointer whitespace-nowrap items-center overflow-hidden text-base justify-center gap-1.5 shadow rounded transition-all duration-300 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed data-[loading=true]:cursor-progress',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: buttonTypeClasses,
|
variant: buttonTypeClasses,
|
||||||
|
@ -168,10 +168,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(
|
className={buttonVariants({ variant, size, iconButton, rounding, block, className })}
|
||||||
'cursor-pointer',
|
|
||||||
buttonVariants({ variant, size, iconButton, rounding, block, className })
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -39,6 +39,7 @@ import { Separator } from '../../separator';
|
||||||
import { useBusterNotifications } from '@/context/BusterNotifications';
|
import { useBusterNotifications } from '@/context/BusterNotifications';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useClickAway } from '@/hooks/useClickAway';
|
import { useClickAway } from '@/hooks/useClickAway';
|
||||||
|
import { isUrlFromAcceptedDomain } from '@/lib/url';
|
||||||
|
|
||||||
const parseGenericUrl: EmbedUrlParser = (url: string) => {
|
const parseGenericUrl: EmbedUrlParser = (url: string) => {
|
||||||
return {
|
return {
|
||||||
|
@ -48,6 +49,13 @@ const parseGenericUrl: EmbedUrlParser = (url: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const urlParsers: EmbedUrlParser[] = [parseTwitterUrl, parseVideoUrl, parseGenericUrl];
|
const urlParsers: EmbedUrlParser[] = [parseTwitterUrl, parseVideoUrl, parseGenericUrl];
|
||||||
|
const ACCEPTED_DOMAINS = [
|
||||||
|
process.env.NEXT_PUBLIC_URL,
|
||||||
|
'twitter.com',
|
||||||
|
'x.com',
|
||||||
|
'youtube.com',
|
||||||
|
'vimeo.com'
|
||||||
|
];
|
||||||
|
|
||||||
export const MediaEmbedElement = withHOC(
|
export const MediaEmbedElement = withHOC(
|
||||||
ResizableProvider,
|
ResizableProvider,
|
||||||
|
@ -186,6 +194,16 @@ export const MediaEmbedPlaceholder = (props: PlateElementProps<TMediaEmbedElemen
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the URL is from an accepted domain
|
||||||
|
const isAccepted = isUrlFromAcceptedDomain(url);
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
openInfoMessage(
|
||||||
|
`Please enter a valid URL from an accepted domain: ${ACCEPTED_DOMAINS.join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update the current node with the URL
|
// Update the current node with the URL
|
||||||
editor.tf.setNodes({ url }, { at: editor.api.findPath(props.element) });
|
editor.tf.setNodes({ url }, { at: editor.api.findPath(props.element) });
|
||||||
|
|
||||||
|
|
|
@ -83,18 +83,15 @@ export function MediaToolbar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="box-content flex items-center">
|
<div className="box-content flex items-center">
|
||||||
<FloatingMediaPrimitive.EditButton
|
<FloatingMediaPrimitive.EditButton className={buttonVariants({ variant: 'ghost' })}>
|
||||||
className={buttonVariants({ size: 'small', variant: 'ghost' })}>
|
|
||||||
Edit link
|
Edit link
|
||||||
</FloatingMediaPrimitive.EditButton>
|
</FloatingMediaPrimitive.EditButton>
|
||||||
|
|
||||||
<CaptionButton size="small" variant="ghost">
|
<CaptionButton variant="ghost">Caption</CaptionButton>
|
||||||
Caption
|
|
||||||
</CaptionButton>
|
|
||||||
|
|
||||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
|
||||||
<Button size="small" prefix={<Trash2 />} variant="ghost" {...buttonProps}></Button>
|
<Button prefix={<Trash2 />} variant="ghost" {...buttonProps}></Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { isUrlFromAcceptedDomain } from './url';
|
||||||
|
|
||||||
|
describe('isUrlFromAcceptedDomain', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset environment variable mock before each test
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for URLs from exact accepted domains', () => {
|
||||||
|
// Test case: URLs from exact accepted domains should be allowed
|
||||||
|
// Expected: All these URLs should return true
|
||||||
|
expect(isUrlFromAcceptedDomain('https://twitter.com/status/123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://x.com/user/post')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://youtube.com/watch?v=123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://youtube-nocookie.com/embed/123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://vimeo.com/123456')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('http://twitter.com')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://youtu.be/QrM39m22jH4?list=RDQrM39m22jH4')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for URLs from subdomains of accepted domains', () => {
|
||||||
|
// Test case: URLs from subdomains of accepted domains should be allowed
|
||||||
|
// Expected: All these subdomain URLs should return true
|
||||||
|
expect(isUrlFromAcceptedDomain('https://www.twitter.com/status/123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://mobile.twitter.com/user')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://www.youtube.com/watch?v=123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://m.youtube.com/watch?v=123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://www.youtube-nocookie.com/embed/123')).toBe(true);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://player.vimeo.com/video/123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for URLs from non-accepted domains', () => {
|
||||||
|
// Test case: URLs from domains not in the accepted list should be rejected
|
||||||
|
// Expected: All these URLs should return false
|
||||||
|
expect(isUrlFromAcceptedDomain('https://facebook.com/post/123')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://instagram.com/user')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://tiktok.com/video/123')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('https://malicious-site.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid URLs and handle errors gracefully', () => {
|
||||||
|
// Test case: Invalid URL strings should be handled gracefully and return false
|
||||||
|
// Expected: All invalid URLs should return false without throwing errors
|
||||||
|
expect(isUrlFromAcceptedDomain('not-a-url')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('invalid://url')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('javascript:alert("xss")')).toBe(false);
|
||||||
|
expect(isUrlFromAcceptedDomain('ftp://twitter.com')).toBe(true); // Still valid URL format
|
||||||
|
expect(isUrlFromAcceptedDomain('https://')).toBe(false); // Invalid URL
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,3 +2,42 @@ export const getURLPathname = (url: string): string => {
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
return parsedUrl.pathname.toString();
|
return parsedUrl.pathname.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACCEPTED_DOMAINS = [
|
||||||
|
process.env.NEXT_PUBLIC_URL,
|
||||||
|
'twitter.com',
|
||||||
|
'x.com',
|
||||||
|
'youtube.com',
|
||||||
|
'youtube-nocookie.com',
|
||||||
|
'vimeo.com',
|
||||||
|
'youtu.be'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a URL is from an accepted domain
|
||||||
|
* @param url - The URL string to validate
|
||||||
|
* @returns boolean - true if the URL is from an accepted domain, false otherwise
|
||||||
|
*/
|
||||||
|
export function isUrlFromAcceptedDomain(url: string): boolean {
|
||||||
|
try {
|
||||||
|
console.log('url', url);
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
console.log('parsedUrl', parsedUrl);
|
||||||
|
return ACCEPTED_DOMAINS.some((accepted) => {
|
||||||
|
try {
|
||||||
|
// If accepted is a full URL, compare origins
|
||||||
|
if (accepted?.startsWith('http') || accepted?.startsWith('https')) {
|
||||||
|
const acceptedUrl = new URL(accepted);
|
||||||
|
return parsedUrl.origin === acceptedUrl.origin;
|
||||||
|
}
|
||||||
|
// Otherwise, compare hostnames (e.g., 'youtube.com')
|
||||||
|
return parsedUrl.hostname === accepted || parsedUrl.hostname.endsWith(`.${accepted}`);
|
||||||
|
} catch {
|
||||||
|
// If accepted is not a valid URL, fallback to hostname match
|
||||||
|
return parsedUrl.hostname === accepted || parsedUrl.hostname.endsWith(`.${accepted}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue