Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/components/document-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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} />
}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/document-modal/content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/timeline-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 2 additions & 8 deletions apps/web/components/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
73 changes: 73 additions & 0 deletions apps/web/lib/url-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
16 changes: 16 additions & 0 deletions apps/web/lib/url-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isYouTubeUrl() now intentionally accepts uppercase YouTube hosts, as covered by the new cases for HTTPS://youtube.com / WWW.YOUTUBE.COM, but the existing video ID extractors still use case-sensitive regexes for youtube.com / youtu.be. A URL like HTTPS://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ will now be routed to YoutubeVideo / YoutubePreview, but extractVideoId() / extractYouTubeVideoId() return null, so the modal shows Invalid YouTube URL format and the card cannot embed. Please make the extraction path URL/hostname-based too, or at least case-insensitive, before broadening detection.

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).
*/
Expand Down