mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
321 lines
8.3 KiB
TypeScript
321 lines
8.3 KiB
TypeScript
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
|
const DEV_APP_ORIGIN = "https://app.dev.supermemory.ai"
|
|
const PROD_APP_ORIGIN = "https://app.supermemory.ai"
|
|
|
|
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 (url.startsWith("http://") || url.startsWith("https://")) {
|
|
return url
|
|
}
|
|
return `https://${url}`
|
|
}
|
|
|
|
/**
|
|
* Checks if a URL is a Twitter/X URL.
|
|
*/
|
|
export const isTwitterUrl = (url: string): boolean => {
|
|
const normalizedUrl = url.toLowerCase()
|
|
return (
|
|
normalizedUrl.includes("twitter.com") || normalizedUrl.includes("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/")
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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`
|
|
}
|
|
}
|