mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): match YouTube URLs by hostname instead of substring (#1182)
This commit is contained in:
parent
59a0147296
commit
c00f3e1b22
6 changed files with 97 additions and 12 deletions
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "@ui/assets/icons"
|
||||
import { Globe, FileText, FileCode, Image } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
|
||||
function MCPIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
|
|
@ -206,7 +207,7 @@ export function DocumentIcon({
|
|||
return <MCPIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
|
||||
if (isYouTubeUrl(url)) {
|
||||
return <YouTubeIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import dynamic from "next/dynamic"
|
||||
import { isTwitterUrl } from "@/lib/url-helpers"
|
||||
import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers"
|
||||
import { ImagePreview } from "./image-preview"
|
||||
import { TweetContent } from "./tweet"
|
||||
import { NotionDoc } from "./notion-doc"
|
||||
|
|
@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
|
|||
if (document.type === "google_doc") return "google_doc"
|
||||
if (document.type === "google_sheet") return "google_sheet"
|
||||
if (document.type === "google_slide") return "google_slide"
|
||||
if (document.url?.includes("youtube.com")) return "youtube"
|
||||
if (isYouTubeUrl(document.url)) return "youtube"
|
||||
if (document.type === "webpage") return "webpage"
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
|
@ -41,7 +42,7 @@ type CategoryInfo = { label: string; singularLabel: string; key: string }
|
|||
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
|
||||
if (doc.source === "mcp")
|
||||
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
|
||||
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
|
||||
if (isYouTubeUrl(doc.url))
|
||||
return {
|
||||
label: "YouTube Videos",
|
||||
singularLabel: "YouTube Video",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
|
||||
export function isYouTubeUrl(url: string | undefined | null): boolean {
|
||||
if (!url) return false
|
||||
return (
|
||||
url.includes("youtube.com") ||
|
||||
url.includes("youtu.be") ||
|
||||
url.includes("m.youtube.com")
|
||||
)
|
||||
}
|
||||
export { isYouTubeUrl }
|
||||
|
||||
export function extractYouTubeVideoId(
|
||||
url: string | undefined | null,
|
||||
|
|
|
|||
73
apps/web/lib/url-helpers.test.ts
Normal file
73
apps/web/lib/url-helpers.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { isYouTubeUrl } from "./url-helpers"
|
||||
|
||||
describe("isYouTubeUrl", () => {
|
||||
it("matches canonical youtube.com watch URLs", () => {
|
||||
expect(isYouTubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches real youtube subdomains", () => {
|
||||
expect(isYouTubeUrl("https://m.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://music.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches youtu.be short links", () => {
|
||||
expect(isYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches embed and shorts paths", () => {
|
||||
expect(isYouTubeUrl("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("is case-insensitive for scheme and host", () => {
|
||||
expect(isYouTubeUrl("HTTPS://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches scheme-less URLs", () => {
|
||||
expect(isYouTubeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects lookalike domains", () => {
|
||||
expect(isYouTubeUrl("https://notyoutube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isYouTubeUrl("https://myyoutu.be/dQw4w9WgXcQ")).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects hosts that merely start with youtube.com", () => {
|
||||
expect(
|
||||
isYouTubeUrl("https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ"),
|
||||
).toBe(false)
|
||||
expect(isYouTubeUrl("https://youtu.be.evil.example/dQw4w9WgXcQ")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects URLs that only contain youtube.com in the path", () => {
|
||||
expect(
|
||||
isYouTubeUrl("https://evil.example/youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
).toBe(false)
|
||||
expect(isYouTubeUrl("https://evil.example/redirect?to=youtu.be/x")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects empty and nullish input", () => {
|
||||
expect(isYouTubeUrl("")).toBe(false)
|
||||
expect(isYouTubeUrl(null)).toBe(false)
|
||||
expect(isYouTubeUrl(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -157,6 +157,22 @@ export const isTwitterUrl = (url: string): boolean => {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a YouTube URL by matching the hostname against
|
||||
* youtube.com / youtu.be (and their subdomains, e.g. www / m).
|
||||
* Lookalike hosts (`notyoutube.com`, `youtube.com.evil.example`) and URLs
|
||||
* that only contain "youtube.com" in the path do not match.
|
||||
*/
|
||||
export const isYouTubeUrl = (url: string | undefined | null): boolean => {
|
||||
if (!url) return false
|
||||
const parsed = parseWebUrl(url)
|
||||
if (!parsed) return false
|
||||
return (
|
||||
hostnameMatches(parsed.hostname, "youtube.com") ||
|
||||
hostnameMatches(parsed.hostname, "youtu.be")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a LinkedIn profile URL (not a company page).
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue