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
6 changes: 6 additions & 0 deletions apps/api/lib/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import { Hono } from "hono";

import { billingRouter } from "../routers/billing.js";
import { configRouter } from "../routers/config.js";
import { creditsRouter } from "../routers/credits.js";
import { generateRouter } from "../routers/generate.js";
import { historyRouter } from "../routers/history.js";
import type { AppContext } from "./context.js";
import { router } from "./trpc.js";

// tRPC API router
const appRouter = router({
billing: billingRouter,
config: configRouter,
credits: creditsRouter,
generate: generateRouter,
history: historyRouter,
});

// HTTP router
Expand Down
11 changes: 11 additions & 0 deletions apps/api/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Ai, KVNamespace, R2Bucket } from "@cloudflare/workers-types";
import { z } from "zod";

/** Environment contract and source for the inferred `Env` type. */
Expand All @@ -22,6 +23,16 @@ export const envSchema = z.object({
STRIPE_STARTER_PRICE_ID: z.string().startsWith("price_").optional(),
STRIPE_PRO_PRICE_ID: z.string().startsWith("price_").optional(),
STRIPE_PRO_ANNUAL_PRICE_ID: z.string().startsWith("price_").optional(),
// Turnstile bot protection on generate (optional – when set, tokens are
// REQUIRED and verified server-side; absent means the gate is disabled,
// which is only acceptable in local development).
TURNSTILE_SECRET_KEY: z.string().optional(),
// Cloudflare bindings. Optional because tests and non-Workers runtimes build
// Env by hand: a missing binding degrades the feature (no rate limiting,
// generation reports unavailable) instead of crashing unrelated requests.
AI: z.custom<Ai>().optional(),
IMAGES: z.custom<R2Bucket>().optional(),
RATELIMIT: z.custom<KVNamespace>().optional(),
});

// Do not parse `Bun.env` at module load: production bindings arrive on `c.env`,
Expand Down
101 changes: 101 additions & 0 deletions apps/api/lib/generations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Generation persistence. All access to the `generation` table lives here so
* routers stay orchestration-only and future surfaces (public gallery,
* reconcile sweep) reuse the same queries.
*
* Filters are always parameterized drizzle predicates over plain parameters;
* callers pass server-derived ids.
*/

import { generation, type Database } from "@repo/db";
import { and, desc, eq } from "drizzle-orm";

export type GenerationRow = typeof generation.$inferSelect;
export type NewGenerationRow = typeof generation.$inferInsert;

export async function insertGeneration(
db: Database,
values: NewGenerationRow,
): Promise<GenerationRow> {
const rows = await db.insert(generation).values(values).returning();
return rows[0];
}

/** Idempotent replay lookup: the caller's (userId, retry key) pair. */
export async function findGenerationByIdempotencyKey(
db: Database,
ownerId: string,
idempotencyKey: string,
): Promise<GenerationRow | null> {
const rows = await db
.select()
.from(generation)
.where(
and(
eq(generation.userId, ownerId),
eq(generation.idempotencyKey, idempotencyKey),
),
)
.limit(1);
return rows[0] ?? null;
}

export async function listRecentGenerations(
db: Database,
ownerId: string,
limit = 20,
): Promise<GenerationRow[]> {
return db
.select()
.from(generation)
.where(eq(generation.userId, ownerId))
.orderBy(desc(generation.createdAt))
.limit(limit);
}

export async function findGenerationById(
db: Database,
ownerId: string,
id: string,
): Promise<GenerationRow | null> {
const rows = await db
.select()
.from(generation)
.where(and(eq(generation.id, id), eq(generation.userId, ownerId)))
.limit(1);
return rows[0] ?? null;
}

/**
* Terminal success transition: only a `processing` row can become
* `succeeded`, so a replayed or swept row can never flip states twice.
*/
export async function markGenerationSucceeded(
db: Database,
id: string,
r2Key: string,
): Promise<GenerationRow | null> {
const rows = await db
.update(generation)
.set({ status: "succeeded", r2Key })
.where(and(eq(generation.id, id), eq(generation.status, "processing")))
.returning();
return rows[0] ?? null;
}

