Skip to content
Closed
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
59 changes: 59 additions & 0 deletions apps/web/lib/url-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "bun:test"
import {
collectValidUrls,
isLinkedInProfileUrl,
isTwitterUrl,
} from "./url-helpers"

describe("social URL detection", () => {
it("matches Twitter and X by hostname only", () => {
expect(isTwitterUrl("https://twitter.com/supermemoryai")).toBe(true)
expect(isTwitterUrl("https://mobile.twitter.com/supermemoryai")).toBe(true)
expect(isTwitterUrl("https://x.com/supermemoryai")).toBe(true)
expect(isTwitterUrl("x.com/supermemoryai")).toBe(true)

expect(isTwitterUrl("https://exampletwitter.com/supermemoryai")).toBe(false)
expect(isTwitterUrl("https://notx.com/profile")).toBe(false)
expect(isTwitterUrl("https://example.com/twitter.com/supermemoryai")).toBe(
false,
)
expect(isTwitterUrl("https://x.com.evil.example/supermemoryai")).toBe(false)
})

it("matches LinkedIn profile URLs by hostname and /in/ path", () => {
expect(isLinkedInProfileUrl("https://linkedin.com/in/supermemoryai")).toBe(
true,
)
expect(
isLinkedInProfileUrl("https://www.linkedin.com/in/supermemoryai"),
).toBe(true)
expect(isLinkedInProfileUrl("linkedin.com/in/supermemoryai")).toBe(true)

expect(
isLinkedInProfileUrl(
"https://evil.example/linkedin.com/in/supermemoryai",
),
).toBe(false)
expect(isLinkedInProfileUrl("https://linkedin.com/company/acme")).toBe(
false,
)
expect(isLinkedInProfileUrl("https://notlinkedin.com/in/person")).toBe(
false,
)
expect(isLinkedInProfileUrl("https://linkedin.com/in")).toBe(false)
})
})

describe("collectValidUrls", () => {
it("keeps non-Twitter domains that only contain x.com as text", () => {
expect(collectValidUrls("", ["https://notx.com/profile"])).toEqual([
"https://notx.com/profile",
])
})

it("rejects LinkedIn profile URLs on unrelated hosts", () => {
expect(
collectValidUrls("https://evil.example/linkedin.com/in/person", []),
).toEqual([])
})
})
41 changes: 34 additions & 7 deletions apps/web/lib/url-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,25 +120,52 @@ export const extractUrls = (
return { urls, duplicates }
}

const parseWebUrl = (url: string): URL | null => {
const trimmed = url.trim()
if (!trimmed) return null

try {
const parsed = new URL(trimmed)
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed
: null
} catch {
try {
return new URL(`https://${trimmed}`)
} catch {
return null
}
}
}

const hostnameMatches = (hostname: string, domain: string): boolean => {
const normalizedHostname = hostname.toLowerCase()
return (
normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`)
)
}

/**
* Checks if a URL is a Twitter/X URL.
*/
export const isTwitterUrl = (url: string): boolean => {
const normalizedUrl = url.toLowerCase()
const parsed = parseWebUrl(url)

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.

This hostname-based check works for raw uppercase-scheme inputs, but collectValidUrls calls normalizeUrl before this helper. Because normalizeUrl("HTTP://x.com/foo") becomes https://HTTP://x.com/foo, this helper sees hostname http and collectValidUrls("", ["HTTP://x.com/foo"]) now keeps the Twitter link instead of filtering it. Making normalizeUrl detect http(s):// case-insensitively, plus a regression case through collectValidUrls, should preserve the intended behavior.

if (!parsed) return false
return (
normalizedUrl.includes("twitter.com") || normalizedUrl.includes("x.com")
hostnameMatches(parsed.hostname, "twitter.com") ||
hostnameMatches(parsed.hostname, "x.com")
)
}

/**
* Checks if a URL is a LinkedIn profile URL (not a company page).
*/
export const isLinkedInProfileUrl = (url: string): boolean => {
const normalizedUrl = url.toLowerCase()
return (
normalizedUrl.includes("linkedin.com/in/") &&
!normalizedUrl.includes("linkedin.com/company/")
)
const parsed = parseWebUrl(url)
if (!parsed || !hostnameMatches(parsed.hostname, "linkedin.com")) return false

const [section, handle] = parsed.pathname.split("/").filter(Boolean)
return section?.toLowerCase() === "in" && Boolean(handle)
}

/**
Expand Down