diff --git a/.env.example b/.env.example index 67c4fcd..8b9015b 100644 --- a/.env.example +++ b/.env.example @@ -7,10 +7,21 @@ SUPABASE_PUBLISHABLE_KEY= # --- GitHub (optional: unauthenticated requests have lower API rate limits) --- GITHUB_TOKEN= -# --- LLM: configure OpenRouter **or** Google AI Studio (OpenRouter is checked first) --- +# --- Quick LLM (GET prompt / reverse-prompt API) --- +# Pick the provider in one line. Keys for other providers can stay in the file; only the selected one is used. +# GITREVERSE_QUICK_LLM=auto +# auto — first key wins: Grok → OpenRouter → OpenAI → Google (same as leaving this unset) +# grok | openrouter | openai | google — require that provider’s API key only + +# XAI_API_KEY= +# XAI_MODEL=grok-3 # or e.g. grok-4.20-0309-non-reasoning + OPENROUTER_API_KEY= # OPENROUTER_MODEL=google/gemini-2.5-pro +# OPENAI_API_KEY= +# OPENAI_MODEL=gpt-4.1 # or e.g. gpt-5.5 + # GOOGLE_GENERATIVE_AI_API_KEY= # GOOGLE_AI_STUDIO_MODEL=gemini-2.5-pro @@ -18,9 +29,17 @@ OPENROUTER_API_KEY= # OPENROUTER_HTTP_REFERER=https://yoursite.example # OPENROUTER_APP_TITLE=gitreverse -# Cache TTL for reverse-prompt results in Supabase (hours; default 24) -# CACHE_TTL_HOURS=24 - # REQUIRED in production — generate with: openssl rand -hex 32 (or PowerShell random hex) # Without this, the app will refuse to start in production. VIEWS_IP_SALT= + +# --- Custom reverse (optional) --- +# Backend base URL for Custom reverse (local or your own deployment). +# CUSTOM_REVERSE_SERVICE_URL=http://localhost:3001 + +# --- Stripe checkout (optional: “Get Unlimited” on rate limit) --- +# Server-side Checkout Session — success URL is set in code (?session_id={CHECKOUT_SESSION_ID}). +# STRIPE_SECRET_KEY=sk_live_... # or sk_test_... for test mode +# STRIPE_PRICE_ID=price_1TQj8FIBG5KwEK8atVJ49Oq9 # GitReverse $9/mo (or your price id) +# Fallback if /api/create-checkout fails (optional): +# NEXT_PUBLIC_STRIPE_PAYMENT_LINK=https://buy.stripe.com/... diff --git a/.gitignore b/.gitignore index 7b8da95..37b9160 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +debug.log # env files (can opt-in for committing if needed) .env* diff --git a/README.md b/README.md index 3d6ec82..83c70ee 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,58 @@ https://github.com/user-attachments/assets/f0cdb7b2-c6f0-4483-8a01-153170479f2e Turn a **public GitHub repository** into a **single synthetic user prompt** that someone might paste into Cursor, Claude Code, Codex, etc. to vibe code the project from scratch. -The app pulls **repo metadata**, a **root file tree** (depth 1), and the **README**, then uses an LLM via [OpenRouter](https://openrouter.ai/) to produce one short, conversational prompt grounded in that context. +The app pulls **repo metadata**, a **root file tree** (depth 1), and the **README**, then uses an LLM to produce one short, conversational prompt grounded in that context. Paste a GitHub URL or `owner/repo` on the home page. You can also open **`/owner/repo`** (e.g. `/vercel/next.js`) for a shareable link that runs the same flow. +GitHub-style **`/owner/repo/tree/...`** URLs on this site **redirect to `/owner/repo`** so they do not 404. The reverse flow still uses the whole repo for now; **subfolder-aware** context (scoped to that path) is planned for a later change. + ## Stack -Next.js (App Router), React, TypeScript, Tailwind CSS, GitHub API, OpenRouter. +Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4, GitHub API, Supabase (optional), Stripe (optional). ## Configuration -Copy `.env.example` to `.env.local`. You need **`OPENROUTER_API_KEY`**. Optional: `OPENROUTER_MODEL` (defaults to `google/gemini-2.5-pro`), `GITHUB_TOKEN` for better GitHub rate limits, and Supabase env vars from the example file if you want server-side caching. +Copy `.env.example` to `.env.local` and fill in at least one LLM API key. + +### Quick LLM (required) + +The quick reverse endpoint supports four providers. Set **`GITREVERSE_QUICK_LLM`** to pin one, or leave it unset (`auto`) to let the app use whichever key it finds first: + +| Provider | Key env var | Model env var | Default model | +|---|---|---|---| +| Grok (xAI) | `XAI_API_KEY` | `XAI_MODEL` | `grok-3` | +| OpenRouter | `OPENROUTER_API_KEY` | `OPENROUTER_MODEL` | `google/gemini-2.5-pro` | +| OpenAI | `OPENAI_API_KEY` | `OPENAI_MODEL` | `gpt-4.1` | +| Google AI Studio | `GOOGLE_GENERATIVE_AI_API_KEY` | `GOOGLE_AI_STUDIO_MODEL` | `gemini-2.5-pro` | + +In `auto` mode the order of preference is: Grok → OpenRouter → OpenAI → Google. + +### Other env vars + +- **`GITHUB_TOKEN`** — optional; increases GitHub API rate limits. +- **`SUPABASE_URL`** + **`SUPABASE_PUBLISHABLE_KEY`** — optional; enables server-side caching of quick prompts in `prompt_cache` and exposes the `/library` page. +- **`VIEWS_IP_SALT`** — **required in production**. Generate one with `openssl rand -hex 32`. The app will refuse to start in production without a non-default value. + +### Custom reverse (optional) + +For **deep / focus** prompts, point the app at a backend service: + +``` +CUSTOM_REVERSE_SERVICE_URL=http://localhost:3001 +``` + +## Routes + +| Path | Description | +|---|---| +| `/` | Home — quick and custom reverse | +| `/library` | Browse cached quick prompts (requires Supabase) | +| `/history` | Recent repos from localStorage | +| `/[owner]/[repo]` | Shareable quick-reverse link | +| `/[owner]/[repo]/deep` | Shareable deep-reverse link | +| `/[owner]/[repo]/[focus]` | Shareable manual-focus link | +| `/[owner]/[repo]/tree/...` | Redirects to `/[owner]/[repo]` | ## Development @@ -29,6 +70,4 @@ Open [http://localhost:3000](http://localhost:3000). pnpm build pnpm start pnpm lint -``` - -Shout out to [GitIngest](http://github.com/coderamp-labs/gitingest) for inspiration. +``` \ No newline at end of file diff --git a/app/[owner]/[repo]/[focus]/page.tsx b/app/[owner]/[repo]/[focus]/page.tsx new file mode 100644 index 0000000..63673bf --- /dev/null +++ b/app/[owner]/[repo]/[focus]/page.tsx @@ -0,0 +1,69 @@ +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { ReversePromptHome } from "@/components/reverse-prompt-home"; +import { focusFingerprint } from "@/lib/focus-fingerprint"; +import { isValidGitHubRepoPath, normalizeRepoSegment } from "@/lib/parse-github-repo"; +import { getSupabase } from "@/lib/supabase"; + +type PageProps = { + params: Promise<{ owner: string; repo: string; focus: string }>; +}; + +export default async function RepoFocusPage({ params }: PageProps) { + await connection(); + const { owner: ownerRaw, repo: repoRaw, focus: focusRaw } = await params; + const owner = decodeURIComponent(ownerRaw); + const repo = decodeURIComponent(repoRaw); + let focus: string; + try { + focus = decodeURIComponent(focusRaw); + } catch { + notFound(); + } + + if (!isValidGitHubRepoPath(owner, repo)) { + notFound(); + } + + const trimmedFocus = focus.trim(); + if (!trimmedFocus) { + notFound(); + } + + const repoNorm = normalizeRepoSegment(repo); + const initialRepoInput = `${owner}/${repoNorm}`; + const fp = focusFingerprint(trimmedFocus); + + let cachedPrompt: string | undefined; + try { + const supabase = getSupabase(); + if (supabase) { + const { data } = await supabase + .from("custom_prompt_cache") + .select("prompt") + .eq("owner", owner) + .eq("repo", repoNorm) + .eq("focus_fingerprint", fp) + .maybeSingle(); + if (data?.prompt) { + cachedPrompt = data.prompt as string; + } + } + } catch { + // fall back to client auto-submit + } + + return ( + + ); +} diff --git a/app/[owner]/[repo]/deep/page.tsx b/app/[owner]/[repo]/deep/page.tsx new file mode 100644 index 0000000..6b0d833 --- /dev/null +++ b/app/[owner]/[repo]/deep/page.tsx @@ -0,0 +1,57 @@ +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { ReversePromptHome } from "@/components/reverse-prompt-home"; +import { DEEP_REVERSE_FOCUS, focusFingerprint } from "@/lib/focus-fingerprint"; +import { isValidGitHubRepoPath, normalizeRepoSegment } from "@/lib/parse-github-repo"; +import { getSupabase } from "@/lib/supabase"; + +type PageProps = { + params: Promise<{ owner: string; repo: string }>; +}; + +export default async function RepoDeepPage({ params }: PageProps) { + await connection(); + const { owner: ownerRaw, repo: repoRaw } = await params; + const owner = decodeURIComponent(ownerRaw); + const repo = decodeURIComponent(repoRaw); + + if (!isValidGitHubRepoPath(owner, repo)) { + notFound(); + } + + const repoNorm = normalizeRepoSegment(repo); + const initialRepoInput = `${owner}/${repoNorm}`; + const fp = focusFingerprint(DEEP_REVERSE_FOCUS); + + let cachedPrompt: string | undefined; + try { + const supabase = getSupabase(); + if (supabase) { + const { data } = await supabase + .from("custom_prompt_cache") + .select("prompt") + .eq("owner", owner) + .eq("repo", repoNorm) + .eq("focus_fingerprint", fp) + .maybeSingle(); + if (data?.prompt) { + cachedPrompt = data.prompt as string; + } + } + } catch { + // fall back to client auto-submit + } + + return ( + + ); +} diff --git a/app/[owner]/[repo]/page.tsx b/app/[owner]/[repo]/page.tsx index ac451b4..0aaa5ad 100644 --- a/app/[owner]/[repo]/page.tsx +++ b/app/[owner]/[repo]/page.tsx @@ -46,6 +46,7 @@ export default async function RepoPage({ params }: PageProps) { initialPrompt={cachedPrompt} owner={owner} repo={repoNorm} + initialGenerationKind={cachedPrompt ? "quick" : undefined} /> ); } diff --git a/app/[owner]/[repo]/tree/[[...path]]/page.tsx b/app/[owner]/[repo]/tree/[[...path]]/page.tsx new file mode 100644 index 0000000..ba1019c --- /dev/null +++ b/app/[owner]/[repo]/tree/[[...path]]/page.tsx @@ -0,0 +1,25 @@ +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; +import { isValidGitHubRepoPath, normalizeRepoSegment } from "@/lib/parse-github-repo"; + +type PageProps = { + params: Promise<{ owner: string; repo: string; path?: string[] }>; +}; + +/** + * GitHub-style `/owner/repo/tree/branch/...` → `/owner/repo` (avoids 404). + * Subfolder-scoped reverse context: planned for later; see README. + */ +export default async function RepoTreeRedirectPage({ params }: PageProps) { + await connection(); + const { owner: ownerRaw, repo: repoRaw } = await params; + const owner = decodeURIComponent(ownerRaw); + const repo = decodeURIComponent(repoRaw); + + if (!isValidGitHubRepoPath(owner, repo)) { + notFound(); + } + + const repoNorm = normalizeRepoSegment(repo); + redirect(`/${encodeURIComponent(owner)}/${encodeURIComponent(repoNorm)}`); +} diff --git a/app/api/check-subscription/route.ts b/app/api/check-subscription/route.ts new file mode 100644 index 0000000..44040f8 --- /dev/null +++ b/app/api/check-subscription/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkActiveSubscriber } from "@/lib/subscriber"; + +export const runtime = "nodejs"; + +const MAX_EMAIL_LEN = 320; + +export async function GET(req: NextRequest) { + const email = req.nextUrl.searchParams.get("email")?.trim(); + if (!email || email.length > MAX_EMAIL_LEN) { + return NextResponse.json({ error: "bad_email" }, { status: 400 }); + } + + const subscribed = await checkActiveSubscriber(email); + if (subscribed === null) { + return NextResponse.json( + { subscribed: false, degraded: true }, + { status: 200 } + ); + } + + return NextResponse.json({ subscribed }); +} diff --git a/app/api/checkout-abandonment/route.ts b/app/api/checkout-abandonment/route.ts new file mode 100644 index 0000000..48e2acc --- /dev/null +++ b/app/api/checkout-abandonment/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSupabase } from "@/lib/supabase"; + +export const runtime = "nodejs"; + +const ALLOWED_REASONS = new Set([ + "too_expensive", + "not_ready_yet", + "just_browsing", + "other", +]); + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON." }, { status: 400 }); + } + + if (typeof body !== "object" || body === null || !("reason" in body)) { + return NextResponse.json( + { error: "Expected JSON body with reason." }, + { status: 400 } + ); + } + + const reason = (body as { reason: unknown }).reason; + if (typeof reason !== "string" || !ALLOWED_REASONS.has(reason)) { + return NextResponse.json({ error: "Invalid reason." }, { status: 400 }); + } + + const rawOtherText = (body as { other_text?: unknown }).other_text; + const otherText = + reason === "other" && typeof rawOtherText === "string" + ? rawOtherText.trim().slice(0, 1000) + : null; + + const supabase = getSupabase(); + if (!supabase) { + return NextResponse.json({ ok: true }); + } + + const { error } = await supabase.from("checkout_abandonment_responses").insert({ + reason, + ...(otherText ? { other_text: otherText } : {}), + }); + + if (error) { + console.warn("[checkout-abandonment]", error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/create-checkout/route.ts b/app/api/create-checkout/route.ts new file mode 100644 index 0000000..6896786 --- /dev/null +++ b/app/api/create-checkout/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; + +export const runtime = "nodejs"; + +function getStripeClient(): Stripe | null { + const key = process.env.STRIPE_SECRET_KEY?.trim(); + if (!key) return null; + return new Stripe(key, { + apiVersion: "2025-02-24.acacia", + }); +} + +export async function POST(req: NextRequest) { + const stripe = getStripeClient(); + if (!stripe) { + return NextResponse.json( + { + error: "stripe_not_configured", + message: "STRIPE_SECRET_KEY is not set", + }, + { status: 503 } + ); + } + + const priceId = process.env.STRIPE_PRICE_ID?.trim(); + if (!priceId) { + return NextResponse.json( + { + error: "stripe_not_configured", + message: "STRIPE_PRICE_ID is not set", + }, + { status: 503 } + ); + } + + const origin = + req.headers.get("origin")?.trim() || "https://gitreverse.com"; + + try { + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${origin}/?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/`, + allow_promotion_codes: true, + }); + + const url = session.url; + if (!url) { + return NextResponse.json( + { error: "checkout_failed", message: "Stripe returned no checkout URL" }, + { status: 502 } + ); + } + + return NextResponse.json({ url }); + } catch (err) { + console.error("[create-checkout]", err); + return NextResponse.json( + { + error: "checkout_failed", + message: err instanceof Error ? err.message : "Unknown error", + }, + { status: 502 } + ); + } +} diff --git a/app/api/custom-reverse/route.ts b/app/api/custom-reverse/route.ts new file mode 100644 index 0000000..c056778 --- /dev/null +++ b/app/api/custom-reverse/route.ts @@ -0,0 +1,486 @@ +import { NextRequest, NextResponse } from "next/server"; +import http from "node:http"; +import https from "node:https"; +import { URL } from "node:url"; +import { enforceCustomReverseRateLimit } from "@/lib/custom-reverse-rate-limit"; +import { DEEP_REVERSE_FOCUS, focusFingerprint } from "@/lib/focus-fingerprint"; +import { parseGitHubRepoInput } from "@/lib/parse-github-repo"; +import { getSupabase } from "@/lib/supabase"; + +export const runtime = "nodejs"; + +const DEFAULT_CUSTOM_REVERSE_URL = "http://localhost:3001"; + +function getServiceUrl(): string { + return ( + process.env.CUSTOM_REVERSE_SERVICE_URL?.trim() || DEFAULT_CUSTOM_REVERSE_URL + ); +} + +/** 15 min hard cap — route-level abort. */ +const ROUTE_TIMEOUT_MS = 900_000; + +const inFlight = new Map>(); + +function buildInFlightKey(owner: string, repo: string, focus: string): string { + return `${owner}/${repo}:${focusFingerprint(focus)}`; +} + +/** + * Raw http.request with no socket/headers timeout (Node default is 0 = none). + * Needed because global fetch (Undici) has a 5-minute headersTimeout by default, + * which causes "fetch failed" on runs that take longer before sending any response. + */ +function httpPost( + url: string, + body: string +): Promise<{ status: number; json: unknown }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const lib = isHttps ? https : http; + + const req = lib.request( + { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 0, // no socket inactivity timeout + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + reject(new Error(`Upstream returned non-JSON: ${text.slice(0, 200)}`)); + return; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + res.on("error", reject); + } + ); + + req.on("error", reject); + req.write(body); + req.end(); + }); +} + +/** Background-parse SSE for `event: done` and persist; errors ignored. */ +async function parseSseStreamForDonePersist( + body: ReadableStream, + repoUrl: string, + focus: string +): Promise { + const reader = body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + for (;;) { + const idx = buf.indexOf("\n\n"); + if (idx < 0) break; + const block = buf.slice(0, idx); + buf = buf.slice(idx + 2); + if (!block.includes("event: done")) continue; + const dataLine = block + .split("\n") + .find((l) => l.startsWith("data: ")); + if (!dataLine) continue; + try { + const json = JSON.parse( + dataLine.slice(5).trim() as string + ) as { prompt?: string }; + if (typeof json.prompt === "string" && json.prompt) { + persistCustomPromptCache({ + repoUrl: repoUrl.trim(), + focus, + prompt: json.prompt, + }); + } + } catch { + // ignore + } + } + } + } catch { + // ignore + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } +} + +function persistCustomPromptCache(opts: { + repoUrl: string; + focus: string; + prompt: string; +}): void { + const sb = getSupabase(); + if (!sb) return; + const parsed = parseGitHubRepoInput(opts.repoUrl); + if (!parsed) return; + const fp = focusFingerprint(opts.focus); + void sb + .from("custom_prompt_cache") + .upsert( + { + owner: parsed.owner, + repo: parsed.repo, + focus: opts.focus, + focus_fingerprint: fp, + prompt: opts.prompt, + cached_at: new Date().toISOString(), + }, + { onConflict: "owner,repo,focus_fingerprint" } + ) + .then(({ error }) => { + if (error) { + console.error( + "[custom-reverse] Supabase upsert failed:", + error.message + ); + } + }); +} + +async function executeCustomReverse(opts: { + request: NextRequest; + repoUrl: string; + customPrompt: string | undefined; + isDeep: boolean; + focus: string; + parsed: { owner: string; repo: string } | null; +}): Promise { + const { request, repoUrl, customPrompt, isDeep, focus, parsed } = opts; + const fp = focusFingerprint(focus); + + if (parsed) { + const supabase = getSupabase(); + if (supabase) { + try { + const { data, error } = await supabase + .from("custom_prompt_cache") + .select("prompt") + .eq("owner", parsed.owner) + .eq("repo", parsed.repo) + .eq("focus_fingerprint", fp) + .maybeSingle(); + if (!error && data?.prompt) { + return NextResponse.json({ + prompt: data.prompt as string, + fromCache: true, + }); + } + } catch { + // cache miss — continue to upstream + } + } + } + + const rateLimited = await enforceCustomReverseRateLimit(request); + if (rateLimited) return rateLimited; + + const base = getServiceUrl().replace(/\/$/, ""); + + const upstreamBody: { repoUrl: string; customPrompt?: string; mode?: "deep" } = + { + repoUrl: repoUrl.trim(), + }; + if (isDeep) { + upstreamBody.mode = "deep"; + } else { + upstreamBody.customPrompt = customPrompt!.trim(); + } + + let upstreamStatus: number; + let data: unknown; + try { + const timer = new Promise((_, reject) => + setTimeout( + () => reject(new Error("__timeout__")), + ROUTE_TIMEOUT_MS + ) + ); + const result = await Promise.race([ + httpPost(`${base}/reverse`, JSON.stringify(upstreamBody)), + timer, + ]); + upstreamStatus = result.status; + data = result.json; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isTimeout = msg === "__timeout__"; + return NextResponse.json( + { + error: isTimeout + ? "Manual control timed out. Try a smaller repo or a narrower prompt." + : `Manual control service unreachable (${msg}). Check CUSTOM_REVERSE_SERVICE_URL and that the service is running.`, + }, + { status: 503 } + ); + } + + if (upstreamStatus < 200 || upstreamStatus >= 300) { + const err = + data && + typeof data === "object" && + "error" in data && + typeof (data as { error: unknown }).error === "string" + ? (data as { error: string }).error + : `Request failed (${upstreamStatus})`; + return NextResponse.json( + { error: err }, + { status: upstreamStatus >= 400 && upstreamStatus < 600 ? upstreamStatus : 502 } + ); + } + + const prompt = + data && + typeof data === "object" && + "prompt" in data && + typeof (data as { prompt: unknown }).prompt === "string" + ? (data as { prompt: string }).prompt + : null; + + if (!prompt) { + return NextResponse.json( + { error: "Manual control service did not return a prompt." }, + { status: 502 } + ); + } + + persistCustomPromptCache({ + repoUrl: repoUrl.trim(), + focus, + prompt, + }); + + return NextResponse.json({ prompt }, { status: 200 }); +} + +/** + * Server-Sent Events proxy to `custom_reverse` /reverse/stream, with cache short-circuit. + * Tee response to persist the final `done` event in Supabase. + */ +async function executeCustomReverseStream(opts: { + request: NextRequest; + repoUrl: string; + customPrompt: string | undefined; + isDeep: boolean; + focus: string; + parsed: { owner: string; repo: string } | null; +}): Promise { + const { request, repoUrl, customPrompt, isDeep, focus, parsed } = opts; + const fp = focusFingerprint(focus); + + if (parsed) { + const supabase = getSupabase(); + if (supabase) { + try { + const { data, error } = await supabase + .from("custom_prompt_cache") + .select("prompt") + .eq("owner", parsed.owner) + .eq("repo", parsed.repo) + .eq("focus_fingerprint", fp) + .maybeSingle(); + if (!error && data?.prompt) { + return NextResponse.json({ + prompt: data.prompt as string, + fromCache: true, + }); + } + } catch { + // cache miss + } + } + } + + const rateLimited = await enforceCustomReverseRateLimit(request); + if (rateLimited) return rateLimited; + + const base = getServiceUrl().replace(/\/$/, ""); + const upstreamBody: { repoUrl: string; customPrompt?: string; mode?: "deep" } = { + repoUrl: repoUrl.trim(), + }; + if (isDeep) { + upstreamBody.mode = "deep"; + } else { + upstreamBody.customPrompt = customPrompt!.trim(); + } + + let upstream: Response; + try { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), ROUTE_TIMEOUT_MS); + upstream = await fetch(`${base}/reverse/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(upstreamBody), + signal: ac.signal, + }); + clearTimeout(timer); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isAbortOrTimeout = + /abort/i.test(msg) || msg === "The operation was aborted."; + return NextResponse.json( + { + error: isAbortOrTimeout + ? "Manual control timed out. Try a smaller repo or a narrower prompt." + : `Manual control service unreachable (${msg}). Check CUSTOM_REVERSE_SERVICE_URL and that the service is running.`, + }, + { status: 503 } + ); + } + + if (!upstream.ok) { + let err = `Request failed (${upstream.status})`; + try { + const j = (await upstream.json()) as { error?: string }; + if (j.error) err = j.error; + } catch { + // ignore + } + return NextResponse.json( + { error: err }, + { status: upstream.status >= 400 && upstream.status < 600 ? upstream.status : 502 } + ); + } + + if (!upstream.body) { + return NextResponse.json( + { error: "Manual control service returned an empty body." }, + { status: 502 } + ); + } + + const [toClient, toParse] = upstream.body.tee(); + void parseSseStreamForDonePersist( + toParse, + repoUrl.trim(), + focus + ); + + return new NextResponse(toClient, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} + +export async function POST(request: NextRequest) { + let body: { + repoUrl?: string; + customPrompt?: string; + mode?: string; + stream?: boolean; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const repoUrl = body.repoUrl; + const customPrompt = body.customPrompt; + const isDeep = body.mode === "deep"; + const useStream = body.stream === true; + + if (typeof repoUrl !== "string" || !repoUrl.trim()) { + return NextResponse.json( + { error: "repoUrl is required (string)" }, + { status: 400 } + ); + } + if ( + !isDeep && + (typeof customPrompt !== "string" || !customPrompt.trim()) + ) { + return NextResponse.json( + { error: "customPrompt is required (string)" }, + { status: 400 } + ); + } + + const trimmedUrl = repoUrl.trim(); + const focus = isDeep ? DEEP_REVERSE_FOCUS : customPrompt!.trim(); + const parsed = parseGitHubRepoInput(trimmedUrl); + const parsedForCache = parsed + ? { owner: parsed.owner, repo: parsed.repo } + : null; + + if (useStream) { + if (!parsedForCache) { + return executeCustomReverseStream({ + request, + repoUrl: trimmedUrl, + customPrompt, + isDeep, + focus, + parsed: null, + }); + } + return executeCustomReverseStream({ + request, + repoUrl: trimmedUrl, + customPrompt, + isDeep, + focus, + parsed: parsedForCache, + }); + } + + if (!parsedForCache) { + return executeCustomReverse({ + request, + repoUrl: trimmedUrl, + customPrompt, + isDeep, + focus, + parsed: null, + }); + } + + const key = buildInFlightKey(parsedForCache.owner, parsedForCache.repo, focus); + const existing = inFlight.get(key); + if (existing) { + return await existing; + } + + const promise = executeCustomReverse({ + request, + repoUrl: trimmedUrl, + customPrompt, + isDeep, + focus, + parsed: parsedForCache, + }); + inFlight.set(key, promise); + try { + return await promise; + } finally { + inFlight.delete(key); + } +} diff --git a/app/api/increment-views/route.ts b/app/api/increment-views/route.ts index 7a2a807..82dc81a 100644 --- a/app/api/increment-views/route.ts +++ b/app/api/increment-views/route.ts @@ -1,64 +1,23 @@ -import { createHash } from "crypto"; import { NextRequest, NextResponse } from "next/server"; -import { isHomeExampleRepo } from "@/lib/home-example-repos"; import { isValidGitHubRepoPath, normalizeRepoSegment, } from "@/lib/parse-github-repo"; import { getSupabase } from "@/lib/supabase"; +import { + hashVisitorIp, + isDefaultIpHashSaltInProduction, +} from "@/lib/visitor-ip"; export const runtime = "nodejs"; -/* Salt for IP hashing. The default below provides only token protection since - it lives in the public source tree — anyone can recompute hashes for the - IPv4 space. Set VIEWS_IP_SALT in the deployment environment to a long random - secret for meaningful protection of stored hashes. */ -const DEFAULT_IP_HASH_SALT = "gitreverse-views-v1"; -const IP_HASH_SALT = - process.env.VIEWS_IP_SALT?.trim() || DEFAULT_IP_HASH_SALT; - -if ( - IP_HASH_SALT === DEFAULT_IP_HASH_SALT && - process.env.NODE_ENV === "production" -) { +if (isDefaultIpHashSaltInProduction()) { throw new Error( "[increment-views] VIEWS_IP_SALT is not set. " + "Set a random secret (openssl rand -hex 32) in your deployment env." ); } -/** Derive a stable, privacy-preserving hash of the visitor IP. - * - * Header trust order: - * 1. `x-real-ip` — set by Vercel (and most reverse proxies) directly from - * the connecting socket, so it cannot be spoofed by the client. - * 2. `x-forwarded-for` — first non-empty entry. Less trustworthy (the client - * can prepend arbitrary values), but better than nothing for non-Vercel - * deployments. We skip empty entries to avoid the `,real-ip` empty-prefix - * bypass where `split(",")[0]` would otherwise return "". - */ -function hashVisitorIp(req: NextRequest): string | null { - const realIp = req.headers.get("x-real-ip")?.trim(); - if (realIp) { - return createHash("sha256") - .update(`${IP_HASH_SALT}:${realIp}`) - .digest("hex"); - } - - const xffFirst = req.headers - .get("x-forwarded-for") - ?.split(",") - .map((s) => s.trim()) - .find((s) => s.length > 0); - if (xffFirst) { - return createHash("sha256") - .update(`${IP_HASH_SALT}:${xffFirst}`) - .digest("hex"); - } - - return null; -} - export async function POST(req: NextRequest) { let body: unknown; try { @@ -95,10 +54,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid owner or repo." }, { status: 400 }); } - if (isHomeExampleRepo(owner, repo)) { - return NextResponse.json({ ok: true }); - } - const supabase = getSupabase(); if (!supabase) { return NextResponse.json({ error: "Database unavailable." }, { status: 503 }); diff --git a/app/api/reverse-prompt/route.ts b/app/api/reverse-prompt/route.ts index 8238158..2849064 100644 --- a/app/api/reverse-prompt/route.ts +++ b/app/api/reverse-prompt/route.ts @@ -7,38 +7,147 @@ import { getSupabase } from "@/lib/supabase"; const README_MAX_CHARS = 8000; const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; +const XAI_URL = "https://api.x.ai/v1/chat/completions"; +const OPENAI_URL = "https://api.openai.com/v1/chat/completions"; const GOOGLE_AI_STUDIO_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"; -type LlmTarget = - | { provider: "openrouter"; url: string; apiKey: string; model: string } - | { provider: "google"; url: string; apiKey: string; model: string }; +type LlmProvider = "openrouter" | "grok" | "openai" | "google"; + +type LlmTarget = { + provider: LlmProvider; + url: string; + apiKey: string; + model: string; +}; + +function providerDisplayName(p: LlmProvider): string { + switch (p) { + case "openrouter": + return "OpenRouter"; + case "grok": + return "xAI Grok"; + case "openai": + return "OpenAI"; + case "google": + return "Google AI Studio"; + default: { + const _exhaustive: never = p; + return _exhaustive; + } + } +} + +function grokTargetFromApiKey(apiKey: string): LlmTarget { + return { + provider: "grok", + url: XAI_URL, + apiKey, + model: process.env.XAI_MODEL?.trim() || "grok-3", + }; +} + +function openRouterTargetFromApiKey(apiKey: string): LlmTarget { + return { + provider: "openrouter", + url: OPENROUTER_URL, + apiKey, + model: process.env.OPENROUTER_MODEL?.trim() || "google/gemini-2.5-pro", + }; +} + +function openAiTargetFromApiKey(apiKey: string): LlmTarget { + return { + provider: "openai", + url: OPENAI_URL, + apiKey, + model: process.env.OPENAI_MODEL?.trim() || "gpt-4.1", + }; +} + +function googleTargetFromApiKey(apiKey: string): LlmTarget { + return { + provider: "google", + url: GOOGLE_AI_STUDIO_URL, + apiKey, + model: process.env.GOOGLE_AI_STUDIO_MODEL?.trim() || "gemini-2.5-pro", + }; +} + +/** When unset or `auto`, first configured key wins in this order. */ +function resolveLlmTargetAuto( + xaiKey: string | undefined, + openRouterKey: string | undefined, + openAiKey: string | undefined, + googleKey: string | undefined +): LlmTarget | { error: string } { + if (xaiKey) return grokTargetFromApiKey(xaiKey); + if (openRouterKey) return openRouterTargetFromApiKey(openRouterKey); + if (openAiKey) return openAiTargetFromApiKey(openAiKey); + if (googleKey) return googleTargetFromApiKey(googleKey); + return { + error: + "No LLM API key configured. Set GITREVERSE_QUICK_LLM and the matching key(s), or leave GITREVERSE_QUICK_LLM unset (auto) and set one of: XAI_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY.", + }; +} function resolveLlmTarget(): LlmTarget | { error: string } { + const modeRaw = process.env.GITREVERSE_QUICK_LLM?.trim().toLowerCase() ?? ""; + const mode = modeRaw === "" ? "auto" : modeRaw; + + const xaiKey = process.env.XAI_API_KEY?.trim(); const openRouterKey = process.env.OPENROUTER_API_KEY?.trim(); - if (openRouterKey) { - return { - provider: "openrouter", - url: OPENROUTER_URL, - apiKey: openRouterKey, - model: - process.env.OPENROUTER_MODEL?.trim() || "google/gemini-2.5-pro", - }; - } + const openAiKey = process.env.OPENAI_API_KEY?.trim(); const googleKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim(); - if (googleKey) { + + if (mode === "auto") { + return resolveLlmTargetAuto(xaiKey, openRouterKey, openAiKey, googleKey); + } + + const valid = new Set(["grok", "openrouter", "openai", "google"]); + if (!valid.has(mode)) { return { - provider: "google", - url: GOOGLE_AI_STUDIO_URL, - apiKey: googleKey, - model: - process.env.GOOGLE_AI_STUDIO_MODEL?.trim() || "gemini-2.5-pro", + error: + "Invalid GITREVERSE_QUICK_LLM. Use grok, openrouter, openai, google, or auto.", }; } - return { - error: - "No LLM API key configured. Set OPENROUTER_API_KEY (recommended) or GOOGLE_GENERATIVE_AI_API_KEY in .env.local.", - }; + + const explicitMode = mode as LlmProvider; + + switch (explicitMode) { + case "grok": + if (!xaiKey) { + return { + error: + "GITREVERSE_QUICK_LLM=grok requires XAI_API_KEY in .env.local.", + }; + } + return grokTargetFromApiKey(xaiKey); + case "openrouter": + if (!openRouterKey) { + return { + error: + "GITREVERSE_QUICK_LLM=openrouter requires OPENROUTER_API_KEY in .env.local.", + }; + } + return openRouterTargetFromApiKey(openRouterKey); + case "openai": + if (!openAiKey) { + return { + error: + "GITREVERSE_QUICK_LLM=openai requires OPENAI_API_KEY in .env.local.", + }; + } + return openAiTargetFromApiKey(openAiKey); + case "google": + if (!googleKey) { + return { + error: + "GITREVERSE_QUICK_LLM=google requires GOOGLE_GENERATIVE_AI_API_KEY in .env.local.", + }; + } + return googleTargetFromApiKey(googleKey); + } } const inFlight = new Map>(); @@ -81,11 +190,6 @@ function buildUserMessage( ].join("\n"); } -function cacheTtlHours(): number { - const n = Number(process.env.CACHE_TTL_HOURS); - return Number.isFinite(n) && n > 0 ? n : 24; -} - /** Maps to client 429 handling → “Browse the library” (same as GitHub/rate limits). */ function isExhaustedCreditsOrQuotaMessage(msg: string): boolean { const lower = msg.toLowerCase(); @@ -108,6 +212,12 @@ function isExhaustedCreditsOrQuotaMessage(msg: string): boolean { ) { return true; } + if ( + lower.includes("insufficient_quota") || + lower.includes("rate_limit_exceeded") + ) { + return true; + } return false; } @@ -187,26 +297,16 @@ export async function POST(request: NextRequest) { const promise = (async () => { const supabase = getSupabase(); - let stalePrompt: string | null = null; if (supabase) { try { - const ttlHours = cacheTtlHours(); const { data, error } = await supabase .from("prompt_cache") - .select("prompt, cached_at") + .select("prompt") .eq("owner", owner) .eq("repo", repo) .maybeSingle(); if (!error && data?.prompt) { - if (data.cached_at) { - const ageHours = - (Date.now() - new Date(data.cached_at).getTime()) / 36e5; - if (ageHours < ttlHours) { - return { prompt: data.prompt as string }; - } - } - // Entry exists but is stale — keep as fallback - stalePrompt = data.prompt as string; + return { prompt: data.prompt as string }; } } catch { // cache miss — continue to GitHub + LLM @@ -278,10 +378,7 @@ export async function POST(request: NextRequest) { }), }); } catch (e) { - const label = - llm.provider === "openrouter" - ? "OpenRouter" - : "Google AI Studio"; + const label = providerDisplayName(llm.provider); const message = e instanceof Error ? e.message : `${label} request failed`; return NextResponse.json( @@ -294,10 +391,7 @@ export async function POST(request: NextRequest) { try { data = await res.json(); } catch { - const label = - llm.provider === "openrouter" - ? "OpenRouter" - : "Google AI Studio"; + const label = providerDisplayName(llm.provider); return NextResponse.json( { error: `${label} returned invalid JSON.` }, { status: 502 } @@ -305,10 +399,7 @@ export async function POST(request: NextRequest) { } if (!res.ok) { - const label = - llm.provider === "openrouter" - ? "OpenRouter" - : "Google AI Studio"; + const label = providerDisplayName(llm.provider); const msg = extractProviderErrorMessage(data) ?? `${label} error ${res.status}: ${JSON.stringify(data).slice(0, 300)}`; @@ -319,9 +410,6 @@ export async function POST(request: NextRequest) { isExhaustedCreditsOrQuotaMessage(msg); if (creditsExhausted) { - if (stalePrompt) { - return { prompt: stalePrompt }; - } return NextResponse.json( { error: "Service is currently over capacity. Try again later." }, { status: 429 } @@ -336,7 +424,11 @@ export async function POST(request: NextRequest) { const authHint = llm.provider === "openrouter" ? "OpenRouter authentication failed. Check OPENROUTER_API_KEY in .env.local." - : "Google AI Studio authentication failed. Check GOOGLE_GENERATIVE_AI_API_KEY in .env.local."; + : llm.provider === "grok" + ? "xAI Grok authentication failed. Check XAI_API_KEY in .env.local." + : llm.provider === "openai" + ? "OpenAI authentication failed. Check OPENAI_API_KEY in .env.local." + : "Google AI Studio authentication failed. Check GOOGLE_GENERATIVE_AI_API_KEY in .env.local."; return NextResponse.json( { error: isAuth ? authHint : `Generation failed: ${msg}`, diff --git a/app/api/verify-subscription/route.ts b/app/api/verify-subscription/route.ts new file mode 100644 index 0000000..ef4d1e6 --- /dev/null +++ b/app/api/verify-subscription/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEmailFromCheckoutSession } from "@/lib/subscriber"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("session_id")?.trim(); + if (!sessionId) { + return NextResponse.json( + { error: "missing_session_id" }, + { status: 400 } + ); + } + + const email = await getEmailFromCheckoutSession(sessionId); + if (!email) { + return NextResponse.json({ error: "still_processing" }, { status: 404 }); + } + + return NextResponse.json({ email }); +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000..48d8abc --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; +import { + AUTH_SKIP, + getSupabaseAuthClient, + isSupabaseAuthConfigured, +} from "@/lib/supabase-auth"; + +function AuthCallbackInner() { + const searchParams = useSearchParams(); + const [message, setMessage] = useState("Signing you in…"); + + useEffect(() => { + async function run() { + if (AUTH_SKIP || !isSupabaseAuthConfigured()) { + setMessage("Auth is disabled in this environment."); + return; + } + + const supabase = getSupabaseAuthClient(); + if (!supabase) { + setMessage("Auth client unavailable."); + return; + } + + const errorParam = searchParams.get("error"); + const errorDesc = searchParams.get("error_description"); + if (errorParam) { + setMessage(errorDesc?.trim() || "Sign in was cancelled."); + return; + } + + const code = searchParams.get("code"); + if (!code) { + setMessage("Missing authorization code."); + return; + } + + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (error) { + setMessage(error.message); + return; + } + + setMessage("Success — you can close this window."); + window.setTimeout(() => { + try { + window.close(); + } catch { + /* ignore */ + } + }, 300); + } + + void run(); + }, [searchParams]); + + return ( + + + {message} + + + If this tab doesn't close automatically, return to GitReverse — + you're signed in. + + + ); +} + +export default function AuthCallbackPage() { + return ( + + Loading… + + } + > + + + ); +} diff --git a/app/globals.css b/app/globals.css index e4333ed..a8fa009 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,12 +9,12 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono); } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index 7a46845..cf118b1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; +import { AuthProvider } from "@/contexts/AuthContext"; import "./globals.css"; const geistSans = Geist({ @@ -19,6 +20,65 @@ export const metadata: Metadata = { "Turn a GitHub repository into a plain-language coding agent prompt.", }; +const checkoutNavigationRestoreScript = ` +(function () { + var key = "gr_checkout_navigation_state"; + var reloading = false; + + function getState() { + try { + return sessionStorage.getItem(key); + } catch (_) { + return null; + } + } + + function setState(value) { + try { + sessionStorage.setItem(key, value); + } catch (_) {} + } + + function clearState() { + try { + sessionStorage.removeItem(key); + } catch (_) {} + } + + function maybeReload() { + if (reloading) return; + var state = getState(); + if (state !== "started" && state !== "left") return; + if (new URLSearchParams(window.location.search).get("session_id")) { + clearState(); + return; + } + reloading = true; + setState("returned"); + window.location.reload(); + } + + function markLeft() { + if (getState() === "started") { + setState("left"); + } + } + + window.addEventListener("pagehide", markLeft); + window.addEventListener("pageshow", maybeReload); + window.addEventListener("focus", maybeReload); + window.addEventListener("popstate", maybeReload); + document.addEventListener("visibilitychange", function () { + if (document.visibilityState === "hidden") { + markLeft(); + } else { + maybeReload(); + } + }); + maybeReload(); +})(); +`; + export default function RootLayout({ children, }: Readonly<{ @@ -30,7 +90,10 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full bg-[#fffdf8] antialiased`} > - {children} + + {children}
+ {message} +
+ If this tab doesn't close automatically, return to GitReverse — + you're signed in. +
Loading…