diff --git a/.env.example b/.env.example index 28c61a2..9399573 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,32 @@ # Database DATABASE_URL=postgresql://roastfolio:roastfolio@localhost:5432/roastfolio -# Cloudflare R2 +# Cloudflare R2 (required for screenshot capture/upload) R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com -R2_ACCESS_KEY_ID=xxx -R2_SECRET_ACCESS_KEY=xxx +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= R2_BUCKET_NAME=roastfolio-screenshots R2_PUBLIC_URL=https://screenshots.roastfol.io -# Auth +# Admin panel (the admin cookie is signed with ADMIN_SESSION_SECRET, not +# ADMIN_PASSWORD — generate a fresh random secret per environment): +# openssl rand -hex 32 ADMIN_USERNAME=admin -ADMIN_PASSWORD=changeme -BETTER_AUTH_SECRET=generate-a-random-secret-here +ADMIN_PASSWORD=change-me-in-prod +ADMIN_SESSION_SECRET=generate-a-random-32-byte-secret + +# better-auth (user accounts) +BETTER_AUTH_SECRET=generate-a-random-32-byte-secret BETTER_AUTH_URL=http://localhost:3000 -# Payments -LEMONSQUEEZY_API_KEY=xxx -LEMONSQUEEZY_WEBHOOK_SECRET=xxx -LEMONSQUEEZY_STORE_ID=xxx +# Payments (Lemon Squeezy) +LEMONSQUEEZY_API_KEY= +LEMONSQUEEZY_WEBHOOK_SECRET= +LEMONSQUEEZY_STORE_ID= +LEMONSQUEEZY_CHECKOUT_URL= -# Email -POSTMARK_API_KEY=xxx +# Email (Postmark) +POSTMARK_API_KEY= POSTMARK_FROM_EMAIL=noreply@roastfol.io # App diff --git a/README.md b/README.md index cebf048..87e6252 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Submit any website and get a brutally honest AI critique of its design, copy, an ## Features - AI-generated roast posts with scores, sections, pros/cons -- Gallery scrapers (SaaS Landing Page, Landingfolio, Lapa Ninja) +- Gallery scrapers (SaaS Landing Page, Godly Website, Lapa Ninja) - Bulk URL import - Admin dashboard with queue management - Dynamic OG images per roast - RSS feed (`/api/feed`) and sitemap (`/api/sitemap`) - JSON-LD structured data - View tracking -- User auth (better-auth) and separate admin auth +- User auth (better-auth) + separate HMAC-signed admin session - Payments via Lemon Squeezy - Transactional email via Postmark + React Email - Screenshot storage on Cloudflare R2 @@ -25,7 +25,7 @@ Submit any website and get a brutally honest AI critique of its design, copy, an | Framework | Next.js 16 (App Router) | | Database | PostgreSQL 18 | | ORM | Drizzle | -| Auth | better-auth | +| Auth | better-auth + custom admin cookie | | Storage | Cloudflare R2 | | AI | Claude CLI | | Screenshots | Playwright | @@ -39,35 +39,66 @@ Submit any website and get a brutally honest AI critique of its design, copy, an ```bash docker compose up db -d npm install -cp .env.example .env # fill in your values +cp .env.example .env # fill in all required values npm run db:push npm run dev ``` App runs at http://localhost:3000. +Generate secure secrets once per environment: + +```bash +openssl rand -hex 32 # ADMIN_SESSION_SECRET, BETTER_AUTH_SECRET +``` + ## Environment Variables -| Variable | Required | +All env vars are validated at startup by `src/lib/env.ts`. Missing or malformed +required values throw immediately so misconfiguration can't be papered over. + +| Variable | Required | Notes | +|---|---|---| +| `DATABASE_URL` | yes | Postgres connection string | +| `ADMIN_USERNAME` | yes | Admin panel user | +| `ADMIN_PASSWORD` | yes | Admin panel password (min 8 chars) | +| `ADMIN_SESSION_SECRET` | yes | Signs the admin cookie (min 32 chars) | +| `BETTER_AUTH_SECRET` | yes | Signs user sessions (min 32 chars) | +| `BETTER_AUTH_URL` | yes | Canonical app URL for better-auth | +| `NEXT_PUBLIC_APP_URL` | yes | Canonical app URL for metadata/feeds | +| `NODE_ENV` | no | Defaults to `development` | +| `R2_ENDPOINT` | screenshots | Cloudflare R2 endpoint | +| `R2_ACCESS_KEY_ID` | screenshots | | +| `R2_SECRET_ACCESS_KEY` | screenshots | | +| `R2_BUCKET_NAME` | screenshots | | +| `R2_PUBLIC_URL` | screenshots | Public base URL for uploaded images | +| `POSTMARK_API_KEY` | email | | +| `POSTMARK_FROM_EMAIL` | email | | +| `LEMONSQUEEZY_API_KEY` | payments | | +| `LEMONSQUEEZY_WEBHOOK_SECRET` | payments | | +| `LEMONSQUEEZY_STORE_ID` | payments | | +| `LEMONSQUEEZY_CHECKOUT_URL` | payments | Hosted checkout URL wired to the pricing CTA | + +## Scripts + +| Command | What it does | |---|---| -| `DATABASE_URL` | yes | -| `ADMIN_USERNAME` | yes | -| `ADMIN_PASSWORD` | yes | -| `ADMIN_SESSION_SECRET` | yes (min 32 chars) | -| `BETTER_AUTH_SECRET` | yes (min 32 chars) | -| `BETTER_AUTH_URL` | yes | -| `NEXT_PUBLIC_APP_URL` | yes | -| `NODE_ENV` | no | -| `R2_ENDPOINT` | for screenshots | -| `R2_ACCESS_KEY_ID` | for screenshots | -| `R2_SECRET_ACCESS_KEY` | for screenshots | -| `R2_BUCKET_NAME` | for screenshots | -| `R2_PUBLIC_URL` | for screenshots | -| `POSTMARK_API_KEY` | for email | -| `POSTMARK_FROM_EMAIL` | for email | -| `LEMONSQUEEZY_API_KEY` | for payments | -| `LEMONSQUEEZY_WEBHOOK_SECRET` | for payments | -| `LEMONSQUEEZY_STORE_ID` | for payments | +| `npm run dev` | Next.js dev server | +| `npm run build` | Production build (`output: "standalone"`) | +| `npm run start` | Start the built app | +| `npm run lint` | ESLint | +| `npm run test` | Vitest (single run) | +| `npm run test:watch` | Vitest watch mode | +| `npm run db:push` | Drizzle Kit: push schema to database | +| `npm run db:generate` | Drizzle Kit: generate SQL migrations | +| `npm run db:studio` | Drizzle Studio | + +Data pipeline: + +```bash +npx tsx scripts/seed-gallery.ts saaslandingpage.com # scrape a gallery +npx tsx scripts/process-queue.ts # process pending items +``` ## Seeding Content @@ -85,6 +116,21 @@ You can also paste URLs (one per line) at `/admin/new`. Manage everything from `/admin/queue`. +## Architecture Notes + +- **Edge middleware**: `src/middleware.ts` protects `/admin/**`. It requires a + valid HMAC-signed `admin-session` cookie (signed with `ADMIN_SESSION_SECRET`). + Server actions and admin API routes independently re-check via + `requireAdmin()` as defense-in-depth. +- **Env validation**: `src/lib/env.ts` zod-parses `process.env` at first + access. Missing required secrets fail loudly. During `next build` only, a + relaxed fallback is used so prerendering doesn't require prod secrets. +- **Data access**: All DB queries go through `src/db/queries/*`. The drizzle + client in `src/db/index.ts` is lazy so importing it during build doesn't + connect. +- **Rate limiting**: In-memory per-IP via `src/lib/rate-limit.ts`. Sufficient + for single-node Coolify deployments; swap for Redis when horizontally scaled. + ## Deploying (Hetzner + Coolify) 1. Spin up a Hetzner server (CPX21+, Ubuntu 22.04) @@ -98,45 +144,51 @@ Manage everything from `/admin/queue`. ``` src/ - actions/ server actions (generate, posts, queue, seed) + actions/ server actions (generate, posts, queue, seed) app/ - admin/ dashboard pages (analytics, health, queue, posts, seed) - api/ auth, feed, sitemap, webhooks - category/ category pages - roast/ individual roast pages - login/ login - register/ register - pricing/ pricing + admin/ dashboard pages (analytics, health, queue, posts, seed) + api/ auth, feed, sitemap, webhooks + category/ category pages + roast/ individual roast pages + login/ login + register/ register + pricing/ pricing + error.tsx route error boundary + loading.tsx route loading state components/ - admin/ admin UI - roast/ roast display - shared/ header, footer, etc. + admin/ admin UI (buttons, forms, nav) + roast/ roast display + shared/ header, footer, sort, json-ld db/ - schema.ts drizzle schema - queries/ query helpers + schema.ts drizzle schema (posts/sections/screenshots/queue + better-auth) + queries/ query helpers lib/ - auth.ts admin auth - better-auth.ts - env.ts zod env validation - lemonsqueezy.ts - postmark.ts - r2.ts R2 uploads/deletes - score-utils.ts - scrapers/ gallery scrapers -emails/ react email templates -tests/ vitest tests + admin-session.ts HMAC admin cookie helpers + auth.ts requireAdmin / validators + better-auth.ts better-auth config + env.ts zod env validation + lemonsqueezy.ts webhook signature verify + postmark.ts welcome / password-reset email + r2.ts R2 upload/delete + rate-limit.ts in-memory sliding-window limiter + score-utils.ts score → colour/severity mapping + utils.ts cn / slugify / string utils + scrapers/ gallery scrapers + middleware.ts admin route gate +emails/ react email templates +tests/ vitest tests ``` ## API Routes -| Route | Method | -|---|---| -| `/api/auth/[...all]` | ALL | -| `/api/auth/admin-login` | POST | -| `/api/auth/register` | POST | -| `/api/feed` | GET | -| `/api/sitemap` | GET | -| `/api/webhooks/lemonsqueezy` | POST | +| Route | Method | Auth | +|---|---|---| +| `/api/auth/[...all]` | ALL | better-auth handler | +| `/api/auth/admin-login` | POST | rate-limited | +| `/api/auth/register` | POST | rate-limited | +| `/api/feed` | GET | public | +| `/api/sitemap` | GET | public | +| `/api/webhooks/lemonsqueezy` | POST | HMAC signature | ## License diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..bd566d5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,9 +5,20 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, - // Override default ignores of eslint-config-next. + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + }, + }, globalIgnores([ - // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", diff --git a/scripts/generate-roast.ts b/scripts/generate-roast.ts index b3c0a4a..ba3858a 100644 --- a/scripts/generate-roast.ts +++ b/scripts/generate-roast.ts @@ -151,7 +151,7 @@ async function main() { uploadedR2Keys.push(key); uploaded.push({ r2Url: url, r2Key: key, viewport: ss.viewport, width: ss.viewport === "desktop" ? 1440 : 375, height: ss.viewport === "desktop" ? 900 : 812 }); - } catch (e) { console.warn(`R2 upload failed for ${key}`); } + } catch (err) { console.warn(`R2 upload failed for ${key}:`, err instanceof Error ? err.message : err); } } } diff --git a/src/actions/generate.ts b/src/actions/generate.ts index 5a123ca..a2cf0c2 100644 --- a/src/actions/generate.ts +++ b/src/actions/generate.ts @@ -7,15 +7,21 @@ import { stripProtocol } from "@/lib/utils"; import { eq } from "drizzle-orm"; import { requireAdmin, isValidDomain } from "@/lib/auth"; -export async function triggerGeneration(formData: FormData) { +type Result = + | { success: true; queueId: string } + | { success: false; error: string }; + +export async function triggerGeneration(formData: FormData): Promise { try { await requireAdmin(); } catch { return { success: false, error: "Unauthorized" }; } - const rawUrl = formData.get("siteUrl") as string; - if (!rawUrl) return { success: false, error: "URL is required" }; + const rawUrl = formData.get("siteUrl"); + if (typeof rawUrl !== "string" || !rawUrl.trim()) { + return { success: false, error: "URL is required" }; + } const siteUrl = stripProtocol(rawUrl.trim()); if (!isValidDomain(siteUrl)) { @@ -23,13 +29,11 @@ export async function triggerGeneration(formData: FormData) { } try { - // Check duplicate in both queue and posts const existingInQueue = await db .select({ id: queue.id }) .from(queue) .where(eq(queue.siteUrl, siteUrl)) .limit(1); - if (existingInQueue.length > 0) { return { success: false, error: "Already in queue" }; } @@ -39,21 +43,25 @@ export async function triggerGeneration(formData: FormData) { .from(posts) .where(eq(posts.siteUrl, siteUrl)) .limit(1); - if (existingPost.length > 0) { return { success: false, error: "Already roasted" }; } - const result = await db + const [inserted] = await db .insert(queue) .values({ siteUrl, source: "manual", priority: 10 }) - .returning(); + .returning({ id: queue.id }); + + if (!inserted) { + return { success: false, error: "Failed to add to queue" }; + } revalidatePath("/admin/queue"); revalidatePath("/admin/new"); revalidatePath("/admin"); - return { success: true, queueId: result[0]?.id }; + return { success: true, queueId: inserted.id }; } catch (error) { + console.error("triggerGeneration failed:", error); return { success: false, error: "Failed to add to queue" }; } } diff --git a/src/actions/posts.ts b/src/actions/posts.ts index 9310488..b4f6370 100644 --- a/src/actions/posts.ts +++ b/src/actions/posts.ts @@ -6,126 +6,147 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { deleteFromR2 } from "@/lib/r2"; import { requireAdmin, isValidUUID } from "@/lib/auth"; -import { SEVERITIES, SITE_TYPES } from "@/lib/constants"; +import { SEVERITIES, SITE_TYPES, type Severity, type SiteType } from "@/lib/constants"; -export async function publishPost(postId: string) { +type Result = { success: true } | { success: false; error: string }; + +function isSeverity(value: string): value is Severity { + return (SEVERITIES as readonly string[]).includes(value); +} +function isSiteType(value: string): value is SiteType { + return (SITE_TYPES as readonly string[]).includes(value); +} + +async function guard(): Promise { try { await requireAdmin(); - if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; + return null; + } catch { + return { success: false, error: "Unauthorized" }; + } +} + +export async function publishPost(postId: string): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; + + try { await db .update(posts) - .set({ status: "published", publishedAt: new Date(), updatedAt: new Date() }) + .set({ + status: "published", + publishedAt: new Date(), + updatedAt: new Date(), + }) .where(eq(posts.id, postId)); revalidatePath("/"); revalidatePath("/admin"); + revalidatePath("/admin/published"); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("publishPost failed:", error); return { success: false, error: "Failed to publish post" }; } } -export async function unpublishPost(postId: string) { +export async function unpublishPost(postId: string): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; + try { - await requireAdmin(); - if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; await db .update(posts) .set({ status: "draft", updatedAt: new Date() }) .where(eq(posts.id, postId)); revalidatePath("/"); revalidatePath("/admin"); + revalidatePath("/admin/published"); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("unpublishPost failed:", error); return { success: false, error: "Failed to unpublish post" }; } } -export async function deletePost(postId: string) { - try { - await requireAdmin(); - if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; +export async function deletePost(postId: string): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; - // Get screenshots to delete from R2 + try { const postScreenshots = await db .select({ r2Key: screenshots.r2Key }) .from(screenshots) .where(eq(screenshots.postId, postId)); - // Delete from R2 await Promise.allSettled( postScreenshots.map((s) => deleteFromR2(s.r2Key)) ); - // Cascade delete handles sections and screenshots in DB await db.delete(posts).where(eq(posts.id, postId)); revalidatePath("/"); revalidatePath("/admin"); + revalidatePath("/admin/published"); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("deletePost failed:", error); return { success: false, error: "Failed to delete post" }; } } +interface UpdatePostInput { + siteName?: string; + score?: number; + severity?: string; + siteType?: string; + overallSummary?: string; + pros?: string[]; + cons?: string[]; + suggestions?: string[]; +} + export async function updatePost( postId: string, - data: { - siteName?: string; - score?: number; - severity?: string; - siteType?: string; - overallSummary?: string; - pros?: string[]; - cons?: string[]; - suggestions?: string[]; + data: UpdatePostInput +): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; + + if (data.score !== undefined && (data.score < 0 || data.score > 100)) { + return { success: false, error: "Score must be between 0 and 100" }; + } + if (data.severity !== undefined && !isSeverity(data.severity)) { + return { success: false, error: "Invalid severity value" }; + } + if (data.siteType !== undefined && !isSiteType(data.siteType)) { + return { success: false, error: "Invalid site type" }; } -) { - try { - await requireAdmin(); - if (!isValidUUID(postId)) return { success: false, error: "Invalid post ID" }; - - // Validate fields if provided - if (data.score !== undefined && (data.score < 0 || data.score > 100)) { - return { success: false, error: "Score must be between 0 and 100" }; - } - if (data.severity !== undefined && !SEVERITIES.includes(data.severity as typeof SEVERITIES[number])) { - return { success: false, error: "Invalid severity value" }; - } - if (data.siteType !== undefined && !SITE_TYPES.includes(data.siteType as typeof SITE_TYPES[number])) { - return { success: false, error: "Invalid site type" }; - } - - // Build typed update object with explicit field mapping - const updateFields: Partial = { updatedAt: new Date() }; - if (data.siteName !== undefined) updateFields.siteName = data.siteName; - if (data.score !== undefined) updateFields.score = data.score; - if (data.severity !== undefined) updateFields.severity = data.severity; - if (data.siteType !== undefined) updateFields.siteType = data.siteType; - if (data.overallSummary !== undefined) updateFields.overallSummary = data.overallSummary; - if (data.pros !== undefined) updateFields.pros = data.pros; - if (data.cons !== undefined) updateFields.cons = data.cons; - if (data.suggestions !== undefined) updateFields.suggestions = data.suggestions; - await db - .update(posts) - .set(updateFields) - .where(eq(posts.id, postId)); + const updateFields: Partial = { + updatedAt: new Date(), + }; + if (data.siteName !== undefined) updateFields.siteName = data.siteName; + if (data.score !== undefined) updateFields.score = data.score; + if (data.severity !== undefined) updateFields.severity = data.severity; + if (data.siteType !== undefined) updateFields.siteType = data.siteType; + if (data.overallSummary !== undefined) + updateFields.overallSummary = data.overallSummary; + if (data.pros !== undefined) updateFields.pros = data.pros; + if (data.cons !== undefined) updateFields.cons = data.cons; + if (data.suggestions !== undefined) updateFields.suggestions = data.suggestions; + + try { + await db.update(posts).set(updateFields).where(eq(posts.id, postId)); revalidatePath("/"); revalidatePath("/admin"); + revalidatePath("/admin/published"); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("updatePost failed:", error); return { success: false, error: "Failed to update post" }; } } @@ -133,23 +154,28 @@ export async function updatePost( export async function updateSection( sectionId: string, data: { content?: string; score?: number; name?: string } -) { +): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(sectionId)) + return { success: false, error: "Invalid section ID" }; + if (data.score !== undefined && (data.score < 0 || data.score > 100)) { + return { success: false, error: "Score must be between 0 and 100" }; + } + + const sectionUpdate: Partial = {}; + if (data.content !== undefined) sectionUpdate.content = data.content; + if (data.score !== undefined) sectionUpdate.score = data.score; + if (data.name !== undefined) sectionUpdate.name = data.name; + try { - await requireAdmin(); - if (!isValidUUID(sectionId)) return { success: false, error: "Invalid section ID" }; - if (data.score !== undefined && (data.score < 0 || data.score > 100)) { - return { success: false, error: "Score must be between 0 and 100" }; - } - const sectionUpdate: Partial = {}; - if (data.content !== undefined) sectionUpdate.content = data.content; - if (data.score !== undefined) sectionUpdate.score = data.score; - if (data.name !== undefined) sectionUpdate.name = data.name; - await db.update(sections).set(sectionUpdate).where(eq(sections.id, sectionId)); + await db + .update(sections) + .set(sectionUpdate) + .where(eq(sections.id, sectionId)); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("updateSection failed:", error); return { success: false, error: "Failed to update section" }; } } diff --git a/src/actions/queue.ts b/src/actions/queue.ts index 0283f99..c40d522 100644 --- a/src/actions/queue.ts +++ b/src/actions/queue.ts @@ -2,138 +2,159 @@ import { db } from "@/db"; import { queue, posts } from "@/db/schema"; -import { eq, sql, and, inArray } from "drizzle-orm"; +import { eq, sql, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { stripProtocol } from "@/lib/utils"; import { requireAdmin, isValidUUID, isValidDomain } from "@/lib/auth"; -export async function addToQueue(formData: FormData) { +type Result = + | { success: true; queued?: number; duplicates?: number; total?: number } + | { success: false; error: string }; + +const MAX_RETRIES = 5; +const MAX_BULK_URLS = 1000; + +async function guard(): Promise { try { await requireAdmin(); + return null; } catch { return { success: false, error: "Unauthorized" }; } +} - const siteUrl = formData.get("siteUrl") as string; - if (!siteUrl) return { success: false, error: "URL is required" }; +export async function addToQueue(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const raw = formData.get("siteUrl"); + if (typeof raw !== "string" || !raw.trim()) { + return { success: false, error: "URL is required" }; + } - const cleanUrl = stripProtocol(siteUrl.trim()); + const cleanUrl = stripProtocol(raw.trim()); if (!isValidDomain(cleanUrl)) { return { success: false, error: "Invalid domain" }; } try { - // Check for duplicate - const existing = await db + const [existingQueue] = await db .select({ id: queue.id }) .from(queue) .where(eq(queue.siteUrl, cleanUrl)) .limit(1); - - const existingPost = await db + const [existingPost] = await db .select({ id: posts.id }) .from(posts) .where(eq(posts.siteUrl, cleanUrl)) .limit(1); - - if (existing.length > 0 || existingPost.length > 0) { - return { success: false, error: "URL already exists in queue or posts" }; + if (existingQueue || existingPost) { + return { + success: false, + error: "URL already exists in queue or posts", + }; } - await db.insert(queue).values({ - siteUrl: cleanUrl, - source: "manual", - }); - + await db.insert(queue).values({ siteUrl: cleanUrl, source: "manual" }); revalidatePath("/admin/queue"); revalidatePath("/admin"); return { success: true }; } catch (error) { + console.error("addToQueue failed:", error); return { success: false, error: "Failed to add to queue" }; } } -export async function addBulkToQueue(formData: FormData) { - try { - await requireAdmin(); - } catch { - return { success: false, error: "Unauthorized" }; - } +export async function addBulkToQueue(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; - const urlsText = formData.get("urls") as string; - if (!urlsText) return { success: false, error: "URLs required" }; + const raw = formData.get("urls"); + if (typeof raw !== "string" || !raw.trim()) { + return { success: false, error: "URLs required" }; + } - const urls = urlsText + const urls = raw .split("\n") .map((u) => stripProtocol(u.trim())) - .filter((u) => u.length > 0 && isValidDomain(u)); - - // Batch dedup check instead of N+1 queries - const existingInQueue = urls.length > 0 - ? await db.select({ siteUrl: queue.siteUrl }).from(queue).where(inArray(queue.siteUrl, urls)) - : []; - const existingInPosts = urls.length > 0 - ? await db.select({ siteUrl: posts.siteUrl }).from(posts).where(inArray(posts.siteUrl, urls)) - : []; - const existingUrls = new Set([ - ...existingInQueue.map(r => r.siteUrl), - ...existingInPosts.map(r => r.siteUrl), - ]); - - let queued = 0; - let duplicates = 0; - - for (const url of urls) { - if (existingUrls.has(url)) { - duplicates++; - continue; - } - try { - await db.insert(queue).values({ - siteUrl: url, - source: "bulk", - }); - queued++; - existingUrls.add(url); // prevent dupes within same batch - } catch { - // Skip individual failures - } + .filter((u) => u.length > 0 && isValidDomain(u)) + .slice(0, MAX_BULK_URLS); + + if (urls.length === 0) { + return { success: false, error: "No valid URLs provided" }; } - revalidatePath("/admin/queue"); - revalidatePath("/admin"); - return { success: true, queued, duplicates, total: urls.length }; + try { + const existingInQueue = await db + .select({ siteUrl: queue.siteUrl }) + .from(queue) + .where(inArray(queue.siteUrl, urls)); + const existingInPosts = await db + .select({ siteUrl: posts.siteUrl }) + .from(posts) + .where(inArray(posts.siteUrl, urls)); + const existingUrls = new Set([ + ...existingInQueue.map((r) => r.siteUrl), + ...existingInPosts.map((r) => r.siteUrl), + ]); + + let queued = 0; + let duplicates = 0; + + for (const url of urls) { + if (existingUrls.has(url)) { + duplicates++; + continue; + } + try { + await db.insert(queue).values({ siteUrl: url, source: "bulk" }); + queued++; + existingUrls.add(url); + } catch (err) { + console.warn("addBulkToQueue insert failed:", url, err); + duplicates++; + } + } + + revalidatePath("/admin/queue"); + revalidatePath("/admin"); + return { success: true, queued, duplicates, total: urls.length }; + } catch (error) { + console.error("addBulkToQueue failed:", error); + return { success: false, error: "Failed to enqueue URLs" }; + } } -export async function deleteQueueItem(id: string) { +export async function deleteQueueItem(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(id)) return { success: false, error: "Invalid ID" }; + try { - await requireAdmin(); - if (!isValidUUID(id)) return { success: false, error: "Invalid ID" }; await db.delete(queue).where(eq(queue.id, id)); revalidatePath("/admin/queue"); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("deleteQueueItem failed:", error); return { success: false, error: "Failed to delete queue item" }; } } -export async function retryQueueItem(id: string) { - try { - await requireAdmin(); - if (!isValidUUID(id)) return { success: false, error: "Invalid ID" }; +export async function retryQueueItem(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + if (!isValidUUID(id)) return { success: false, error: "Invalid ID" }; - // Check current retry count before allowing retry + try { const [item] = await db .select({ retryCount: queue.retryCount }) .from(queue) .where(eq(queue.id, id)) .limit(1); - if (!item) return { success: false, error: "Queue item not found" }; - if (item.retryCount >= 5) return { success: false, error: "Max retries reached" }; + if (item.retryCount >= MAX_RETRIES) { + return { success: false, error: "Max retries reached" }; + } await db .update(queue) @@ -147,9 +168,7 @@ export async function retryQueueItem(id: string) { revalidatePath("/admin/queue"); return { success: true }; } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return { success: false, error: "Unauthorized" }; - } + console.error("retryQueueItem failed:", error); return { success: false, error: "Failed to retry queue item" }; } } diff --git a/src/actions/seed.ts b/src/actions/seed.ts index 4188cfe..8989779 100644 --- a/src/actions/seed.ts +++ b/src/actions/seed.ts @@ -5,7 +5,11 @@ import { queue, posts } from "@/db/schema"; import { inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireAdmin, isValidDomain } from "@/lib/auth"; -import { getScraper, getAvailableSources, type ScrapedSite } from "@/lib/scrapers"; +import { + getScraper, + getAllowedScrapedSources, + type ScrapedSite, +} from "@/lib/scrapers"; export async function importFromSource(source: string) { try { @@ -28,7 +32,10 @@ export async function importFromSource(source: string) { errors: result.errors, }; } catch (e) { - return { success: false as const, error: e instanceof Error ? e.message : "Scraping failed" }; + return { + success: false as const, + error: e instanceof Error ? e.message : "Scraping failed", + }; } } @@ -39,42 +46,47 @@ export async function queueScrapedSites(sites: ScrapedSite[]) { return { success: false as const, error: "Unauthorized" }; } - const urls = sites.map(s => s.url); - - // Batch dedup check - const existingInQueue = urls.length > 0 - ? await db.select({ siteUrl: queue.siteUrl }).from(queue).where(inArray(queue.siteUrl, urls)) - : []; - const existingInPosts = urls.length > 0 - ? await db.select({ siteUrl: posts.siteUrl }).from(posts).where(inArray(posts.siteUrl, urls)) - : []; + const allowedSources = getAllowedScrapedSources(); + const urls = sites.map((s) => s.url); + const existingInQueue = + urls.length > 0 + ? await db + .select({ siteUrl: queue.siteUrl }) + .from(queue) + .where(inArray(queue.siteUrl, urls)) + : []; + const existingInPosts = + urls.length > 0 + ? await db + .select({ siteUrl: posts.siteUrl }) + .from(posts) + .where(inArray(posts.siteUrl, urls)) + : []; const existingUrls = new Set([ - ...existingInQueue.map(r => r.siteUrl), - ...existingInPosts.map(r => r.siteUrl), + ...existingInQueue.map((r) => r.siteUrl), + ...existingInPosts.map((r) => r.siteUrl), ]); let queued = 0; let duplicates = 0; - - // Validate and sanitize the known source values - const validSources = new Set(getAvailableSources().map(s => `seed-${s}`)); + let rejected = 0; for (const site of sites) { if (existingUrls.has(site.url)) { duplicates++; continue; } - // Validate URL to prevent SSRF if (!isValidDomain(site.url)) { - duplicates++; + rejected++; continue; } - // Sanitize client-supplied fields const safeName = site.name ? site.name.slice(0, 200) : null; const safeCategory = site.category ? site.category.slice(0, 50) : null; - const safeSource = validSources.has(site.source) ? site.source : "seed-unknown"; + const safeSource = allowedSources.has(site.source) + ? site.source + : "seed-unknown"; try { await db.insert(queue).values({ @@ -84,13 +96,20 @@ export async function queueScrapedSites(sites: ScrapedSite[]) { source: safeSource, }); queued++; - existingUrls.add(site.url); // prevent dupes within same batch + existingUrls.add(site.url); } catch { - // Skip individual insert failures (e.g., race condition duplicates) + // Race against a concurrent insert on the same URL; treat as duplicate. + duplicates++; } } revalidatePath("/admin/queue"); revalidatePath("/admin"); - return { success: true as const, queued, duplicates, total: sites.length }; + return { + success: true as const, + queued, + duplicates, + rejected, + total: sites.length, + }; } diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 56ae9f5..c2de898 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -5,19 +5,11 @@ import { ScoreBadge } from "@/components/roast/score-badge"; export const dynamic = "force-dynamic"; export default async function AnalyticsPage() { - let pipeline = { total: 0, published: 0, queued: 0, failed: 0, successRate: 0 }; - let avgScore = 0; - let topPosts: Awaited> = []; - - try { - [pipeline, avgScore, topPosts] = await Promise.all([ - getPipelineStats(), - getAverageScore(), - getTopPostsByViews(20), - ]); - } catch { - // DB not connected - } + const [pipeline, avgScore, topPosts] = await Promise.all([ + getPipelineStats(), + getAverageScore(), + getTopPostsByViews(20), + ]); return (
@@ -29,7 +21,10 @@ export default async function AnalyticsPage() { { label: "Avg Score", value: avgScore || "—" }, { label: "Success Rate", value: `${pipeline.successRate}%` }, ].map((stat) => ( -
+

{stat.label}

{stat.value}

@@ -47,13 +42,20 @@ export default async function AnalyticsPage() { ) : (
{topPosts.map((post, i) => ( -
+
- #{i + 1} + + #{i + 1} +
{post.siteName}
-
{post.siteUrl}
+
+ {post.siteUrl} +
{post.viewCount} views
diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx new file mode 100644 index 0000000..441ff7c --- /dev/null +++ b/src/app/admin/error.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect } from "react"; + +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Admin route error:", error); + }, [error]); + + return ( +
+
+

+ Admin page failed to load +

+

+ {error.message || "Unexpected error."} + {error.digest ? ` (digest: ${error.digest})` : null} +

+ +
+
+ ); +} diff --git a/src/app/admin/health/page.tsx b/src/app/admin/health/page.tsx index db039d9..cfd2178 100644 --- a/src/app/admin/health/page.tsx +++ b/src/app/admin/health/page.tsx @@ -1,44 +1,167 @@ import { db } from "@/db"; import { sql } from "drizzle-orm"; +import { env } from "@/lib/env"; +import { + HeadBucketCommand, + S3Client, +} from "@aws-sdk/client-s3"; export const dynamic = "force-dynamic"; +export const revalidate = 0; -async function checkDb(): Promise { +type CheckStatus = "ok" | "not-configured" | "failed"; + +interface Check { + name: string; + status: CheckStatus; + detail?: string; +} + +async function withTimeout( + promise: Promise, + ms: number, + label: string +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) + ), + ]); +} + +async function checkDb(): Promise { try { - await db.execute(sql`SELECT 1`); - return true; - } catch { - return false; + await withTimeout(db.execute(sql`SELECT 1`), 3000, "PostgreSQL"); + return { name: "PostgreSQL", status: "ok" }; + } catch (err) { + return { + name: "PostgreSQL", + status: "failed", + detail: err instanceof Error ? err.message : "query failed", + }; } } -function checkR2(): boolean { - const endpoint = process.env.R2_ENDPOINT; - return !!endpoint && endpoint !== "xxx"; +async function checkR2(): Promise { + const { + R2_ENDPOINT, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + } = env(); + if (!R2_ENDPOINT || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return { name: "Cloudflare R2", status: "not-configured" }; + } + try { + const s3 = new S3Client({ + region: "auto", + endpoint: R2_ENDPOINT, + credentials: { + accessKeyId: R2_ACCESS_KEY_ID, + secretAccessKey: R2_SECRET_ACCESS_KEY, + }, + }); + await withTimeout( + s3.send(new HeadBucketCommand({ Bucket: R2_BUCKET_NAME })), + 3000, + "R2" + ); + return { name: "Cloudflare R2", status: "ok" }; + } catch (err) { + return { + name: "Cloudflare R2", + status: "failed", + detail: err instanceof Error ? err.message : "HEAD failed", + }; + } } -function checkPostmark(): boolean { - const key = process.env.POSTMARK_API_KEY; - return !!key && key !== "xxx"; +async function checkPostmark(): Promise { + const { POSTMARK_API_KEY } = env(); + if (!POSTMARK_API_KEY) { + return { name: "Postmark", status: "not-configured" }; + } + try { + const res = await withTimeout( + fetch("https://api.postmarkapp.com/server", { + headers: { + Accept: "application/json", + "X-Postmark-Server-Token": POSTMARK_API_KEY, + }, + }), + 3000, + "Postmark" + ); + if (!res.ok) { + return { + name: "Postmark", + status: "failed", + detail: `HTTP ${res.status}`, + }; + } + return { name: "Postmark", status: "ok" }; + } catch (err) { + return { + name: "Postmark", + status: "failed", + detail: err instanceof Error ? err.message : "request failed", + }; + } } -function checkLemonSqueezy(): boolean { - const key = process.env.LEMONSQUEEZY_API_KEY; - return !!key && key !== "xxx"; +async function checkLemonSqueezy(): Promise { + const { LEMONSQUEEZY_API_KEY } = env(); + if (!LEMONSQUEEZY_API_KEY) { + return { name: "Lemon Squeezy", status: "not-configured" }; + } + try { + const res = await withTimeout( + fetch("https://api.lemonsqueezy.com/v1/users/me", { + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${LEMONSQUEEZY_API_KEY}`, + }, + }), + 3000, + "Lemon Squeezy" + ); + if (!res.ok) { + return { + name: "Lemon Squeezy", + status: "failed", + detail: `HTTP ${res.status}`, + }; + } + return { name: "Lemon Squeezy", status: "ok" }; + } catch (err) { + return { + name: "Lemon Squeezy", + status: "failed", + detail: err instanceof Error ? err.message : "request failed", + }; + } } -export default async function HealthPage() { - const dbOk = await checkDb(); - const r2Ok = checkR2(); - const postmarkOk = checkPostmark(); - const lsOk = checkLemonSqueezy(); +const STATUS_LABEL: Record = { + ok: "Connected", + "not-configured": "Not configured", + failed: "Failed", +}; - const checks = [ - { name: "PostgreSQL", ok: dbOk }, - { name: "Cloudflare R2", ok: r2Ok }, - { name: "Postmark", ok: postmarkOk }, - { name: "Lemon Squeezy", ok: lsOk }, - ]; +const STATUS_CLASS: Record = { + ok: "text-success", + "not-configured": "text-muted-foreground", + failed: "text-destructive", +}; + +export default async function HealthPage() { + const checks = await Promise.all([ + checkDb(), + checkR2(), + checkPostmark(), + checkLemonSqueezy(), + ]); return (
@@ -49,10 +172,22 @@ export default async function HealthPage() {
{checks.map((check) => ( -
- {check.name} - - {check.ok ? "Connected" : "Not configured"} +
+
+
{check.name}
+ {check.detail && ( +
+ {check.detail} +
+ )} +
+ + {STATUS_LABEL[check.status]}
))} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b94a683..628a39a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6,21 +6,16 @@ import { ScoreBadge } from "@/components/roast/score-badge"; export const dynamic = "force-dynamic"; export default async function AdminDashboard() { - let stats = { publishedCount: 0, queueCount: 0, failedCount: 0, totalViews: 0 }; - let recentPosts: Awaited> = []; - - try { - stats = await getDashboardStats(); - recentPosts = await getPublishedPosts({ limit: 10 }); - } catch { - // DB not connected - } + const [stats, recentPosts] = await Promise.all([ + getDashboardStats(), + getPublishedPosts({ limit: 10 }), + ]); const statCards = [ - { label: "Published", value: stats.publishedCount, icon: "📰" }, - { label: "In Queue", value: stats.queueCount, icon: "📋" }, - { label: "Failed", value: stats.failedCount, icon: "❌" }, - { label: "Total Views", value: stats.totalViews, icon: "👁️" }, + { label: "Published", value: stats.publishedCount }, + { label: "In Queue", value: stats.queueCount }, + { label: "Failed", value: stats.failedCount }, + { label: "Total Views", value: stats.totalViews }, ]; return ( @@ -29,14 +24,12 @@ export default async function AdminDashboard() {
{statCards.map((stat) => ( -
-
-
-

{stat.label}

-

{stat.value}

-
- {stat.icon} -
+
+

{stat.label}

+

{stat.value}

))}
@@ -48,22 +41,31 @@ export default async function AdminDashboard() { {recentPosts.length === 0 ? (
No roasts yet. Go to{" "} - New Roast{" "} + + New Roast + {" "} to generate your first.
) : (
{recentPosts.map((post) => ( -
+
{post.siteName}
-
{post.siteUrl}
+
+ {post.siteUrl} +
- {post.status} + + {post.status} +
-

Edit: {post.siteName}

+

Edit: {postRow.siteName}

- {post.status === "published" ? ( - + {postRow.status === "published" ? ( + Preview → ) : ( - + Preview (draft) )} - {post.status === "draft" && ( -
{ "use server"; await publishPost(id); }}> + {postRow.status === "draft" && ( + { + "use server"; + await publishPost(id); + }} + >
)} -
{ "use server"; await deletePost(id); }}> + { + "use server"; + await deletePost(id); + }} + > @@ -59,16 +73,16 @@ export default async function EditPostPage({ ({ id: s.id, diff --git a/src/app/admin/published/page.tsx b/src/app/admin/published/page.tsx index 8686b6c..71a195a 100644 --- a/src/app/admin/published/page.tsx +++ b/src/app/admin/published/page.tsx @@ -2,19 +2,18 @@ import { db } from "@/db"; import { posts } from "@/db/schema"; import { desc } from "drizzle-orm"; import { ScoreBadge } from "@/components/roast/score-badge"; -import { PostDeleteButton, PostPublishButton, PostUnpublishButton } from "@/components/admin/post-actions"; +import { + PostDeleteButton, + PostPublishButton, + PostUnpublishButton, +} from "@/components/admin/post-actions"; import Link from "next/link"; import { formatDate } from "@/lib/utils"; export const dynamic = "force-dynamic"; export default async function PublishedPage() { - let allPosts: (typeof posts.$inferSelect)[] = []; - try { - allPosts = await db.select().from(posts).orderBy(desc(posts.createdAt)); - } catch { - // DB not connected - } + const allPosts = await db.select().from(posts).orderBy(desc(posts.createdAt)); return (
diff --git a/src/app/admin/queue/page.tsx b/src/app/admin/queue/page.tsx index f75a0c4..0d8b45e 100644 --- a/src/app/admin/queue/page.tsx +++ b/src/app/admin/queue/page.tsx @@ -1,15 +1,14 @@ import { getQueueItems } from "@/db/queries/queue"; -import { QueueDeleteButton, QueueRetryButton } from "@/components/admin/queue-actions"; +import { + QueueDeleteButton, + QueueRetryButton, +} from "@/components/admin/queue-actions"; +import Link from "next/link"; export const dynamic = "force-dynamic"; export default async function QueuePage() { - let items: Awaited> = []; - try { - items = await getQueueItems(); - } catch { - // DB not connected - } + const items = await getQueueItems(); return (
@@ -23,13 +22,13 @@ export default async function QueuePage() { {items.length === 0 ? (
Queue is empty. Add domains from{" "} - + New Roast - {" "} + {" "} or{" "} - + Seed - + .
) : ( diff --git a/src/app/api/auth/admin-login/route.ts b/src/app/api/auth/admin-login/route.ts index c73f03a..046b8f5 100644 --- a/src/app/api/auth/admin-login/route.ts +++ b/src/app/api/auth/admin-login/route.ts @@ -1,124 +1,77 @@ import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; import crypto from "crypto"; -import { createSession } from "@/proxy"; +import { z } from "zod"; +import { + ADMIN_COOKIE_NAME, + ADMIN_SESSION_MAX_AGE_SEC, + createAdminSession, +} from "@/lib/admin-session"; +import { env } from "@/lib/env"; +import { createRateLimiter, getClientIp } from "@/lib/rate-limit"; + +const loginLimiter = createRateLimiter({ + windowMs: 60_000, + max: 5, +}); -// In-memory rate limiting by IP — acceptable for standalone mode -const failedAttempts = new Map(); -const RATE_LIMIT_WINDOW_MS = 60_000; // 60 seconds -const MAX_FAILED_ATTEMPTS = 5; -const MAX_TRACKED_IPS = 10_000; // Cap map size to prevent memory DoS - -function isRateLimited(ip: string): boolean { - const record = failedAttempts.get(ip); - if (!record) return false; - // If window has expired, clear and allow - if (Date.now() - record.lastAttempt > RATE_LIMIT_WINDOW_MS) { - failedAttempts.delete(ip); - return false; - } - return record.count >= MAX_FAILED_ATTEMPTS; -} - -function recordFailedAttempt(ip: string): void { - // Prune expired entries if map is getting large - if (failedAttempts.size >= MAX_TRACKED_IPS) { - const now = Date.now(); - for (const [key, val] of failedAttempts) { - if (now - val.lastAttempt > RATE_LIMIT_WINDOW_MS) { - failedAttempts.delete(key); - } - } - // If still too large after pruning, drop oldest entries - if (failedAttempts.size >= MAX_TRACKED_IPS) { - const entries = [...failedAttempts.entries()].sort((a, b) => a[1].lastAttempt - b[1].lastAttempt); - for (let i = 0; i < entries.length / 2; i++) { - failedAttempts.delete(entries[i][0]); - } - } - } - - const record = failedAttempts.get(ip); - const now = Date.now(); - if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) { - failedAttempts.set(ip, { count: 1, lastAttempt: now }); - } else { - record.count++; - record.lastAttempt = now; - } -} - -/** Hash with SHA-256 to produce fixed-length output for timingSafeEqual */ function sha256(input: string): Buffer { return crypto.createHash("sha256").update(input).digest(); } +const credentialsSchema = z.object({ + username: z.string().min(1).max(256), + password: z.string().min(1).max(1024), +}); + export async function POST(request: NextRequest) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown"; + const ip = getClientIp(request.headers); - if (isRateLimited(ip)) { + if (loginLimiter.check(ip)) { return NextResponse.json( { error: "Too many failed attempts. Try again later." }, { status: 429 } ); } - let body: { username?: unknown; password?: unknown }; + let raw: unknown; try { - body = await request.json(); + raw = await request.json(); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } - const { username, password } = body; - if (typeof username !== "string" || typeof password !== "string") { + const parsed = credentialsSchema.safeParse(raw); + if (!parsed.success) { return NextResponse.json( { error: "Username and password are required" }, { status: 400 } ); } - const adminUsername = process.env.ADMIN_USERNAME; - const adminPassword = process.env.ADMIN_PASSWORD; - - if (!adminUsername || !adminPassword) { - return NextResponse.json( - { error: "Admin credentials not configured" }, - { status: 500 } - ); - } + const { username, password } = parsed.data; + const { ADMIN_USERNAME, ADMIN_PASSWORD, NODE_ENV } = env(); - // Timing-safe comparison: hash both values with SHA-256 first - // to produce fixed-length outputs, eliminating the length leak const usernameMatch = crypto.timingSafeEqual( sha256(username), - sha256(adminUsername) + sha256(ADMIN_USERNAME) ); const passwordMatch = crypto.timingSafeEqual( sha256(password), - sha256(adminPassword) + sha256(ADMIN_PASSWORD) ); if (!usernameMatch || !passwordMatch) { - recordFailedAttempt(ip); - return NextResponse.json( - { error: "Invalid credentials" }, - { status: 401 } - ); + loginLimiter.record(ip); + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); } - // Create HMAC-signed session cookie - const sessionValue = createSession(); - const cookieStore = await cookies(); - cookieStore.set("admin-session", sessionValue, { + cookieStore.set(ADMIN_COOKIE_NAME, await createAdminSession(), { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: NODE_ENV === "production", sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 7 days + maxAge: ADMIN_SESSION_MAX_AGE_SEC, path: "/", }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 1565370..db0f5f8 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,41 +1,61 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { auth } from "@/lib/better-auth"; +import { createRateLimiter, getClientIp } from "@/lib/rate-limit"; + +const registerLimiter = createRateLimiter({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 10, +}); + +const registerSchema = z.object({ + email: z.string().email().max(254), + password: z.string().min(8).max(128), + name: z.string().trim().min(0).max(120).optional(), +}); export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { email, password, name } = body; + const ip = getClientIp(request.headers); + registerLimiter.record(ip); + if (registerLimiter.check(ip)) { + return NextResponse.json( + { error: "Too many signup attempts. Try again later." }, + { status: 429 } + ); + } - if (!email || !password) { - return NextResponse.json( - { error: "Email and password are required" }, - { status: 400 } - ); - } + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } - if (typeof password !== "string" || password.length < 8) { - return NextResponse.json( - { error: "Password must be at least 8 characters" }, - { status: 400 } - ); - } + const parsed = registerSchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { + error: + parsed.error.issues[0]?.message ?? "Email and password are required", + }, + { status: 400 } + ); + } - // Use better-auth's internal API to create the user + try { const response = await auth.api.signUpEmail({ body: { - email, - password, - name: name || "", + email: parsed.data.email, + password: parsed.data.password, + name: parsed.data.name ?? "", }, }); - if (!response) { return NextResponse.json( { error: "Registration failed" }, { status: 500 } ); } - return NextResponse.json({ success: true }, { status: 201 }); } catch (error) { const message = diff --git a/src/app/api/feed/route.ts b/src/app/api/feed/route.ts index 1d9cafb..b82167d 100644 --- a/src/app/api/feed/route.ts +++ b/src/app/api/feed/route.ts @@ -1,15 +1,12 @@ import { getPublishedPosts } from "@/db/queries/posts"; import { escapeXml } from "@/lib/utils"; +import { env } from "@/lib/env"; -export async function GET() { - const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; +export const dynamic = "force-dynamic"; - let posts: Awaited> = []; - try { - posts = await getPublishedPosts({ sort: "date-desc", limit: 50 }); - } catch { - posts = []; - } +export async function GET() { + const appUrl = env().NEXT_PUBLIC_APP_URL; + const posts = await getPublishedPosts({ sort: "date-desc", limit: 50 }); const items = posts .map( @@ -18,7 +15,7 @@ export async function GET() { ${escapeXml(post.siteName)} Design Roast — Score: ${post.score}/100 ${appUrl}/roast/${escapeXml(post.slug)} - ${post.publishedAt ? new Date(post.publishedAt).toISOString() : new Date(post.createdAt).toISOString()} + ${(post.publishedAt ?? post.createdAt).toISOString()} ${escapeXml(post.overallSummary.slice(0, 300))} ` ) @@ -42,4 +39,3 @@ export async function GET() { }, }); } - diff --git a/src/app/api/sitemap/route.ts b/src/app/api/sitemap/route.ts index 9ac2730..a3b2c36 100644 --- a/src/app/api/sitemap/route.ts +++ b/src/app/api/sitemap/route.ts @@ -1,16 +1,13 @@ import { getAllPublishedSlugs } from "@/db/queries/posts"; import { CATEGORIES } from "@/lib/constants"; import { escapeXml } from "@/lib/utils"; +import { env } from "@/lib/env"; -export async function GET() { - const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; +export const dynamic = "force-dynamic"; - let slugs: { slug: string }[] = []; - try { - slugs = await getAllPublishedSlugs(); - } catch { - slugs = []; - } +export async function GET() { + const appUrl = env().NEXT_PUBLIC_APP_URL; + const slugs = await getAllPublishedSlugs(); const postUrls = slugs .map( diff --git a/src/app/api/webhooks/lemonsqueezy/route.ts b/src/app/api/webhooks/lemonsqueezy/route.ts index 2685e32..690bd88 100644 --- a/src/app/api/webhooks/lemonsqueezy/route.ts +++ b/src/app/api/webhooks/lemonsqueezy/route.ts @@ -1,9 +1,39 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { verifyWebhookSignature } from "@/lib/lemonsqueezy"; import { db } from "@/db"; -import { users } from "@/db/schema"; +import { user } from "@/db/schema"; import { eq } from "drizzle-orm"; +/** + * Minimal shape we need from a Lemon Squeezy subscription webhook. The Lemon + * Squeezy payload is much richer; we parse only the fields we act on and + * ignore the rest so payload changes on their side don't break us. + */ +const webhookSchema = z.object({ + meta: z.object({ + event_name: z.string(), + }), + data: z.object({ + id: z.union([z.string(), z.number()]).transform(String), + attributes: z + .object({ + status: z.string().optional(), + user_email: z.string().email().optional(), + customer_id: z.union([z.string(), z.number()]).optional(), + }) + .passthrough(), + }), +}); + +const LS_STATUS_MAP: Record = { + active: "pro", + paused: "paused", + past_due: "past_due", + unpaid: "past_due", + on_trial: "pro", +}; + export async function POST(request: NextRequest) { const body = await request.text(); const signature = request.headers.get("x-signature") ?? ""; @@ -12,19 +42,28 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } - let event: Record; + let rawEvent: unknown; try { - event = JSON.parse(body); + rawEvent = JSON.parse(body); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } - const eventName = (event.meta as Record)?.event_name; - const data = event.data as Record | undefined; - const attributes = data?.attributes as Record | undefined; - const email = attributes?.user_email as string | undefined; - const customerId = (attributes?.customer_id as number | undefined)?.toString(); - const subscriptionId = (data?.id as string | undefined)?.toString(); + const parsed = webhookSchema.safeParse(rawEvent); + if (!parsed.success) { + return NextResponse.json( + { error: "Malformed webhook payload" }, + { status: 400 } + ); + } + + const event = parsed.data; + const eventName = event.meta.event_name; + const { attributes } = event.data; + const email = attributes.user_email; + const customerId = + attributes.customer_id !== undefined ? String(attributes.customer_id) : null; + const subscriptionId = event.data.id; if (!email) { return NextResponse.json({ error: "No email in event" }, { status: 400 }); @@ -33,29 +72,22 @@ export async function POST(request: NextRequest) { switch (eventName) { case "subscription_created": case "subscription_updated": { - // Map LemonSqueezy subscription status to our status - const lsStatus = attributes?.status as string | undefined; - const statusMap: Record = { - active: "pro", - paused: "paused", - past_due: "past_due", - unpaid: "past_due", - on_trial: "pro", - }; - const subscriptionStatus = statusMap[lsStatus ?? ""] ?? "pro"; - + const subscriptionStatus = + LS_STATUS_MAP[attributes.status ?? ""] ?? "pro"; const result = await db - .update(users) + .update(user) .set({ subscriptionStatus, lemonSqueezyCustomerId: customerId, lemonSqueezySubscriptionId: subscriptionId, updatedAt: new Date(), }) - .where(eq(users.email, email)) - .returning({ id: users.id }); + .where(eq(user.email, email)) + .returning({ id: user.id }); if (result.length === 0) { - console.warn(`Webhook: no user found for provided email, subscription not applied`); + console.warn( + `Webhook: no user found for email, subscription not applied` + ); } break; } @@ -63,25 +95,28 @@ export async function POST(request: NextRequest) { case "subscription_cancelled": case "subscription_expired": { const result = await db - .update(users) + .update(user) .set({ subscriptionStatus: "cancelled", updatedAt: new Date(), }) - .where(eq(users.email, email)) - .returning({ id: users.id }); + .where(eq(user.email, email)) + .returning({ id: user.id }); if (result.length === 0) { - console.warn(`Webhook: no user found for provided email, cancellation not applied`); + console.warn( + `Webhook: no user found for email, cancellation not applied` + ); } break; } case "subscription_payment_failed": - console.warn("Webhook: subscription payment failed"); + console.warn("Webhook: subscription payment failed", { email }); break; default: - console.log(`Unhandled Lemon Squeezy event: ${eventName}`); + // Unhandled event types are acknowledged so Lemon Squeezy doesn't retry. + break; } return NextResponse.json({ received: true }); diff --git a/src/app/category/[category]/page.tsx b/src/app/category/[category]/page.tsx index fea2572..38465ba 100644 --- a/src/app/category/[category]/page.tsx +++ b/src/app/category/[category]/page.tsx @@ -1,26 +1,30 @@ import { Header } from "@/components/shared/header"; import { Footer } from "@/components/shared/footer"; -import { ScoreBadge } from "@/components/roast/score-badge"; -import { SeverityBadge } from "@/components/roast/severity-badge"; -import { getPublishedPosts } from "@/db/queries/posts"; -import { CATEGORIES, type SiteType, type Severity } from "@/lib/constants"; -import Link from "next/link"; +import { RoastCard } from "@/components/roast/roast-card"; +import { getPublishedPostsWithThumbnails } from "@/db/queries/posts"; +import { CATEGORIES, type SiteType, SITE_TYPES } from "@/lib/constants"; import { notFound } from "next/navigation"; import type { Metadata } from "next"; +export const dynamic = "force-dynamic"; + export function generateStaticParams() { return CATEGORIES.filter((c) => c.slug !== "all").map((c) => ({ category: c.slug, })); } +function findCategory(slug: string) { + return CATEGORIES.find((c) => c.slug === slug && c.slug !== "all"); +} + export async function generateMetadata({ params, }: { params: Promise<{ category: string }>; }): Promise { const { category } = await params; - const cat = CATEGORIES.find((c) => c.slug === category); + const cat = findCategory(category); if (!cat) return { title: "Category Not Found" }; return { @@ -35,16 +39,15 @@ export default async function CategoryPage({ params: Promise<{ category: string }>; }) { const { category } = await params; - - const cat = CATEGORIES.find((c) => c.slug === category); + const cat = findCategory(category); if (!cat) notFound(); - let roasts: Awaited> = []; - try { - roasts = await getPublishedPosts({ category: category as SiteType }); - } catch { - roasts = []; - } + const siteType = (SITE_TYPES as readonly string[]).includes(cat.slug) + ? (cat.slug as SiteType) + : undefined; + if (!siteType) notFound(); + + const roasts = await getPublishedPostsWithThumbnails({ category: siteType }); return (
@@ -61,34 +64,7 @@ export default async function CategoryPage({ ) : (
{roasts.map((roast) => ( - -
-
- {roast.siteName?.[0] ?? "?"} -
-
-
{roast.siteName}
-
{roast.siteUrl}
-
- - -
-
- {[1, 2, 3, 4, 5].map((i) => ( -
-
-
- ))} -
- + ))}
)} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..bef4cd7 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Unhandled route error:", error); + }, [error]); + + return ( +
+
+

+ Something went wrong +

+

+ This page couldn't load. If this keeps happening, let us know. +

+ +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 144119c..b7baaec 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,16 +1,8 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { GeistSans } from "geist/font/sans"; +import { GeistMono } from "geist/font/mono"; import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import { env } from "@/lib/env"; export const metadata: Metadata = { title: { @@ -19,9 +11,7 @@ export const metadata: Metadata = { }, description: "The honest design feedback nobody else will give you. Section-by-section roasts of website and landing page designs, scored 0-100.", - metadataBase: new URL( - process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" - ), + metadataBase: new URL(env().NEXT_PUBLIC_APP_URL), }; export default function RootLayout({ @@ -32,7 +22,7 @@ export default function RootLayout({ return ( +
+ + Loading roasts… +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 615c7a4..640c450 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,14 +1,19 @@ import { Header } from "@/components/shared/header"; import { Footer } from "@/components/shared/footer"; -import { ScoreBadge } from "@/components/roast/score-badge"; -import { SeverityBadge } from "@/components/roast/severity-badge"; -import { getPublishedPosts } from "@/db/queries/posts"; -import Link from "next/link"; -import { SITE_TYPES, type SiteType } from "@/lib/constants"; -import type { Severity } from "@/lib/constants"; -import { isValidSortKey } from "@/db/queries/posts"; -import { Suspense } from "react"; +import { RoastCard } from "@/components/roast/roast-card"; import { SortSelect } from "@/components/shared/sort-select"; +import { getPublishedPostsWithThumbnails } from "@/db/queries/posts"; +import { + SITE_TYPES, + type SiteType, +} from "@/lib/constants"; +import { + isValidSortKey, + type SortKey, +} from "@/db/queries/posts"; +import { Suspense } from "react"; + +export const dynamic = "force-dynamic"; export default async function HomePage({ searchParams, @@ -20,34 +25,15 @@ export default async function HomePage({ }>; }) { const params = await searchParams; - const rawCategory = params.category; - const category = rawCategory && (SITE_TYPES as readonly string[]).includes(rawCategory) - ? (rawCategory as SiteType) - : undefined; - const search = params.search; - const sort = params.sort && isValidSortKey(params.sort) ? params.sort : "date-desc"; + const category = isSiteType(params.category) ? params.category : undefined; + const search = params.search?.trim() || undefined; + const sort: SortKey = isValidSortKey(params.sort) ? params.sort : "date-desc"; - let roasts: Array<{ - id: string; - slug: string; - siteName: string; - siteUrl: string; - score: number; - severity: string; - siteType: string; - overallSummary: string; - }> = []; - - try { - roasts = await getPublishedPosts({ category, search, sort }); - } catch { - // DB not connected yet — show sample data - roasts = SAMPLE_ROASTS; - } - - if (roasts.length === 0 && !search && !category) { - roasts = SAMPLE_ROASTS; - } + const roasts = await getPublishedPostsWithThumbnails({ + category, + search, + sort, + }); return (
@@ -55,59 +41,23 @@ export default async function HomePage({
- {/* Sort dropdown */}
- }> + + } + >
-
- {roasts.map((roast) => ( - - {/* Left sidebar: metadata */} -
-
- {roast.siteName?.[0] ?? "?"} -
-
-
- {roast.siteName} -
-
- {roast.siteUrl} -
-
- - - - {roast.siteType} - -
- - {/* Right: screenshot skeleton placeholders */} -
- {[1, 2, 3, 4, 5].map((i) => ( -
-
-
- ))} -
- - ))} -
- - {roasts.length === 0 && ( -
- No roasts found. Try a different search or category. + {roasts.length === 0 ? ( + + ) : ( +
+ {roasts.map((roast) => ( + + ))}
)}
@@ -118,35 +68,42 @@ export default async function HomePage({ ); } -const SAMPLE_ROASTS = [ - { - id: "1", - slug: "notion-landing-page", - siteName: "Notion", - siteUrl: "notion.so", - score: 41, - severity: "heavy-roast", - siteType: "saas", - overallSummary: "Notion's landing page is a masterclass in trying to say everything at once.", - }, - { - id: "2", - slug: "linear-app-design", - siteName: "Linear", - siteUrl: "linear.app", - score: 78, - severity: "mild-burn", - siteType: "saas", - overallSummary: "Linear's landing page is clean, fast, and confident.", - }, - { - id: "3", - slug: "cal-com-landing", - siteName: "Cal.com", - siteUrl: "cal.com", - score: 62, - severity: "medium-roast", - siteType: "saas", - overallSummary: "Cal.com has solid bones but the hero section is fighting itself.", - }, -]; +function isSiteType(value: string | undefined): value is SiteType { + return ( + typeof value === "string" && + (SITE_TYPES as readonly string[]).includes(value) + ); +} + +function EmptyState({ + search, + category, +}: { + search?: string; + category?: SiteType; +}) { + if (search || category) { + return ( +
+

+ No roasts match{" "} + {search ? ( + <> + the search “{search}” + + ) : ( + <>the {category} category + )} + . +

+
+ ); + } + return ( +
+

+ No roasts published yet. Check back soon. +

+
+ ); +} diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index b2eb554..685003d 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -1,8 +1,23 @@ import { Header } from "@/components/shared/header"; import { Footer } from "@/components/shared/footer"; import Link from "next/link"; +import { env } from "@/lib/env"; + +export const metadata = { + title: "Pricing", + description: "RoastFolio Pro — detailed reports and priority roasts.", +}; + +const FEATURES = [ + "Detailed design reports", + "Priority 'Roast My Site' requests", + "Early access to new roasts", + "Newsletter with weekly top roasts", +]; export default function PricingPage() { + const checkoutUrl = env().LEMONSQUEEZY_CHECKOUT_URL; + return (
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index b28106c..ab649d3 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -17,7 +17,6 @@ export default function RegisterPage() { setLoading(true); try { - // TODO: Implement /api/auth/register endpoint const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -27,8 +26,10 @@ export default function RegisterPage() { if (res.ok) { setSuccess(true); } else { - const data = await res.json(); - setError(data.error || "Registration failed"); + const data = await res + .json() + .catch(() => ({ error: "Registration failed" })); + setError(data.error ?? "Registration failed"); } } catch { setError("Something went wrong"); @@ -64,33 +65,85 @@ export default function RegisterPage() {

Create an account

- +
- - setName(e.target.value)} - className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent" /> + + setName(e.target.value)} + className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent" + />
- - setEmail(e.target.value)} required - className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent" /> + + setEmail(e.target.value)} + required + className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent" + />
- - setPassword(e.target.value)} required minLength={8} - className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent" /> + + setPassword(e.target.value)} + required + minLength={8} + className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent" + />
- {error &&

{error}

} + {error && ( +

+ {error} +

+ )} -

Already have an account?{" "} - Log in + + Log in +

diff --git a/src/app/roast/[slug]/opengraph-image.tsx b/src/app/roast/[slug]/opengraph-image.tsx index cbe1b7a..6dad202 100644 --- a/src/app/roast/[slug]/opengraph-image.tsx +++ b/src/app/roast/[slug]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { ImageResponse } from "next/og"; +import { notFound } from "next/navigation"; import { getPostBySlug } from "@/db/queries/posts"; import { scoreToColor } from "@/lib/score-utils"; import { SEVERITY_LABELS, type Severity } from "@/lib/constants"; @@ -14,23 +15,13 @@ export default async function OgImage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + const post = await getPostBySlug(slug); + if (!post) notFound(); - let siteName = "Unknown Site"; - let score = 50; - let severity: Severity = "medium-roast"; - - try { - const post = await getPostBySlug(slug); - if (post) { - siteName = post.siteName; - score = post.score; - severity = post.severity as Severity; - } - } catch { - // Use defaults - } - + const { score, siteName } = post; + const severity = post.severity as Severity; const color = scoreToColor(score); + const severityLabel = SEVERITY_LABELS[severity] ?? severity; return new ImageResponse( ( @@ -46,7 +37,6 @@ export default async function OgImage({ fontFamily: "system-ui, sans-serif", }} > - {/* Logo */}
folio
- {/* Score */}
- {/* Site name */}
- {/* Severity */}
- {SEVERITY_LABELS[severity]} + {severityLabel}
), diff --git a/src/app/roast/[slug]/page.tsx b/src/app/roast/[slug]/page.tsx index 3099fd8..4b54825 100644 --- a/src/app/roast/[slug]/page.tsx +++ b/src/app/roast/[slug]/page.tsx @@ -1,4 +1,6 @@ import Link from "next/link"; +import Image from "next/image"; +import { notFound } from "next/navigation"; import { ScoreBadge } from "@/components/roast/score-badge"; import { SeverityBadge } from "@/components/roast/severity-badge"; import { Header } from "@/components/shared/header"; @@ -7,19 +9,27 @@ import { JsonLd } from "@/components/shared/json-ld"; import { getPostBySlug, getPostSections, + getPostScreenshots, getAllPublishedSlugs, getRelatedPosts, incrementViewCount, } from "@/db/queries/posts"; import { formatDate } from "@/lib/utils"; import type { Severity } from "@/lib/constants"; +import { env } from "@/lib/env"; import type { Metadata } from "next"; +export const dynamic = "force-dynamic"; + export async function generateStaticParams() { + // Build-time hint only. The page is `force-dynamic`, so returning an empty + // list when the DB is unreachable (cold build, no migrations yet) is safe — + // every route still renders on demand. try { const slugs = await getAllPublishedSlugs(); return slugs.map((s) => ({ slug: s.slug })); - } catch { + } catch (err) { + console.warn("generateStaticParams: DB unavailable, skipping:", err); return []; } } @@ -30,8 +40,7 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; - const post = await getPostBySlug(slug).catch(() => null); - + const post = await getPostBySlug(slug); if (!post) return { title: "Roast Not Found" }; return { @@ -53,42 +62,54 @@ export default async function RoastPage({ }) { const { slug } = await params; - let dbPost: Awaited> | null = null; - let dbAvailable = true; - try { - dbPost = await getPostBySlug(slug); - } catch { - dbAvailable = false; - } + const post = await getPostBySlug(slug); + if (!post) notFound(); - if (!dbPost && dbAvailable) { - const { notFound } = await import("next/navigation"); - notFound(); - } - const post = dbPost ?? SAMPLE_ROAST; - const postSections = dbPost - ? await getPostSections(dbPost.id).catch(() => SAMPLE_ROAST.sections) - : SAMPLE_ROAST.sections; - const relatedPosts = dbPost - ? await getRelatedPosts(slug, dbPost.siteType).catch(() => []) - : []; + const [postSections, postScreenshots, relatedPosts] = await Promise.all([ + getPostSections(post.id), + getPostScreenshots(post.id), + getRelatedPosts(slug, post.siteType), + ]); + + // Fire-and-forget view count bump. Failures shouldn't block rendering and + // losing an occasional view to a DB hiccup is acceptable. + void incrementViewCount(post.id).catch((err) => { + console.warn("incrementViewCount failed:", err); + }); - if (dbPost) { - incrementViewCount(dbPost.id).catch(() => {}); + const heroShot = + postScreenshots.find( + (s) => s.sectionId === null && s.viewport === "desktop" + ) ?? + postScreenshots.find((s) => s.sectionId === null) ?? + postScreenshots[0] ?? + null; + + const sectionScreenshots = new Map(); + for (const shot of postScreenshots) { + if (shot.sectionId && !sectionScreenshots.has(shot.sectionId)) { + sectionScreenshots.set(shot.sectionId, shot); + } } + const appUrl = env().NEXT_PUBLIC_APP_URL; + const shareUrl = `${appUrl}/roast/${slug}`; + const jsonLdData = { "@context": "https://schema.org", "@type": "Article", headline: `${post.siteName} Design Roast — Score: ${post.score}/100`, description: post.overallSummary.slice(0, 160), author: { "@type": "Organization", name: "RoastFolio" }, - datePublished: post.publishedAt - ? new Date(post.publishedAt).toISOString() - : new Date(post.createdAt).toISOString(), + datePublished: (post.publishedAt ?? post.createdAt).toISOString(), publisher: { "@type": "Organization", name: "RoastFolio" }, + image: heroShot?.r2Url, }; + const pros = post.pros ?? []; + const cons = post.cons ?? []; + const suggestions = post.suggestions ?? []; + return (
@@ -104,8 +125,7 @@ export default async function RoastPage({ ← Back to Gallery - {/* Score Hero */} -
+

{post.siteName}

{post.siteUrl} @@ -116,18 +136,26 @@ export default async function RoastPage({

{post.siteType} ·{" "} - {post.publishedAt - ? formatDate(post.publishedAt) - : formatDate(post.createdAt)} + {formatDate(post.publishedAt ?? post.createdAt)}

-
+
- {/* Full-page screenshot placeholder */} -
- Full-page screenshot will appear here -
+ {heroShot ? ( +
+
+ {heroShot.altText +
+
+ ) : null} - {/* Overall Summary */}

Overall Summary

@@ -135,35 +163,47 @@ export default async function RoastPage({

- {/* Section-by-section */} - {postSections.map((section, i) => ( -
-
-

{section.name}

- {section.score != null && ( - - )} -
-
- {section.name} screenshot -
-

- {section.content} -

-
- ))} + {postSections.map((section) => { + const shot = sectionScreenshots.get(section.id); + return ( +
+
+

{section.name}

+ {section.score != null && ( + + )} +
+ {shot ? ( +
+ {shot.altText +
+ ) : null} +

+ {section.content} +

+
+ ); + })} - {/* Pros & Cons */}

Pros

    - {((post.pros as string[] | null) ?? []).map((pro, i) => ( + {pros.map((pro, i) => (
  • - + + ✓ + {pro}
  • ))} @@ -172,12 +212,14 @@ export default async function RoastPage({

    Cons

      - {((post.cons as string[] | null) ?? []).map((con, i) => ( + {cons.map((con, i) => (
    • - + + ✗ + {con}
    • ))} @@ -185,19 +227,22 @@ export default async function RoastPage({
- {/* Suggestions */} -
-

Improvement Suggestions

-
    - {((post.suggestions as string[] | null) ?? []).map((suggestion, i) => ( -
  1. - {suggestion} -
  2. - ))} -
-
+ {suggestions.length > 0 ? ( +
+

Improvement Suggestions

+
    + {suggestions.map((suggestion, i) => ( +
  1. + {suggestion} +
  2. + ))} +
+
+ ) : null} - {/* AI Disclosure */}

This analysis was generated by AI and focuses on design patterns, @@ -206,19 +251,22 @@ export default async function RoastPage({

- {/* Share */}
Share on X - · + ·
- {/* Related Roasts */} - {relatedPosts && relatedPosts.length > 0 && ( + {relatedPosts.length > 0 ? (

Related Roasts

@@ -253,7 +300,7 @@ export default async function RoastPage({ ))}
- )} + ) : null}
@@ -261,37 +308,3 @@ export default async function RoastPage({
); } - -const SAMPLE_ROAST = { - id: "sample", - siteName: "Notion", - siteUrl: "notion.so", - score: 41, - severity: "heavy-roast", - siteType: "saas", - publishedAt: new Date("2026-03-18"), - createdAt: new Date("2026-03-18"), - overallSummary: - "Notion's landing page is a masterclass in trying to say everything at once and ending up saying nothing. With 4 competing CTAs, a hero headline that could belong to any productivity app, and a navigation bar that's basically a table of contents for a novel nobody asked to read — this page commits every sin in the conversion playbook while somehow still looking clean enough to get away with it.", - pros: [ - "Clean, minimal visual design with excellent whitespace", - "Testimonials section is genuinely well-executed with real names and companies", - "Page load performance is excellent — sub-2-second LCP", - ], - cons: [ - "Hero headline is vague and undifferentiated", - "4 competing CTAs above the fold create decision paralysis", - "Mega-menu navigation is overly complex for the user's primary goal", - ], - suggestions: [ - "Reduce hero CTAs to one primary action — 'Get Notion Free'", - "Move social proof (customer logos) above the fold", - "Simplify navigation to 5 items max — hide the rest under a 'More' menu", - "Make the pricing page link prominent — users who want to pay shouldn't have to hunt", - ], - sections: [ - { name: "Hero Section", score: 35, content: "Your hero headline is fighting 4 competing CTAs for attention." }, - { name: "Navigation", score: 52, content: "A mega-menu this dense belongs in 2015." }, - { name: "CTA Strategy", score: 28, content: "Four CTAs above the fold. This isn't a call to action — it's a call to confusion." }, - ], -}; diff --git a/src/components/admin/action-button.tsx b/src/components/admin/action-button.tsx new file mode 100644 index 0000000..42feddf --- /dev/null +++ b/src/components/admin/action-button.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useTransition } from "react"; + +type ActionResult = { success: boolean; error?: string }; + +interface ActionButtonProps { + action: () => Promise; + label: string; + pendingLabel?: string; + className?: string; +} + +/** + * Client button that runs a server action and surfaces success / error + * feedback inline. Routes stay in sync with the server's post-action + * `revalidatePath` via `useTransition`. + */ +export function ActionButton({ + action, + label, + pendingLabel, + className = "text-xs text-accent hover:underline", +}: ActionButtonProps) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function run() { + setError(null); + startTransition(async () => { + try { + const result = await action(); + if (!result.success) { + setError(result.error ?? "Action failed"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Action failed"); + } + }); + } + + return ( + + + {error && ( + + {error} + + )} + + ); +} diff --git a/src/components/admin/confirm-button.tsx b/src/components/admin/confirm-button.tsx index d463d1a..4b4afd3 100644 --- a/src/components/admin/confirm-button.tsx +++ b/src/components/admin/confirm-button.tsx @@ -1,46 +1,70 @@ "use client"; -import { useState } from "react"; +import { useState, useTransition } from "react"; + +type ActionResult = { success: boolean; error?: string }; + +interface ConfirmButtonProps { + action: () => Promise; + label: string; + confirmLabel?: string; + className?: string; +} + +/** + * Two-step destructive button: the first click reveals a confirm + cancel + * pair, the second click executes. Surfaces server-action errors inline. + */ export function ConfirmButton({ action, label, confirmLabel = "Confirm", - className = "", -}: { - action: () => Promise; - label: string; - confirmLabel?: string; - className?: string; -}) { + className, +}: ConfirmButtonProps) { const [confirming, setConfirming] = useState(false); - const [pending, setPending] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); - async function handleConfirm() { - setPending(true); - try { - await action(); - } finally { - setPending(false); - setConfirming(false); - } + function run() { + setError(null); + startTransition(async () => { + try { + const result = await action(); + if (result.success) { + setConfirming(false); + } else { + setError(result.error ?? "Action failed"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Action failed"); + } + }); } if (confirming) { return ( - + + {error && ( + + {error} + + )} ); } diff --git a/src/components/admin/edit-post-form.tsx b/src/components/admin/edit-post-form.tsx index bf6832c..ec5ef14 100644 --- a/src/components/admin/edit-post-form.tsx +++ b/src/components/admin/edit-post-form.tsx @@ -14,9 +14,9 @@ interface EditPostFormProps { severity: string; siteType: string; overallSummary: string; - pros: string[] | null; - cons: string[] | null; - suggestions: string[] | null; + pros: string[]; + cons: string[]; + suggestions: string[]; }; sections: Array<{ id: string; @@ -33,7 +33,9 @@ export function EditPostForm({ post, sections }: EditPostFormProps) { const [siteType, setSiteType] = useState(post.siteType); const [overallSummary, setOverallSummary] = useState(post.overallSummary); const [saving, setSaving] = useState(false); - const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(null); + const [feedback, setFeedback] = useState< + { type: "success" | "error"; message: string } | null + >(null); async function handleSave() { setSaving(true); @@ -49,10 +51,16 @@ export function EditPostForm({ post, sections }: EditPostFormProps) { if (result.success) { setFeedback({ type: "success", message: "Saved successfully" }); } else { - setFeedback({ type: "error", message: result.error || "Failed to save" }); + setFeedback({ + type: "error", + message: result.error ?? "Failed to save", + }); } - } catch { - setFeedback({ type: "error", message: "Failed to save" }); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : "Failed to save", + }); } finally { setSaving(false); } @@ -60,16 +68,22 @@ export function EditPostForm({ post, sections }: EditPostFormProps) { return (
- {/* Overview card */}
-
{post.siteUrl}
+
+ {post.siteUrl} +
- +
- + setScore(parseInt(e.target.value) || 0)} + onChange={(e) => { + const parsed = parseInt(e.target.value, 10); + setScore(Number.isFinite(parsed) ? parsed : 0); + }} className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent" />
- +
- +
- +