const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]) const DEFAULT_BACKEND_URL = "https://api.supermemory.ai" const DEV_APP_ORIGIN = "https://app.dev.supermemory.ai" const PROD_APP_ORIGIN = "https://app.supermemory.ai" export function getBackendUrl(): string { return (process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_BACKEND_URL).replace( /\/+$/, "", ) } export function getAppOriginForCurrentEnvironment(hostname?: string): string { const currentHostname = hostname ?? (typeof window !== "undefined" ? window.location.hostname : "") const normalized = currentHostname.toLowerCase() const isLocalOrDev = process.env.NODE_ENV !== "production" || PROXY_LOCAL_HOSTS.has(normalized) || normalized.includes("app.dev.supermemory") return isLocalOrDev ? DEV_APP_ORIGIN : PROD_APP_ORIGIN } export function getBillingSettingsUrl(hostname?: string): string { return `${getAppOriginForCurrentEnvironment(hostname)}/settings#billing` } /** Reconstruct the browser-facing URL when running behind portless (or similar). */ export function getPublicRequestUrl(request: Request): URL { const internal = new URL(request.url) const forwardedHost = request.headers .get("x-forwarded-host") ?.split(",")[0] ?.trim() if (forwardedHost) { const proto = request.headers.get("x-forwarded-proto") || "https" return new URL( `${proto}://${forwardedHost}${internal.pathname}${internal.search}`, ) } const portlessUrl = process.env.PORTLESS_URL if (portlessUrl) { try { const base = new URL(portlessUrl) return new URL(`${base.origin}${internal.pathname}${internal.search}`) } catch {} } return internal } /** Map portless proxy localhost redirects back to the current public origin. */ export function resolveAuthRedirectUrl( redirectUrl: string | null, origin: string, ): URL { const fallback = new URL(origin) if (!redirectUrl) return fallback try { const target = new URL(redirectUrl) if (PROXY_LOCAL_HOSTS.has(target.hostname)) { return new URL(`${target.pathname}${target.search}`, origin) } if (target.origin === origin) return target return fallback } catch { return fallback } } /** * Validates if a string is a valid URL. */ export const isValidUrl = (url: string): boolean => { try { new URL(url) return true } catch { return false } } /** * Normalizes a URL by adding https:// prefix if missing. */ export const normalizeUrl = (url: string): string => { if (!url.trim()) return "" if (/^https?:\/\//i.test(url)) { return url } return `https://${url}` } const URL_TOKEN_REGEX = /(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:[/?#][^\s<>[\]"'`]*)?/g const MARKDOWN_LINK_REGEX = /\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g const ANGLE_LINK_REGEX = /<(https?:\/\/[^\s>]+)>/g /** Pull every distinct URL out of a free-text blob; handles markdown `[t](url)`, ``, and bare links. */ export const extractUrls = ( text: string, ): { urls: string[]; duplicates: number } => { if (!text.trim()) return { urls: [], duplicates: 0 } const unwrapped = text .replace(MARKDOWN_LINK_REGEX, " $1 ") .replace(ANGLE_LINK_REGEX, " $1 ") const seen = new Set() const urls: string[] = [] let duplicates = 0 for (const match of unwrapped.matchAll(URL_TOKEN_REGEX)) { const start = match.index ?? 0 const end = start + match[0].length const before = start > 0 ? (unwrapped[start - 1] ?? "") : "" const after = end < unwrapped.length ? (unwrapped[end] ?? "") : "" // Skip email addresses: a domain-shaped token ending at "@" is the // local part, one starting right after "@" is the mail domain. Also // skip matches that begin mid-token (e.g. after "_", which the // hostname charset can't include) — those aren't standalone URLs. if (after === "@" || before === "@" || /[\w.-]/.test(before)) continue let trimmed = match[0].trim().replace(/[.,;!]+$/, "") const opens = (trimmed.match(/\(/g) ?? []).length const closes = (trimmed.match(/\)/g) ?? []).length if (closes > opens && trimmed.endsWith(")")) { trimmed = trimmed.replace(/\)+$/, "").replace(/[.,;!]+$/, "") } const normalized = normalizeUrl(trimmed) if (!isValidUrl(normalized)) continue // Dedupe on the parsed URL so the scheme and host compare // case-insensitively while the path/query — which are case-sensitive // resources — stay distinct. const parsed = new URL(normalized) const key = `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`.replace( /\/+$/, "", ) if (seen.has(key)) { duplicates++ continue } seen.add(key) urls.push(normalized) } 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 parsed = parseWebUrl(url) if (!parsed) return false return ( hostnameMatches(parsed.hostname, "twitter.com") || hostnameMatches(parsed.hostname, "x.com") ) } /** * 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). */ export const isLinkedInProfileUrl = (url: string): boolean => { 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) } /** * Collects and validates URLs from LinkedIn profile and other links, excluding Twitter. */ export const collectValidUrls = ( linkedinProfile: string, otherLinks: string[], ): string[] => { const urls: string[] = [] if (linkedinProfile.trim()) { const normalizedLinkedIn = normalizeUrl(linkedinProfile.trim()) if ( isValidUrl(normalizedLinkedIn) && isLinkedInProfileUrl(normalizedLinkedIn) ) { urls.push(normalizedLinkedIn) } } otherLinks .filter((link) => link.trim()) .forEach((link) => { const normalizedLink = normalizeUrl(link.trim()) if (isValidUrl(normalizedLink) && !isTwitterUrl(normalizedLink)) { urls.push(normalizedLink) } }) return urls } /** * Extracts X/Twitter handle from various input formats (URLs, handles with @, etc.). */ export function parseXHandle(input: string): string { if (!input.trim()) return "" let value = input.trim() if (value.startsWith("@")) { value = value.slice(1) } const lowerValue = value.toLowerCase() if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) { try { let url: URL if (value.startsWith("http://") || value.startsWith("https://")) { url = new URL(value) } else { url = new URL(`https://${value}`) } const pathSegments = url.pathname.split("/").filter(Boolean) if (pathSegments.length > 0) { const firstSegment = pathSegments[0] if (firstSegment && firstSegment !== "status" && firstSegment !== "i") { return firstSegment } } } catch { const match = value.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i) const handle = match?.[1] if (handle && handle !== "status") { return handle } } } if ( value.includes("/") && !lowerValue.includes("x.com") && !lowerValue.includes("twitter.com") ) { const parts = value.split("/").filter(Boolean) const firstPart = parts[0] if (firstPart) { return firstPart } } return value } /** * Extracts LinkedIn handle from various input formats (URLs, handles with @, etc.). */ export function parseLinkedInHandle(input: string): string { if (!input.trim()) return "" let value = input.trim() if (value.startsWith("@")) { value = value.slice(1) } const lowerValue = value.toLowerCase() if (lowerValue.includes("linkedin.com")) { try { let url: URL if (value.startsWith("http://") || value.startsWith("https://")) { url = new URL(value) } else { url = new URL(`https://${value}`) } const pathMatch = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i) const handle = pathMatch?.[2] if (handle) { return handle } } catch { const match = value.match(/linkedin\.com\/(?:in|pub)\/([^/\s?#]+)/i) const handle = match?.[1] if (handle) { return handle } } } if (value.includes("/in/") || value.includes("/pub/")) { const match = value.match(/\/(?:in|pub)\/([^/\s?#]+)/i) const handle = match?.[1] if (handle) { return handle } } return value } /** * Converts X/Twitter handle to full profile URL. */ export function toXProfileUrl(handle: string): string { if (!handle.trim()) return "" return `https://x.com/${handle.trim()}` } /** * Converts LinkedIn handle to full profile URL. */ export function toLinkedInProfileUrl(handle: string): string { if (!handle.trim()) return "" return `https://linkedin.com/in/${handle.trim()}` } /** * Checks if a URL points to a supermemory-hosted file. * Matches the public bucket domain (files.supermemory.ai) and * presigned R2 URLs whose hostname ends with `.r2.cloudflarestorage.com`. * * Note: The R2 check is intentionally broad — it matches any Cloudflare R2 * presigned URL, not only supermemory's account. This is acceptable because * the function is only called on `document.url` values returned by our own * backend, where all R2 URLs originate from the supermemory bucket. * If user-supplied external R2 URLs ever appear in this field, tighten the * check by also validating the account-id subdomain or the bucket path prefix. */ export const isSupermemoryFileUrl = (url: string): boolean => { try { const parsed = new URL(url) if (parsed.hostname === "files.supermemory.ai") return true if (parsed.hostname.endsWith(".r2.cloudflarestorage.com")) return true return false } catch { return false } } /** * Gets the favicon URL for a given URL. */ export function getFaviconUrl(url: string | null | undefined): string | null { if (!url) return null try { const urlObj = new URL(url) return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=16` } catch { return null } } /** * Extracts the document ID from a Google Docs/Sheets/Slides URL. * Works with various URL formats: * - https://docs.google.com/document/d/{id}/edit * - https://docs.google.com/spreadsheets/d/{id}/edit#gid=0 * - https://docs.google.com/presentation/d/{id}/edit */ export function extractGoogleDocId(url: string): string | null { try { const match = url.match(/\/d\/([a-zA-Z0-9_-]+)/) return match?.[1] ?? null } catch { return null } } /** * Generates the embed URL for a Google document based on its type. */ export function getGoogleEmbedUrl( docId: string, type: "google_doc" | "google_sheet" | "google_slide", ): string { switch (type) { case "google_doc": return `https://docs.google.com/document/d/${docId}/preview` case "google_sheet": return `https://docs.google.com/spreadsheets/d/${docId}/preview` case "google_slide": return `https://docs.google.com/presentation/d/${docId}/embed?start=false&loop=false&delayms=3000` } }