import { NextResponse } from "next/server" import { randomBytes } from "crypto" import { createSupabaseServerClient } from "@/lib/supabase/server" import { checkBotId } from "botid/server" import { rateLimit } from "@/lib/rate-limit" const MAX_NOTE_LENGTH = 1000 function getAppOrigin(request: Request): string { const envUrl = process.env.NEXT_PUBLIC_APP_URL if (envUrl) return envUrl.replace(/\/$/, "") const requestUrl = new URL(request.url) return requestUrl.origin } export async function POST(request: Request) { const botVerification = await checkBotId() if (botVerification.isBot) { return NextResponse.json({ error: "access denied" }, { status: 403 }) } const supabaseClient = await createSupabaseServerClient() const { data: { user }, } = await supabaseClient.auth.getUser() if (!user) { return NextResponse.json({ error: "not authenticated" }, { status: 401 }) } const rateLimitResult = await rateLimit(`share:${user.id}`, 30, 60_000) if (!rateLimitResult.success) { return NextResponse.json({ error: "too many requests" }, { status: 429 }) } const { data: userProfile } = await supabaseClient .from("user_profiles") .select("tier") .eq("id", user.id) .single() const tier = userProfile?.tier ?? "free" const expiryDays = tier === "pro" || tier === "developer" ? 30 : 7 const expiresAt = new Date( Date.now() + expiryDays * 24 * 60 * 60 * 1000 ).toISOString() const body = await request.json().catch(() => null) if (!body || typeof body !== "object") { return NextResponse.json({ error: "invalid request body" }, { status: 400 }) } const entryIdentifier = body.entryIdentifier as string const rawNote = body.note if (!entryIdentifier || typeof entryIdentifier !== "string") { return NextResponse.json( { error: "entryIdentifier is required" }, { status: 400 } ) } let note: string | null = null if (rawNote !== undefined && rawNote !== null) { if (typeof rawNote !== "string") { return NextResponse.json( { error: "note must be a string" }, { status: 400 } ) } if (rawNote.length > MAX_NOTE_LENGTH) { return NextResponse.json( { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, { status: 400 } ) } note = rawNote.trim() || null } const noteIsPublic = body.noteIsPublic === true let highlightedText: string | null = null let highlightTextOffset: number | null = null let highlightTextLength: number | null = null let highlightTextPrefix: string = "" let highlightTextSuffix: string = "" if (body.highlightedText && typeof body.highlightedText === "string") { if (typeof body.highlightTextOffset !== "number" || typeof body.highlightTextLength !== "number") { return NextResponse.json( { error: "highlightTextOffset and highlightTextLength are required with highlightedText" }, { status: 400 } ) } highlightedText = body.highlightedText highlightTextOffset = body.highlightTextOffset highlightTextLength = body.highlightTextLength highlightTextPrefix = typeof body.highlightTextPrefix === "string" ? body.highlightTextPrefix : "" highlightTextSuffix = typeof body.highlightTextSuffix === "string" ? body.highlightTextSuffix : "" } const { data: entryAccess } = await supabaseClient .from("entries") .select("id, feed_id") .eq("id", entryIdentifier) .maybeSingle() if (!entryAccess) { return NextResponse.json( { error: "entry not found or not accessible" }, { status: 404 } ) } const { data: subscriptionAccess } = await supabaseClient .from("subscriptions") .select("id") .eq("feed_id", entryAccess.feed_id) .eq("user_id", user.id) .maybeSingle() if (!subscriptionAccess) { return NextResponse.json( { error: "you do not have access to this entry" }, { status: 403 } ) } const origin = getAppOrigin(request) const { data: existingShare } = await supabaseClient .from("shared_entries") .select("share_token, id") .eq("entry_id", entryIdentifier) .eq("user_id", user.id) .maybeSingle() if (existingShare) { if (highlightedText !== null) { await supabaseClient .from("shared_entries") .update({ highlighted_text: highlightedText, highlight_text_offset: highlightTextOffset, highlight_text_length: highlightTextLength, highlight_text_prefix: highlightTextPrefix, highlight_text_suffix: highlightTextSuffix, }) .eq("id", existingShare.id) } const shareUrl = `${origin}/shared/${existingShare.share_token}` return NextResponse.json({ shareToken: existingShare.share_token, shareUrl, }) } const shareToken = randomBytes(16).toString("base64url") const { error } = await supabaseClient.from("shared_entries").insert({ user_id: user.id, entry_id: entryIdentifier, share_token: shareToken, expires_at: expiresAt, expiry_interval_days: expiryDays, note, note_is_public: noteIsPublic, highlighted_text: highlightedText, highlight_text_offset: highlightTextOffset, highlight_text_length: highlightTextLength, highlight_text_prefix: highlightTextPrefix, highlight_text_suffix: highlightTextSuffix, }) if (error) { return NextResponse.json( { error: "failed to create share" }, { status: 500 } ) } const shareUrl = `${origin}/shared/${shareToken}` return NextResponse.json({ shareToken, shareUrl }) }