diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api/share | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/app/api/share')
| -rw-r--r-- | apps/web/app/api/share/[token]/route.ts | 85 | ||||
| -rw-r--r-- | apps/web/app/api/share/route.ts | 132 |
2 files changed, 217 insertions, 0 deletions
diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts new file mode 100644 index 0000000..45224aa --- /dev/null +++ b/apps/web/app/api/share/[token]/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete share" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + const body = await request.json() + const rawNote = body.note + + 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 { error } = await supabaseClient + .from("shared_entries") + .update({ note }) + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update share" }, + { status: 500 } + ) + } + + return NextResponse.json({ note }) +} diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts new file mode 100644 index 0000000..2558560 --- /dev/null +++ b/apps/web/app/api/share/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +function buildOrigin(request: Request): string { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "") + } + + return ( + request.headers.get("origin") ?? + `https://${request.headers.get("host")}` + ) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + 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() + 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 { 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 = buildOrigin(request) + + const { data: existingShare } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier) + .eq("user_id", user.id) + .maybeSingle() + + if (existingShare) { + 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, + note, + }) + + if (error) { + return NextResponse.json( + { error: "Failed to create share" }, + { status: 500 } + ) + } + + const shareUrl = `${origin}/shared/${shareToken}` + + return NextResponse.json({ shareToken, shareUrl }) +} |