diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts index e23b055c3..9fa90346e 100644 --- a/apps/web/app/api/og/route.ts +++ b/apps/web/app/api/og/route.ts @@ -1,3 +1,5 @@ +import { hasVerifiedSession } from "@/lib/verify-session" + interface OGResponse { title: string description: string @@ -13,6 +15,42 @@ function isValidUrl(urlString: string): boolean { } } +const MAX_HTML_BYTES = 2_000_000 + +// OG parsing only needs , so cap the read rather than buffering the whole body. +async function readBoundedText( + response: Response, + maxBytes = MAX_HTML_BYTES, +): Promise { + const contentLength = response.headers.get("content-length") + if (contentLength && Number(contentLength) > maxBytes) { + return null + } + if (!response.body) { + return null + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => {}) + return null + } + chunks.push(value) + } + const merged = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + merged.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(merged) +} + function isPrivateIPv4Octets(a: number, b: number): boolean { // 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback), // 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16 @@ -247,6 +285,10 @@ function resolveImageUrl( export async function GET(request: Request) { try { + if (!(await hasVerifiedSession(request))) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + const { searchParams } = new URL(request.url) const url = searchParams.get("url") @@ -332,7 +374,13 @@ export async function GET(request: Request) { if (contentType && !contentType.includes("text/html")) { return Response.json({ title: "", description: "" }) } - const html = await secondResponse.text() + const html = await readBoundedText(secondResponse) + if (html === null) { + return Response.json( + { error: "Response too large" }, + { status: 413 }, + ) + } return processHtml(html, redirectUrl) } } @@ -349,7 +397,10 @@ export async function GET(request: Request) { return Response.json({ title: "", description: "" }) } - const html = await response.text() + const html = await readBoundedText(response) + if (html === null) { + return Response.json({ error: "Response too large" }, { status: 413 }) + } return processHtml(html, trimmedUrl) } finally { clearTimeout(timeoutId) diff --git a/apps/web/app/api/onboarding/account-status/route.ts b/apps/web/app/api/onboarding/account-status/route.ts deleted file mode 100644 index 3b3eac0c5..000000000 --- a/apps/web/app/api/onboarding/account-status/route.ts +++ /dev/null @@ -1,236 +0,0 @@ -type AccountSource = "x" | "linkedin" - -type ParsedAccount = { - handle: string - url: string -} - -function parseXAccount(value: string): ParsedAccount | null { - const trimmed = value.trim() - if (!trimmed) return null - - let handle = trimmed.replace(/^@/, "") - const lowerValue = handle.toLowerCase() - - if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) { - try { - const url = new URL( - handle.startsWith("http://") || handle.startsWith("https://") - ? handle - : `https://${handle}`, - ) - handle = url.pathname.split("/").filter(Boolean)[0] ?? "" - } catch { - handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? "" - } - } - - handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? "" - if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null - - return { handle, url: `https://x.com/${handle}` } -} - -function parseLinkedInAccount(value: string): ParsedAccount | null { - const trimmed = value.trim() - if (!trimmed) return null - - try { - const url = new URL( - trimmed.startsWith("http://") || trimmed.startsWith("https://") - ? trimmed - : `https://${trimmed}`, - ) - const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i) - const handle = match?.[2] - if (!handle) return null - - return { - handle, - url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`, - } - } catch { - const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i) - const handle = match?.[2] - if (!handle) return null - - return { - handle, - url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`, - } - } -} - -function parseAccount( - source: AccountSource, - value: string, -): ParsedAccount | null { - return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value) -} - -function looksUnavailable(source: AccountSource, html: string) { - const lowerHtml = html.toLowerCase() - if (source === "x") { - return ( - lowerHtml.includes("this account doesn") || - lowerHtml.includes("account suspended") || - lowerHtml.includes("profile not found") - ) - } - - return ( - lowerHtml.includes("profile not found") || - lowerHtml.includes("page not found") || - lowerHtml.includes("this linkedin profile is unavailable") - ) -} - -function linkedinFallback(account: ParsedAccount, status?: number) { - return Response.json({ - found: null, - verified: false, - reason: "unable_to_verify_linkedin", - handle: account.handle, - status, - url: account.url, - }) -} - -async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) { - const oembedUrl = new URL("https://publish.twitter.com/oembed") - oembedUrl.searchParams.set("url", account.url) - - const response = await fetch(oembedUrl, { - signal, - headers: { - Accept: "application/json", - "User-Agent": - "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)", - }, - }) - - if (response.status === 404 || response.status === 410) { - return Response.json({ - found: false, - handle: account.handle, - status: response.status, - url: account.url, - }) - } - - if (!response.ok) { - return Response.json( - { - error: "Unable to verify account", - handle: account.handle, - status: response.status, - url: account.url, - }, - { status: 502 }, - ) - } - - return Response.json({ - found: true, - handle: account.handle, - status: response.status, - url: account.url, - }) -} - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url) - const source = searchParams.get("source") - const value = searchParams.get("value") - - if (source !== "x" && source !== "linkedin") { - return Response.json({ error: "Invalid account source" }, { status: 400 }) - } - - if (!value?.trim()) { - return Response.json({ error: "Missing account value" }, { status: 400 }) - } - - const account = parseAccount(source, value) - if (!account) { - return Response.json({ found: false, reason: "invalid" }, { status: 400 }) - } - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 7000) - - try { - if (source === "x") { - return await verifyXAccount(account, controller.signal) - } - - const response = await fetch(account.url, { - signal: controller.signal, - redirect: "follow", - headers: { - Accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "User-Agent": - "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)", - }, - }) - - if (response.status === 404 || response.status === 410) { - return Response.json({ - found: false, - handle: account.handle, - status: response.status, - url: account.url, - }) - } - - if (!response.ok) { - if (source === "linkedin") { - return linkedinFallback(account, response.status) - } - - return Response.json( - { - error: "Unable to verify account", - handle: account.handle, - status: response.status, - url: account.url, - }, - { status: 502 }, - ) - } - - const html = await response.text() - const found = !looksUnavailable(source, html) - - return Response.json({ - found, - handle: account.handle, - status: response.status, - url: account.url, - }) - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - if (source === "linkedin") { - return linkedinFallback(account) - } - - return Response.json( - { error: "Account lookup timed out", handle: account.handle }, - { status: 504 }, - ) - } - - console.error("Account status lookup failed:", error) - if (source === "linkedin") { - return linkedinFallback(account) - } - - return Response.json( - { error: "Unable to verify account", handle: account.handle }, - { status: 502 }, - ) - } finally { - clearTimeout(timeoutId) - } -} diff --git a/apps/web/app/api/onboarding/extract-content/route.ts b/apps/web/app/api/onboarding/extract-content/route.ts index 9322f3242..56a18d5bb 100644 --- a/apps/web/app/api/onboarding/extract-content/route.ts +++ b/apps/web/app/api/onboarding/extract-content/route.ts @@ -1,3 +1,5 @@ +import { hasVerifiedSession } from "@/lib/verify-session" + export interface ExaContentResult { url: string text: string @@ -16,8 +18,21 @@ if (!exaApiKey) { ) } +function parseHttpUrl(value: string): URL | null { + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" ? url : null + } catch { + return null + } +} + export async function POST(request: Request) { try { + if (!(await hasVerifiedSession(request))) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + if (!exaApiKey) { return Response.json( { error: "Content extraction is unavailable" }, @@ -34,13 +49,37 @@ export async function POST(request: Request) { ) } - if (!urls.every((url) => typeof url === "string" && url.trim())) { + const MAX_URLS = 10 + if (urls.length > MAX_URLS) { return Response.json( - { error: "Invalid input: all urls must be non-empty strings" }, + { error: `Invalid input: at most ${MAX_URLS} urls per request` }, { status: 400 }, ) } + const invalid = Response.json( + { + error: + "Invalid input: all urls must be http(s) strings of at most 2048 characters", + }, + { status: 400 }, + ) + + const normalizedUrls: string[] = [] + const seen = new Set() + for (const url of urls) { + if (typeof url !== "string" || !url.trim() || url.length > 2048) { + return invalid + } + const parsed = parseHttpUrl(url.trim()) + if (!parsed) { + return invalid + } + if (seen.has(parsed.href)) continue + seen.add(parsed.href) + normalizedUrls.push(parsed.href) + } + const response = await fetch("https://api.exa.ai/contents", { method: "POST", headers: { @@ -48,7 +87,7 @@ export async function POST(request: Request) { "Content-Type": "application/json", }, body: JSON.stringify({ - urls, + urls: normalizedUrls, text: true, livecrawl: "fallback", }), diff --git a/apps/web/app/api/onboarding/research/route.ts b/apps/web/app/api/onboarding/research/route.ts index 1bac648c6..e2b7b1294 100644 --- a/apps/web/app/api/onboarding/research/route.ts +++ b/apps/web/app/api/onboarding/research/route.ts @@ -1,5 +1,6 @@ import { xai } from "@ai-sdk/xai" import { generateText } from "ai" +import { hasVerifiedSession } from "@/lib/verify-session" interface ResearchRequest { xUrl: string @@ -18,6 +19,11 @@ const ALLOWED_X_HOSTS: ReadonlySet = new Set([ const X_URL_FALLBACK_REGEX = /^(?:https?:\/\/)?(?:x\.com|www\.x\.com|twitter\.com|www\.twitter\.com|mobile\.twitter\.com)\/([^/\s?#]+)/i +// Each value occupies one prompt line, so collapse whitespace or it can forge extra lines. +function sanitizeContextField(value: unknown): string { + return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "" +} + function isXHost(hostname: string): boolean { return ALLOWED_X_HOSTS.has(hostname.toLowerCase()) } @@ -64,6 +70,10 @@ Format the response as clear, readable paragraphs. Focus on factual information export async function POST(req: Request) { try { + if (!(await hasVerifiedSession(req))) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + const { xUrl, name, email }: ResearchRequest = await req.json() if (!xUrl?.trim()) { @@ -73,6 +83,16 @@ export async function POST(req: Request) { ) } + if ( + (name !== undefined && (typeof name !== "string" || name.length > 200)) || + (email !== undefined && (typeof email !== "string" || email.length > 320)) + ) { + return Response.json( + { error: "Invalid input: name/email too long" }, + { status: 400 }, + ) + } + const handle = extractHandle(xUrl) if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) { @@ -82,9 +102,11 @@ export async function POST(req: Request) { ) } + const safeName = sanitizeContextField(name) + const safeEmail = sanitizeContextField(email) const contextParts: string[] = [] - if (name) contextParts.push(`Name: ${name}`) - if (email) contextParts.push(`Email: ${email}`) + if (safeName) contextParts.push(`Name: ${safeName}`) + if (safeEmail) contextParts.push(`Email: ${safeEmail}`) const userContext = contextParts.length > 0 ? `\n\nAdditional context about the user:\n${contextParts.join("\n")}` @@ -93,6 +115,7 @@ export async function POST(req: Request) { const { text } = await generateText({ model: xai.responses("grok-4-fast"), prompt: finalPrompt(handle, userContext), + abortSignal: AbortSignal.timeout(60_000), tools: { web_search: xai.tools.webSearch(), x_search: xai.tools.xSearch({ diff --git a/apps/web/lib/verify-session.ts b/apps/web/lib/verify-session.ts new file mode 100644 index 000000000..2a4863450 --- /dev/null +++ b/apps/web/lib/verify-session.ts @@ -0,0 +1,47 @@ +import { getBackendUrl } from "./url-helpers" + +const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]) + +// `bun run dev:local` serves localhost while auth lives on api.supermemory.ai, so its cookie never arrives. +function isLocalDevRequest(request: Request): boolean { + if (process.env.NODE_ENV !== "development") { + return false + } + try { + return LOCAL_DEV_HOSTS.has(new URL(request.url).hostname) + } catch { + return false + } +} + +// middleware.ts only checks the cookie is present; metered/proxy routes must verify it server-side. +export async function hasVerifiedSession(request: Request): Promise { + if (isLocalDevRequest(request)) { + return true + } + + const cookie = request.headers.get("cookie") + if (!cookie) { + return false + } + + try { + const response = await fetch(`${getBackendUrl()}/api/auth/get-session`, { + headers: { cookie }, + redirect: "error", + cache: "no-store", + }) + if (!response.ok) { + return false + } + const session: unknown = await response.json() + return Boolean( + session && + typeof session === "object" && + "user" in session && + session.user, + ) + } catch { + return false + } +}