feat: Cloudflare Turnstile on auth, CSP fixes, admin/SEO/analytics additions
Turnstile bot protection (sign-in, sign-up, password-reset): - Register Better Auth's captcha plugin with the cloudflare-turnstile provider; endpoints listed explicitly rather than relying on defaults. /reset-password is intentionally excluded — it is reached only via a single-use emailed token. - Add an explicit-render Turnstile widget component. Tokens are single-use, so each form resets the challenge after a failed submit; submit stays disabled until a token is held. - Read the site key server-side and pass it down as a prop, so rotating it does not require a rebuild. - Fail fast in production when TURNSTILE_SECRET_KEY is missing, and when a secret is set without a site key (that combination would demand a token no form can produce, locking every user out). - Pass a throwaway secret during `next build` in the Dockerfile, mirroring the existing BETTER_AUTH_SECRET treatment, so image builds don't need it. CSP fixes in middleware (these blocked Turnstile entirely): - Add frame-src for challenges.cloudflare.com. Without it the widget's iframe fell back to default-src 'self' and was blocked outright. - Allow 'unsafe-eval' and websockets in DEVELOPMENT only. `next dev` compiles with eval(), so the strict policy threw EvalError and killed hydration — no client JS ran at all, which also meant form submit handlers never fired. Production policy is unchanged and still strict. Also included (concurrent work in the tree): - Admin organizations pages and lib/admin/orgs. - Episode moderation migration, SEO metadata (sitemap, robots, JSON-LD, OG/Twitter images, manifest), Umami analytics, not-found page. Local dev database: docker-compose.dev.yml provisions Postgres 18 on port 5443 (5432-5442 are in use by other local projects). Note: `npx tsc --noEmit` currently fails in app/(app)/team/page.tsx — an `invitations` prop the component does not accept. This predates the commit and will fail `next build` until fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
35379212fb
commit
3e9ba07175
+24
-6
@@ -33,12 +33,30 @@ export async function getEpisodeStatusCounts() {
|
||||
return groups.map((g) => ({ status: g.status as string, count: g._count }));
|
||||
}
|
||||
|
||||
export async function getModerationQueue() {
|
||||
return prisma.contentFlag.findMany({
|
||||
where: { status: "open" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { episode: { select: { id: true, title: true } } },
|
||||
});
|
||||
export const MODERATION_PAGE_SIZE = 25;
|
||||
|
||||
/**
|
||||
* Open content flags, newest first.
|
||||
*
|
||||
* Paginated: this used to load every open flag, so a moderation backlog grew
|
||||
* the page (and its memory cost) without bound. Every other admin list is
|
||||
* paginated for the same reason.
|
||||
*/
|
||||
export async function getModerationQueue(params: { page?: number; pageSize?: number } = {}) {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = params.pageSize ?? MODERATION_PAGE_SIZE;
|
||||
const where = { status: "open" };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.contentFlag.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { episode: { select: { id: true, title: true, userId: true } } },
|
||||
}),
|
||||
prisma.contentFlag.count({ where }),
|
||||
]);
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
export async function listWebhookEvents(params: {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
|
||||
export const ORGS_PAGE_SIZE = 25;
|
||||
|
||||
export interface AdminOrgRow {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
memberCount: number;
|
||||
seats: number;
|
||||
plan: string;
|
||||
status: string | null;
|
||||
whiteLabel: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function orderBy(sort?: string): Prisma.OrganizationOrderByWithRelationInput {
|
||||
const [key, dir] = (sort ?? "createdAt.desc").split(".");
|
||||
const d = dir === "asc" ? "asc" : "desc";
|
||||
if (key === "name") return { name: d };
|
||||
return { createdAt: d };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization list for the admin console.
|
||||
*
|
||||
* Subscriptions are keyed by `referenceId`, which holds either a user id or an
|
||||
* organization id — so an org's plan is looked up by its own id, in one batched
|
||||
* query rather than per row.
|
||||
*/
|
||||
export async function listOrganizations(params: {
|
||||
search?: string;
|
||||
plan?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ rows: AdminOrgRow[]; total: number }> {
|
||||
const pageSize = params.pageSize ?? ORGS_PAGE_SIZE;
|
||||
const where: Prisma.OrganizationWhereInput = {};
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search, mode: "insensitive" } },
|
||||
{ slug: { contains: params.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const [orgs, total] = await Promise.all([
|
||||
prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: orderBy(params.sort),
|
||||
skip: (params.page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
branding: { select: { removePoweredBy: true } },
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
}),
|
||||
prisma.organization.count({ where }),
|
||||
]);
|
||||
|
||||
const subs = await prisma.subscription.findMany({
|
||||
where: {
|
||||
referenceId: { in: orgs.map((o) => o.id) },
|
||||
status: { in: ["active", "trialing", "past_due"] },
|
||||
},
|
||||
select: { referenceId: true, plan: true, status: true, seats: true },
|
||||
});
|
||||
const subByOrg = new Map(subs.map((s) => [s.referenceId, s]));
|
||||
|
||||
const rows = orgs
|
||||
.map((o) => {
|
||||
const sub = subByOrg.get(o.id);
|
||||
const planKey = (sub?.plan ?? "free") as PlanKey;
|
||||
return {
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
memberCount: o._count.members,
|
||||
seats: sub?.seats ?? PLANS[planKey]?.limits.seats ?? 1,
|
||||
plan: planKey,
|
||||
status: sub?.status ?? null,
|
||||
whiteLabel: o.branding?.removePoweredBy ?? false,
|
||||
createdAt: o.createdAt,
|
||||
};
|
||||
})
|
||||
// Plan lives on the subscription table, not the org row, so this filter is
|
||||
// applied after the join rather than in the SQL WHERE.
|
||||
.filter((r) => !params.plan || r.plan === params.plan);
|
||||
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
export interface AdminOrgDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
createdAt: Date;
|
||||
plan: string;
|
||||
status: string | null;
|
||||
seats: number;
|
||||
members: {
|
||||
memberId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
joinedAt: Date;
|
||||
}[];
|
||||
invitations: { id: string; email: string; role: string | null; expiresAt: Date }[];
|
||||
branding: {
|
||||
brandName: string | null;
|
||||
primaryColor: string | null;
|
||||
logoUrl: string | null;
|
||||
removePoweredBy: boolean;
|
||||
customDomain: string | null;
|
||||
} | null;
|
||||
episodeCount: number;
|
||||
recentEpisodes: { id: string; title: string; status: string; createdAt: Date }[];
|
||||
}
|
||||
|
||||
/** Full detail for one organization, or null when it does not exist. */
|
||||
export async function getOrgDetail(orgId: string): Promise<AdminOrgDetail | null> {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: orgId },
|
||||
include: {
|
||||
branding: true,
|
||||
members: {
|
||||
include: { user: { select: { id: true, name: true, email: true, banned: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
invitations: {
|
||||
where: { status: "pending" },
|
||||
select: { id: true, email: true, role: true, expiresAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!org) return null;
|
||||
|
||||
const [sub, episodeCount, recentEpisodes] = await Promise.all([
|
||||
prisma.subscription.findFirst({
|
||||
where: { referenceId: org.id, status: { in: ["active", "trialing", "past_due"] } },
|
||||
select: { plan: true, status: true, seats: true },
|
||||
}),
|
||||
prisma.episode.count({ where: { organizationId: org.id } }),
|
||||
prisma.episode.findMany({
|
||||
where: { organizationId: org.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { id: true, title: true, status: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const planKey = (sub?.plan ?? "free") as PlanKey;
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
createdAt: org.createdAt,
|
||||
plan: planKey,
|
||||
status: sub?.status ?? null,
|
||||
seats: sub?.seats ?? PLANS[planKey]?.limits.seats ?? 1,
|
||||
members: org.members.map((m) => ({
|
||||
memberId: m.id,
|
||||
userId: m.user.id,
|
||||
name: m.user.name,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
banned: !!m.user.banned,
|
||||
joinedAt: m.createdAt,
|
||||
})),
|
||||
invitations: org.invitations.map((i) => ({
|
||||
id: i.id,
|
||||
email: i.email,
|
||||
role: i.role,
|
||||
expiresAt: i.expiresAt,
|
||||
})),
|
||||
branding: org.branding
|
||||
? {
|
||||
brandName: org.branding.brandName,
|
||||
primaryColor: org.branding.primaryColor,
|
||||
logoUrl: org.branding.logoUrl,
|
||||
removePoweredBy: org.branding.removePoweredBy,
|
||||
customDomain: org.branding.customDomain,
|
||||
}
|
||||
: null,
|
||||
episodeCount,
|
||||
recentEpisodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Umami (self-hosted) analytics configuration and payload redaction.
|
||||
*
|
||||
* Two deliberate choices are encoded here:
|
||||
*
|
||||
* 1. The tracker is served same-origin through a rewrite (see `ANALYTICS_PROXY_PATH`
|
||||
* and next.config.mjs) rather than from the Umami host directly. That keeps the
|
||||
* strict CSP in middleware.ts untouched — `script-src`/`connect-src` stay
|
||||
* `'self'` — and stops content blockers from dropping the request.
|
||||
*
|
||||
* 2. Nothing that identifies a user's content ever reaches the analytics database.
|
||||
* See `redactUrl` / `redactTitle`.
|
||||
*/
|
||||
|
||||
/** Same-origin prefix the tracker and its beacon are proxied through. */
|
||||
export const ANALYTICS_PROXY_PATH = "/_a";
|
||||
|
||||
export interface UmamiConfig {
|
||||
/** Origin of the Umami instance, e.g. "https://analytics.example.com". */
|
||||
hostUrl: string;
|
||||
websiteId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Umami config from the environment. Returns null when either value
|
||||
* is absent, which disables analytics entirely (the default in development).
|
||||
*
|
||||
* Both are read at build time for statically rendered pages, so they must be
|
||||
* present as build args in Docker — see the Dockerfile.
|
||||
*/
|
||||
export function umamiConfig(): UmamiConfig | null {
|
||||
const hostUrl = process.env.UMAMI_HOST_URL?.trim().replace(/\/+$/, "");
|
||||
const websiteId = process.env.UMAMI_WEBSITE_ID?.trim();
|
||||
if (!hostUrl || !websiteId) return null;
|
||||
return { hostUrl, websiteId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Path segments that are opaque identifiers rather than page names.
|
||||
*
|
||||
* Sending them would put episode IDs, series IDs and — worst — unlisted share
|
||||
* IDs into the analytics database. Share links are secret by design, so leaking
|
||||
* one there effectively publishes it. Collapsing them also makes the reports
|
||||
* useful: one "/episodes/[id]" row instead of thousands of singletons.
|
||||
*/
|
||||
export function redactPath(pathname: string): string {
|
||||
const seg = pathname.split("/").filter(Boolean);
|
||||
|
||||
// "/episodes/new" is a real page; "/episodes/<cuid>" is not.
|
||||
if (seg[0] === "episodes" && seg[1] && seg[1] !== "new") seg[1] = "[id]";
|
||||
else if (seg[0] === "series" && seg[1]) seg[1] = "[id]";
|
||||
else if (seg[0] === "p" && seg[1]) seg[1] = "[shareId]";
|
||||
else if (seg[0] === "admin" && seg[1] === "users" && seg[2]) seg[2] = "[id]";
|
||||
|
||||
return `/${seg.join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact a full URL: collapse identifier segments and drop the query string and
|
||||
* hash outright.
|
||||
*
|
||||
* The query string is the sharper hazard — /reset-password carries a live
|
||||
* password-reset token in `?token=`, and /sign-in carries the visitor's intended
|
||||
* destination in `?redirect=`. `data-exclude-search` already strips it; this is
|
||||
* the second line of defence, and it also covers the referrer field, which the
|
||||
* tracker fills from the previous URL.
|
||||
*/
|
||||
export function redactUrl(raw: string): string {
|
||||
if (!raw) return raw;
|
||||
try {
|
||||
// Bare paths are the common case; the base only matters for absolute URLs.
|
||||
const url = new URL(raw, "http://localhost");
|
||||
const path = redactPath(url.pathname);
|
||||
return url.origin === "http://localhost" && !raw.startsWith("http")
|
||||
? path
|
||||
: `${url.origin}${path}`;
|
||||
} catch {
|
||||
// Unparseable — return just the redacted path portion, never the raw value.
|
||||
return redactPath(raw.split(/[?#]/)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a path carries user content that its <title> would expose. */
|
||||
function isRedacted(pathname: string): boolean {
|
||||
return redactPath(pathname) !== pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page titles on detail routes are the user's own content — `generateMetadata`
|
||||
* returns the episode or series title verbatim. Swap those for the route name so
|
||||
* the reports stay readable without storing anyone's content.
|
||||
*/
|
||||
export function redactTitle(title: string, url: string): string {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(url, "http://localhost").pathname;
|
||||
} catch {
|
||||
pathname = url.split(/[?#]/)[0];
|
||||
}
|
||||
return isRedacted(pathname) ? `${redactPath(pathname)} · Podcast Distribution AI` : title;
|
||||
}
|
||||
|
||||
/** The Umami event payload we are allowed to inspect and rewrite. */
|
||||
export interface UmamiPayload {
|
||||
url?: string;
|
||||
referrer?: string;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Apply every redaction rule to one outgoing event payload. */
|
||||
export function redactPayload(payload: UmamiPayload): UmamiPayload {
|
||||
const url = payload.url ? redactUrl(payload.url) : payload.url;
|
||||
return {
|
||||
...payload,
|
||||
...(url !== undefined ? { url } : {}),
|
||||
...(payload.referrer ? { referrer: redactUrl(payload.referrer) } : {}),
|
||||
...(payload.title && payload.url
|
||||
? { title: redactTitle(payload.title, payload.url) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
+134
-1
@@ -1,10 +1,30 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { admin, organization } from "better-auth/plugins";
|
||||
import { admin, captcha, organization } from "better-auth/plugins";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { createAuthMiddleware, APIError } from "better-auth/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendEmail, emailLayout } from "@/lib/email";
|
||||
|
||||
/**
|
||||
* Enforce the `signups_enabled` kill-switch on the REAL registration path.
|
||||
*
|
||||
* The sign-up page also checks this flag, but that only hides the form — a
|
||||
* client can still POST /api/auth/sign-up/email directly. This is the actual
|
||||
* boundary. `@/lib/flags` is imported dynamically because it pulls in
|
||||
* `server-only`, which throws when lib/* is loaded outside Next's RSC bundler
|
||||
* (the worker runs under plain tsx); a lazy import keeps that cost off the
|
||||
* module graph until an actual sign-up is attempted.
|
||||
*/
|
||||
async function assertSignupsEnabled(): Promise<void> {
|
||||
const { isFlagEnabled } = await import("@/lib/flags");
|
||||
if (!(await isFlagEnabled("signups_enabled"))) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Sign-ups are currently paused. Please check back soon.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
|
||||
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
@@ -24,6 +44,81 @@ if (secretIsWeak && process.env.NODE_ENV === "production") {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard impersonation — the single most powerful action in the platform.
|
||||
*
|
||||
* Two problems with leaving this to the better-auth admin plugin alone:
|
||||
* 1. It is the ONLY admin mutation with no entry in our audit log, so a
|
||||
* compromised admin account can read every user's data untraceably.
|
||||
* 2. Nothing stops one admin impersonating another, which is lateral
|
||||
* movement between privileged accounts rather than support access.
|
||||
*
|
||||
* Enforcing it here (rather than in the server action) means a direct POST to
|
||||
* /api/auth/admin/impersonate-user is covered too.
|
||||
*/
|
||||
async function guardImpersonation(headers: Headers, targetUserId: unknown): Promise<void> {
|
||||
if (typeof targetUserId !== "string" || !targetUserId) {
|
||||
throw new APIError("BAD_REQUEST", { message: "A target user id is required." });
|
||||
}
|
||||
// `auth` is referenced lazily: this runs per-request, long after module init.
|
||||
const session = await auth.api.getSession({ headers });
|
||||
if (!session || session.user.role !== "admin") {
|
||||
throw new APIError("FORBIDDEN", { message: "Not allowed." });
|
||||
}
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true, email: true },
|
||||
});
|
||||
if (!target) throw new APIError("NOT_FOUND", { message: "User not found." });
|
||||
if (target.role === "admin") {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Admins cannot impersonate other admins.",
|
||||
});
|
||||
}
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: session.user.id,
|
||||
actorType: "admin",
|
||||
action: "user.impersonate",
|
||||
target: target.id,
|
||||
metadata: { targetEmail: target.email },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile — bot protection on the public auth endpoints.
|
||||
*
|
||||
* The plugin verifies the `x-captcha-response` header server-side against
|
||||
* Cloudflare's siteverify API BEFORE the handler runs, so it is the real
|
||||
* boundary: hiding/!rendering the widget client-side proves nothing, and a
|
||||
* bot POSTing straight to /api/auth/sign-in/email is rejected here.
|
||||
*
|
||||
* Enabled whenever TURNSTILE_SECRET_KEY is set. Required in production so a
|
||||
* misconfigured deploy cannot silently ship with bot protection switched off
|
||||
* (same fail-fast posture as BETTER_AUTH_SECRET above); left optional in
|
||||
* dev/test so local work stays frictionless without Cloudflare keys.
|
||||
*/
|
||||
const turnstileSecretKey = process.env.TURNSTILE_SECRET_KEY?.trim();
|
||||
const turnstileEnabled = !!turnstileSecretKey;
|
||||
if (!turnstileEnabled && process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"TURNSTILE_SECRET_KEY must be set in production — Turnstile guards the sign-in, sign-up and password-reset endpoints."
|
||||
);
|
||||
}
|
||||
// Guard the asymmetric misconfiguration: with a secret but no site key the server
|
||||
// would demand a captcha token that no form can produce, locking every user out
|
||||
// of sign-in. Fail the boot (and the production build) instead of shipping that.
|
||||
if (
|
||||
turnstileEnabled &&
|
||||
!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() &&
|
||||
process.env.NODE_ENV === "production"
|
||||
) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_TURNSTILE_SITE_KEY must be set alongside TURNSTILE_SECRET_KEY — without it the auth forms cannot render the Turnstile widget and every sign-in would be rejected."
|
||||
);
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Podcast Distribution AI",
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
@@ -93,6 +188,30 @@ export const auth = betterAuth({
|
||||
accountLinking: { enabled: true, trustedProviders: ["google"] },
|
||||
},
|
||||
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path === "/sign-up/email") await assertSignupsEnabled();
|
||||
if (ctx.path === "/admin/impersonate-user") {
|
||||
await guardImpersonation(ctx.headers ?? new Headers(), (ctx.body as { userId?: unknown } | undefined)?.userId);
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
// Catch-all: a user row is only ever created by registration, so this also
|
||||
// covers first-time Google sign-in, which does not hit /sign-up/email.
|
||||
// (scripts/create-admin.ts and scripts/seed-demo.ts write via Prisma directly
|
||||
// and are intentionally unaffected.)
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
await assertSignupsEnabled();
|
||||
return { data: user };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
||||
organization({
|
||||
@@ -100,6 +219,20 @@ export const auth = betterAuth({
|
||||
// Agency seat cap is enforced in app logic against the subscription's seat count.
|
||||
membershipLimit: 5,
|
||||
}),
|
||||
// Bot protection. Endpoints listed explicitly rather than relying on the
|
||||
// plugin default so adding one is a deliberate, reviewable change.
|
||||
// `/reset-password` is intentionally absent: it is reached only with a
|
||||
// single-use token emailed to a verified address, and challenging it would
|
||||
// break the emailed-link flow.
|
||||
...(turnstileEnabled
|
||||
? [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: turnstileSecretKey!,
|
||||
endpoints: ["/sign-in/email", "/sign-up/email", "/request-password-reset"],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// Must remain last: lets Server Actions / route handlers set auth cookies.
|
||||
nextCookies(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Cloudflare Turnstile site key, resolved server-side.
|
||||
*
|
||||
* Read at request time and passed down to the auth forms as a prop (the same
|
||||
* pattern `googleEnabled` uses) rather than referenced inside a client
|
||||
* component. `NEXT_PUBLIC_*` values referenced in client code are inlined at
|
||||
* BUILD time, which would bake the key into the Docker image and require a
|
||||
* rebuild to rotate it; reading it here keeps it a runtime concern.
|
||||
*
|
||||
* Null when unset — the forms then render without a challenge. The server-side
|
||||
* enforcement in `lib/auth/auth.ts` is keyed off TURNSTILE_SECRET_KEY, which is
|
||||
* mandatory in production.
|
||||
*/
|
||||
export function getTurnstileSiteKey(): string | null {
|
||||
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() || null;
|
||||
}
|
||||
+21
-1
@@ -24,7 +24,24 @@ export function isPaypalConfigured(): boolean {
|
||||
return !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
// PayPal client-credentials tokens are valid for hours. Minting a fresh one per
|
||||
// call turned every inbound webhook into two outbound PayPal requests, which an
|
||||
// anonymous caller could amplify until our PayPal rate limits were exhausted.
|
||||
// Cache it in-process and refresh a minute before expiry.
|
||||
let tokenCache: { token: string; expiresAt: number } | null = null;
|
||||
let tokenInFlight: Promise<string> | null = null;
|
||||
|
||||
async function accessToken(): Promise<string> {
|
||||
if (tokenCache && Date.now() < tokenCache.expiresAt) return tokenCache.token;
|
||||
// Collapse concurrent misses onto a single token request.
|
||||
if (tokenInFlight) return tokenInFlight;
|
||||
tokenInFlight = fetchAccessToken().finally(() => {
|
||||
tokenInFlight = null;
|
||||
});
|
||||
return tokenInFlight;
|
||||
}
|
||||
|
||||
async function fetchAccessToken(): Promise<string> {
|
||||
const { id, secret } = creds();
|
||||
const res = await fetch(`${base()}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
@@ -35,7 +52,10 @@ async function accessToken(): Promise<string> {
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal token error ${res.status}`);
|
||||
const data = (await res.json()) as { access_token: string };
|
||||
const data = (await res.json()) as { access_token: string; expires_in?: number };
|
||||
// Default to 5 minutes if PayPal omits expires_in; refresh 60s early.
|
||||
const ttlSec = Math.max((data.expires_in ?? 300) - 60, 60);
|
||||
tokenCache = { token: data.access_token, expiresAt: Date.now() + ttlSec * 1000 };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,13 @@ async function syncStripeSubscription(
|
||||
const item = sub.items.data[0];
|
||||
const priceId = item?.price?.id;
|
||||
const mapped = priceId ? planFromStripePrice(priceId) : null;
|
||||
// metadata.plan is attacker-influenceable; only honour it if it's a known plan.
|
||||
// The price-mapping fallback (derived from the real Stripe price) is preferred.
|
||||
const plan: PlanKey = planFromMetadata(metadata?.plan) ?? mapped?.plan ?? "free";
|
||||
// The PRICE is authoritative: it is what the customer is actually charged, and
|
||||
// Stripe updates it on every plan change. metadata.plan is only written once at
|
||||
// checkout (lib/billing/stripe.ts) and is NOT rewritten when a customer switches
|
||||
// plans in the Billing Portal — trusting it first would let a downgraded customer
|
||||
// keep the higher tier's entitlements. Metadata is a fallback for the case where
|
||||
// a price is missing or unmapped, and is still narrowed to a known PlanKey.
|
||||
const plan: PlanKey = mapped?.plan ?? planFromMetadata(metadata?.plan) ?? "free";
|
||||
const referenceId = metadata?.subjectId || sub.metadata?.subjectId;
|
||||
if (!referenceId || referenceId.trim() === "") {
|
||||
console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id);
|
||||
|
||||
@@ -41,6 +41,36 @@ export async function finishJob(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic, user-safe copy for a failed generation.
|
||||
*
|
||||
* Episode.errorMessage is rendered straight to the end user (see
|
||||
* app/(app)/episodes/[id]/page.tsx) and streamed over SSE, so it must never
|
||||
* carry raw pipeline text: ffmpeg stderr leaks internal binary paths, and an
|
||||
* ElevenLabs/OpenAI response body leaks our upstream quota and billing state.
|
||||
*/
|
||||
export const GENERIC_FAILURE =
|
||||
"Generation failed. Our team has been notified — please try again shortly.";
|
||||
|
||||
/**
|
||||
* Record a terminal pipeline failure.
|
||||
*
|
||||
* The RAW error is preserved on GenerationJob.error, which is only ever surfaced
|
||||
* in the admin UI (lib/admin/ops.ts), so debuggability is unchanged. The episode
|
||||
* itself gets the generic message above.
|
||||
*/
|
||||
export async function failEpisode(
|
||||
episodeId: string,
|
||||
rawError: string,
|
||||
userMessage: string = GENERIC_FAILURE
|
||||
): Promise<void> {
|
||||
await prisma.generationJob.updateMany({
|
||||
where: { episodeId, status: { in: ["queued", "running"] } },
|
||||
data: { status: "failed", error: rawError, finishedAt: new Date() },
|
||||
});
|
||||
await setEpisodeStatus(episodeId, "FAILED", { errorMessage: userMessage });
|
||||
}
|
||||
|
||||
/** Terminal episode states — used by the UI/SSE to stop polling. */
|
||||
export function isTerminal(status: EpisodeStatus): boolean {
|
||||
return status === "READY" || status === "FAILED";
|
||||
|
||||
@@ -87,4 +87,7 @@ export const LIMITS = {
|
||||
read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key
|
||||
stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user
|
||||
publicMedia: { points: 120, durationSec: 60 }, // anon audio/cover (Range) reqs / min / IP
|
||||
seriesPlan: { points: 5, durationSec: 3600 }, // season plans / hr / user (uncapped GPT-4o call)
|
||||
export: { points: 10, durationSec: 60 }, // zip exports / min / user (buffers MP3 in memory)
|
||||
webhook: { points: 60, durationSec: 60 }, // anon webhook posts / min / IP
|
||||
} as const;
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { PLANS, PLAN_ORDER } from "@/lib/billing/plans";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL, absoluteUrl } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* schema.org JSON-LD builders.
|
||||
*
|
||||
* Everything is emitted into a single `@graph` per page with stable `@id`s, so
|
||||
* nodes can reference each other (e.g. the software product is `publisher`-ed by
|
||||
* the organization) instead of being repeated on every route.
|
||||
*/
|
||||
|
||||
const ORG_ID = `${SITE_URL}/#organization`;
|
||||
const SITE_ID = `${SITE_URL}/#website`;
|
||||
const APP_ID = `${SITE_URL}/#software`;
|
||||
|
||||
export function organizationSchema() {
|
||||
return {
|
||||
"@type": "Organization",
|
||||
"@id": ORG_ID,
|
||||
name: SITE_NAME,
|
||||
url: absoluteUrl("/"),
|
||||
description: SITE_DESCRIPTION,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: absoluteUrl("/logo-dark.png"),
|
||||
contentUrl: absoluteUrl("/logo-dark.png"),
|
||||
},
|
||||
contactPoint: [
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
contactType: "customer support",
|
||||
email: "support@podcastdistributionai.com",
|
||||
availableLanguage: ["English"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteSchema() {
|
||||
return {
|
||||
"@type": "WebSite",
|
||||
"@id": SITE_ID,
|
||||
url: absoluteUrl("/"),
|
||||
name: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
publisher: { "@id": ORG_ID },
|
||||
inLanguage: "en",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The product itself, with one Offer per plan. Prices come from the plan catalog
|
||||
* so the markup can never drift from what the pricing page actually charges.
|
||||
*/
|
||||
export function softwareApplicationSchema() {
|
||||
return {
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": APP_ID,
|
||||
name: SITE_NAME,
|
||||
url: absoluteUrl("/"),
|
||||
description: SITE_DESCRIPTION,
|
||||
applicationCategory: "MultimediaApplication",
|
||||
applicationSubCategory: "Podcast production",
|
||||
operatingSystem: "Web browser",
|
||||
publisher: { "@id": ORG_ID },
|
||||
featureList: [
|
||||
"AI podcast script generation",
|
||||
"Realistic multi-voice text-to-speech",
|
||||
"AI-generated episode cover art",
|
||||
"Content repurposing to blog and social posts",
|
||||
"Series and season generator",
|
||||
"13+ languages",
|
||||
"Team workspace and white-label branding",
|
||||
"REST API access",
|
||||
],
|
||||
offers: PLAN_ORDER.map((key) => {
|
||||
const plan = PLANS[key];
|
||||
return {
|
||||
"@type": "Offer",
|
||||
name: `${plan.name} plan`,
|
||||
description: plan.tagline,
|
||||
price: (plan.priceMonthly / 100).toFixed(2),
|
||||
priceCurrency: "USD",
|
||||
category: plan.priceMonthly === 0 ? "Free" : "Subscription",
|
||||
url: absoluteUrl("/pricing"),
|
||||
availability: "https://schema.org/InStock",
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A single page node, linked to the site — gives each URL an explicit identity. */
|
||||
export function webPageSchema({
|
||||
path,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
path: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}) {
|
||||
return {
|
||||
"@type": "WebPage",
|
||||
"@id": `${absoluteUrl(path)}#webpage`,
|
||||
url: absoluteUrl(path),
|
||||
name,
|
||||
description,
|
||||
isPartOf: { "@id": SITE_ID },
|
||||
about: { "@id": ORG_ID },
|
||||
inLanguage: "en",
|
||||
};
|
||||
}
|
||||
|
||||
/** Breadcrumbs from Home to the current page. Pass the trail without Home. */
|
||||
export function breadcrumbSchema(trail: { name: string; path: string }[]) {
|
||||
return {
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": `${absoluteUrl(trail[trail.length - 1]?.path ?? "/")}#breadcrumb`,
|
||||
itemListElement: [{ name: "Home", path: "/" }, ...trail].map((item, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: item.name,
|
||||
item: absoluteUrl(item.path),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function faqPageSchema(items: { q: string; a: string }[]) {
|
||||
return {
|
||||
"@type": "FAQPage",
|
||||
"@id": `${absoluteUrl("/faq")}#faq`,
|
||||
mainEntity: items.map(({ q, a }) => ({
|
||||
"@type": "Question",
|
||||
name: q,
|
||||
acceptedAnswer: { "@type": "Answer", text: a },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap nodes into the `@graph` envelope every page emits. */
|
||||
export function graph(...nodes: Record<string, unknown>[]) {
|
||||
return { "@context": "https://schema.org", "@graph": nodes };
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* Central SEO configuration — the single source of truth for the canonical
|
||||
* origin, brand naming, and shared metadata construction. Every page that needs
|
||||
* a canonical URL, an Open Graph card, or JSON-LD should build it from here so
|
||||
* the values can never drift between routes.
|
||||
*/
|
||||
|
||||
export const SITE_NAME = "Podcast Distribution AI";
|
||||
|
||||
export const SITE_TAGLINE = "From topic idea to published podcast in minutes";
|
||||
|
||||
export const SITE_DESCRIPTION =
|
||||
"Podcast Distribution AI is an all-in-one AI platform that writes your script, records realistic multi-voice audio, and designs cover art — turning a topic into a finished episode in minutes.";
|
||||
|
||||
/** Brand accent (--brand, light theme) as a hex literal for OG image rendering. */
|
||||
export const BRAND_HEX = "#e65000";
|
||||
|
||||
/**
|
||||
* The canonical, absolute origin of the deployment (no trailing slash).
|
||||
*
|
||||
* Canonical tags, sitemap entries and OG URLs are only correct if this is the
|
||||
* real public origin, so a production build refuses to silently fall back to
|
||||
* localhost: an unset NEXT_PUBLIC_APP_URL would otherwise ship canonicals
|
||||
* pointing at http://localhost:3000 and de-index the whole site.
|
||||
*/
|
||||
function resolveSiteUrl(): string {
|
||||
const raw = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||
|
||||
if (raw) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
// Strip any trailing slash so `${SITE_URL}${path}` never doubles up.
|
||||
return url.origin;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`[seo] NEXT_PUBLIC_APP_URL is not a valid absolute URL: ${JSON.stringify(raw)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"[seo] NEXT_PUBLIC_APP_URL must be set in production — it is the canonical origin used for canonical tags, the sitemap, robots.txt and Open Graph URLs."
|
||||
);
|
||||
}
|
||||
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
export const SITE_URL = resolveSiteUrl();
|
||||
|
||||
/** Resolve a root-relative path to its absolute canonical URL. */
|
||||
export function absoluteUrl(path = "/"): string {
|
||||
return path === "/" ? `${SITE_URL}/` : `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The site-wide social card (app/opengraph-image.tsx).
|
||||
*
|
||||
* It has to be restated on every page: Next merges `metadata` shallowly, so a
|
||||
* page that defines its own `openGraph` object replaces the root layout's
|
||||
* entirely — including the images the file convention contributed. Leaving this
|
||||
* out silently drops og:image from every page but the homepage.
|
||||
*/
|
||||
const DEFAULT_OG_IMAGE = {
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${SITE_NAME} — from a topic idea to a finished podcast in minutes`,
|
||||
};
|
||||
|
||||
interface PageMetaOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Root-relative path, e.g. "/pricing". Used for the canonical + OG URL. */
|
||||
path: string;
|
||||
/** Keep the page out of the index (authed surfaces, share links, utilities). */
|
||||
noIndex?: boolean;
|
||||
/** Override the social card image path (defaults to the site-wide OG card). */
|
||||
image?: string;
|
||||
type?: "website" | "article";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete, canonical-tagged Metadata object for a page. Next.js merges
|
||||
* this over the root layout's metadata, so only the differing fields are set.
|
||||
*/
|
||||
export function pageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
noIndex = false,
|
||||
image,
|
||||
type = "website",
|
||||
}: PageMetaOptions): Metadata {
|
||||
const url = absoluteUrl(path);
|
||||
const images = image ? [{ url: image }] : [DEFAULT_OG_IMAGE];
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: `${title} · ${SITE_NAME}`,
|
||||
description,
|
||||
url,
|
||||
siteName: SITE_NAME,
|
||||
type,
|
||||
locale: "en_US",
|
||||
images,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${title} · ${SITE_NAME}`,
|
||||
description,
|
||||
images,
|
||||
},
|
||||
...(noIndex
|
||||
? { robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Metadata for surfaces that must never be indexed (app, admin, auth). */
|
||||
export const NO_INDEX: Metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
nocache: true,
|
||||
googleBot: { index: false, follow: false, noimageindex: true },
|
||||
},
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"january", "february", "march", "april", "may", "june",
|
||||
"july", "august", "september", "october", "november", "december",
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert a human "Month D, YYYY" stamp (as rendered on the legal pages) into a
|
||||
* calendar-only ISO-8601 date for schema.org `dateModified`.
|
||||
*
|
||||
* Parsed explicitly rather than via `new Date(...)` so the result can never be
|
||||
* shifted a day by the server's timezone. Returns undefined for anything it does
|
||||
* not recognise, so callers omit the property instead of emitting a bad date.
|
||||
*/
|
||||
export function toIsoDate(display: string): string | undefined {
|
||||
const match = /^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/.exec(display.trim());
|
||||
if (!match) return undefined;
|
||||
|
||||
const month = MONTHS.indexOf(match[1].toLowerCase());
|
||||
if (month < 0) return undefined;
|
||||
|
||||
const day = Number(match[2]);
|
||||
if (day < 1 || day > 31) return undefined;
|
||||
|
||||
return `${match[3]}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user