Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
160 changes: 106 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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

Expand Down
15 changes: 13 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-roast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
}

Expand Down
26 changes: 17 additions & 9 deletions src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,33 @@ 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<Result> {
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)) {
return { success: false, error: "Invalid domain" };
}

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" };
}
Expand All @@ -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" };
}
}
Loading