/** Terminal failure transition; refunds are the caller's responsibility. */
export async function markGenerationFailed(
db: Database,
id: string,
errorKind: GenerationRow["errorKind"],
errorMessage: string,
): Promise<void> {
await db
.update(generation)
.set({
status: "failed",
errorKind,
errorMessage: errorMessage.slice(0, 500),
})
.where(eq(generation.id, id));
}
125 changes: 125 additions & 0 deletions apps/api/lib/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Abuse guardrails for the generate endpoint: input validation, prompt
* blocklist, Turnstile server-side verification, and a KV rate limiter.
*
* Each piece degrades explicitly rather than silently: an unset Turnstile
* secret disables the captcha gate (dev only – production sets the secret),
* and a missing KV binding disables rate limiting rather than erroring every
* request.
*/

import { z } from "zod";

export const generateInputSchema = z.object({
prompt: z.string().trim().min(1).max(2048),
steps: z.int().min(1).max(8).default(4),
turnstileToken: z.string().min(1).optional(),
/**
* Client retry key. A network retry replays it and receives the original
* outcome instead of a second charge.
*/
idempotencyKey: z
.string()
.min(8)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/)
.optional(),
});

export type GenerateInput = z.infer<typeof generateInputSchema>;

/** Credits per generation: 1 base, doubled above 4 steps. */
export function creditCost(steps: number): number {
return steps > 4 ? 2 : 1;
}

/**
* Coarse prompt blocklist for the highest-harm categories. This is a
* first-pass filter, not moderation: it exists so a brand-new site is not a
* free CSAM/gore printer on day one. Match is case-insensitive on word
* fragments; expand per-site in the site layer later.
*/
const BLOCKED_PATTERNS: readonly RegExp[] = [
/\bchild(?:ren)?\s+(?:porn|nude|naked|sex)/i,
/\bcsam\b/i,
/\bloli(?:con)?\s+(?:porn|nude|naked|sex|hentai)?/i,
/\bshota(?:con)?\s+(?:porn|nude|naked|sex|hentai)?/i,
/\bbeastiality\b/i,
/\brape\s+(?:scene|victim)/i,
/\bgore\s+(?:porn|sex)/i,
];

/** Returns the matched pattern source when the prompt must be rejected. */
export function findBlockedPrompt(prompt: string): string | null {
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(prompt)) return pattern.source;
}
return null;
}

const TURNSTILE_VERIFY_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";

/**
* Verify a Turnstile token server-side. `secret` absent means the gate is
* disabled (local development) and every token passes.
*/
export async function verifyTurnstile(
secret: string | undefined,
token: string | undefined,
remoteIp?: string,
): Promise<boolean> {
if (!secret) return true;
if (!token) return false;

const body = new URLSearchParams({ secret, response: token });
if (remoteIp) body.set("remoteip", remoteIp);

try {
const response = await fetch(TURNSTILE_VERIFY_URL, {
method: "POST",
body,
});
if (!response.ok) return false;
const data = (await response.json()) as { success?: boolean };
return data.success === true;
} catch {
// Network failure verifying the captcha: fail closed. A transient error
// inconveniences one request; an open gate serves abusers.
return false;
}
}

/** The subset of KV the limiter needs; satisfied by the binding and mocks. */
export type RateLimitStore = Pick<KVNamespace, "get" | "put">;

export type RateLimitDecision = {
allowed: boolean;
/** Attempts left in the current window (0 when blocked). */
remaining: number;
};

/**
* Fixed-window limiter. Deliberately best-effort: the get-then-put race can
* undercount by one under bursts, which is fine for an abuse brake (the
* wallet still enforces the real money constraint).
*/
export async function consumeRateLimit(
store: RateLimitStore | undefined,
key: string,
limit: number,
windowSeconds: number,
): Promise<RateLimitDecision> {
if (!store) return { allowed: true, remaining: limit };

const windowStart = Math.floor(Date.now() / 1000 / windowSeconds);
const windowKey = `rl:${key}:${windowStart}`;
const count = Number((await store.get(windowKey)) ?? "0");

if (count >= limit) return { allowed: false, remaining: 0 };

await store.put(windowKey, String(count + 1), {
expirationTtl: windowSeconds * 2,
});
return { allowed: true, remaining: limit - count - 1 };
}
Loading
Loading