From 4adf3ba88cf3e1eb5fd7a9f19d7a68211840ac7a Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Thu, 2 Jul 2026 06:14:41 +0530 Subject: [PATCH] fix(web): match YouTube URLs by hostname instead of substring isYouTubeUrl() and a few inline checks classified any URL containing youtube.com / youtu.be as YouTube, so lookalike hosts like notyoutube.com, youtube.com.evil.example, or URLs that only carry youtube.com in the path were rendered with the YouTube icon, grouped as YouTube videos, and opened in the embed player. Centralize detection in lib/url-helpers.ts using the same parseWebUrl/hostnameMatches approach isTwitterUrl already uses, and switch the document modal, document icon, and timeline view to the shared helper. The modal now also recognizes youtu.be short links, which the player component already supported. Add regression tests covering lookalike domains, subdomain tricks, path-only matches, short/mobile links, uppercase schemes, scheme-less URLs, and nullish input. Fixes #1175 --- apps/web/components/document-icon.tsx | 3 +- .../document-modal/content/index.tsx | 4 +- apps/web/components/timeline-view.tsx | 3 +- apps/web/components/utils.ts | 10 +-- apps/web/lib/url-helpers.test.ts | 73 +++++++++++++++++++ apps/web/lib/url-helpers.ts | 16 ++++ 6 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 apps/web/lib/url-helpers.test.ts 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). */