diff --git a/apps/web/components/document-icon.tsx b/apps/web/components/document-icon.tsx index 00e363414..8088b44eb 100644 --- a/apps/web/components/document-icon.tsx +++ b/apps/web/components/document-icon.tsx @@ -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 } - if (url?.includes("youtube.com") || url?.includes("youtu.be")) { + if (isYouTubeUrl(url)) { return } diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx index 8e1c9a11e..2232d159c 100644 --- a/apps/web/components/document-modal/content/index.tsx +++ b/apps/web/components/document-modal/content/index.tsx @@ -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 diff --git a/apps/web/components/timeline-view.tsx b/apps/web/components/timeline-view.tsx index 18437d774..c0975c892 100644 --- a/apps/web/components/timeline-view.tsx +++ b/apps/web/components/timeline-view.tsx @@ -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", diff --git a/apps/web/components/utils.ts b/apps/web/components/utils.ts index cd0cbc39a..3da320183 100644 --- a/apps/web/components/utils.ts +++ b/apps/web/components/utils.ts @@ -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, diff --git a/apps/web/lib/url-helpers.test.ts b/apps/web/lib/url-helpers.test.ts new file mode 100644 index 000000000..bc34c9c55 --- /dev/null +++ b/apps/web/lib/url-helpers.test.ts @@ -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) + }) +}) diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts index ea5bdec21..cf8f1cf88 100644 --- a/apps/web/lib/url-helpers.ts +++ b/apps/web/lib/url-helpers.ts @@ -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). */