Security & robustness hardening pass
Cross-cutting input-validation, isolation, and DoS-resistance fixes across the app, API, billing, queue, and infra layers. - Runtime validation (zod) for client-supplied admin actions (role/plan/ limits), series generation index, and all pg-boss queue payloads - Auth: require email verification before sign-in; reject weak/placeholder/ short BETTER_AUTH_SECRET in production - Billing: sanitize Stripe/PayPal errors (log server-side, generic to client); race-safe subscription upsert; only count "processed" webhook events as handled; verify org membership in getEffectivePlan to block plan escalation - Series generation: reserve usage up front and refund on failure; bill the owning org, not the caller's active org - Injection defenses: HTML-escape user fields in emails, strip CR/LF from subject/recipient, validate ElevenLabs voiceId before URL interpolation - Media routes: stream off disk instead of buffering whole files; rate-limit anonymous public audio/cover endpoints by client IP
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { getServerSession } from "@/lib/auth/guards";
|
import { getServerSession } from "@/lib/auth/guards";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -43,8 +44,13 @@ export async function banUserAction(userId: string, ban: boolean): Promise<{ ok:
|
|||||||
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
|
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
|
||||||
const s = await adminSession();
|
const s = await adminSession();
|
||||||
if (!s) return { ok: false, error: "Not allowed." };
|
if (!s) return { ok: false, error: "Not allowed." };
|
||||||
await prisma.user.update({ where: { id: userId }, data: { role } });
|
// Don't trust the TS union at runtime — reject anything outside the allowed set.
|
||||||
await audit(s.user.id, "user.role", userId, { role });
|
const parsedRole = z.enum(["admin", "user"]).safeParse(role);
|
||||||
|
if (!parsedRole.success) return { ok: false, error: "Invalid role." };
|
||||||
|
// Self-guard: an admin must not demote/remove their own admin role and lock out.
|
||||||
|
if (userId === s.user.id) return { ok: false, error: "You can't change your own role." };
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: { role: parsedRole.data } });
|
||||||
|
await audit(s.user.id, "user.role", userId, { role: parsedRole.data });
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,16 @@ export async function compPlanAction(
|
|||||||
const s = await adminSession();
|
const s = await adminSession();
|
||||||
if (!s) return { ok: false, error: "Not allowed." };
|
if (!s) return { ok: false, error: "Not allowed." };
|
||||||
|
|
||||||
|
// Validate the client-supplied plan/interval at runtime, not just via the TS union.
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
plan: z.enum(["creator", "pro", "agency"]),
|
||||||
|
interval: z.enum(["month", "year"]),
|
||||||
|
})
|
||||||
|
.safeParse({ plan, interval });
|
||||||
|
if (!parsed.success) return { ok: false, error: "Invalid plan or interval." };
|
||||||
|
({ plan, interval } = parsed.data);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS);
|
const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS);
|
||||||
|
|
||||||
@@ -309,26 +325,65 @@ export interface PlanUpdateInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A monthly metric cap: a finite integer that is either a real non-negative cap
|
||||||
|
// or UNLIMITED (-1). Mirrors the canonical PlanLimits shape in lib/billing/plans.ts.
|
||||||
|
const quotaCap = z.number().int().gte(-1);
|
||||||
|
// Counts that can't be "unlimited": must be finite non-negative integers.
|
||||||
|
const nonNegInt = z.number().int().nonnegative();
|
||||||
|
|
||||||
|
// Validate the limits object against the known keys only — `.strict()` rejects
|
||||||
|
// extra/unknown keys so a client can't mass-assign arbitrary JSON into the column.
|
||||||
|
const planLimitsSchema = z
|
||||||
|
.object({
|
||||||
|
script: quotaCap,
|
||||||
|
audio: quotaCap,
|
||||||
|
art: quotaCap,
|
||||||
|
repurpose: quotaCap,
|
||||||
|
seats: nonNegInt,
|
||||||
|
maxEpisodeMinutes: nonNegInt,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const planUpdateSchema = z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
priceMonthly: z.number().int().nonnegative(),
|
||||||
|
priceYearly: z.number().int().nonnegative(),
|
||||||
|
limits: planLimitsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
/** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */
|
/** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */
|
||||||
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
|
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
|
||||||
const s = await adminSession();
|
const s = await adminSession();
|
||||||
if (!s) return { ok: false, error: "Not allowed." };
|
if (!s) return { ok: false, error: "Not allowed." };
|
||||||
|
|
||||||
const existing = await prisma.plan.findUnique({ where: { key } });
|
// Validate ALL client args (key + prices + limits) before touching the DB so
|
||||||
|
// nothing unvalidated reaches the JSON column. Prices arrive as cents; round
|
||||||
|
// to ints first to keep the existing tolerance for fractional input.
|
||||||
|
const parsed = planUpdateSchema.safeParse({
|
||||||
|
key,
|
||||||
|
priceMonthly: Math.round(input.priceMonthly),
|
||||||
|
priceYearly: Math.round(input.priceYearly),
|
||||||
|
limits: input.limits,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid plan input." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.plan.findUnique({ where: { key: parsed.data.key } });
|
||||||
if (!existing) return { ok: false, error: "Plan not found." };
|
if (!existing) return { ok: false, error: "Plan not found." };
|
||||||
|
|
||||||
await prisma.plan.update({
|
await prisma.plan.update({
|
||||||
where: { key },
|
where: { key: parsed.data.key },
|
||||||
data: {
|
data: {
|
||||||
priceMonthly: Math.max(0, Math.round(input.priceMonthly)),
|
priceMonthly: parsed.data.priceMonthly,
|
||||||
priceYearly: Math.max(0, Math.round(input.priceYearly)),
|
priceYearly: parsed.data.priceYearly,
|
||||||
limits: input.limits as unknown as Prisma.InputJsonValue,
|
limits: parsed.data.limits as unknown as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await audit(s.user.id, "plan.update", key, {
|
await audit(s.user.id, "plan.update", parsed.data.key, {
|
||||||
priceMonthly: input.priceMonthly,
|
priceMonthly: parsed.data.priceMonthly,
|
||||||
priceYearly: input.priceYearly,
|
priceYearly: parsed.data.priceYearly,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ import type { PlanKey } from "@/lib/billing/plans";
|
|||||||
|
|
||||||
type ActionResult = { ok: true; url?: string } | { ok: false; error: string };
|
type ActionResult = { ok: true; url?: string } | { ok: false; error: string };
|
||||||
|
|
||||||
|
// Billing calls hit upstream providers (Stripe/PayPal) whose error messages can
|
||||||
|
// contain sensitive/verbose detail. Log the real error server-side, but return a
|
||||||
|
// single generic message to the client so nothing upstream leaks to end users.
|
||||||
function errMsg(e: unknown): string {
|
function errMsg(e: unknown): string {
|
||||||
return e instanceof Error ? e.message : "Something went wrong";
|
console.error("[billing] action failed", e);
|
||||||
|
return "Something went wrong with billing. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startStripeCheckoutAction(
|
export async function startStripeCheckoutAction(
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { z } from "zod";
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { getServerSession } from "@/lib/auth/guards";
|
import { getServerSession } from "@/lib/auth/guards";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { subjectHasFeature } from "@/lib/billing/subscription";
|
import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription";
|
||||||
import { enforceLimit, LimitExceededError } from "@/lib/usage/limits";
|
import { reserveLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||||
|
import { refundUsage } from "@/lib/usage/meter";
|
||||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||||
|
import type { UsageMetric } from "@/lib/billing/plans";
|
||||||
import { FORMAT_SPEAKERS } from "@/lib/episodes/options";
|
import { FORMAT_SPEAKERS } from "@/lib/episodes/options";
|
||||||
import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices";
|
import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices";
|
||||||
import { isFlagEnabled } from "@/lib/flags";
|
import { isFlagEnabled } from "@/lib/flags";
|
||||||
@@ -62,17 +64,41 @@ export async function generateFromSeriesAction(
|
|||||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `index` is client-supplied and only TS-typed — validate it at runtime.
|
||||||
|
if (!Number.isInteger(index) || index < 0) {
|
||||||
|
return { ok: false, error: "Invalid episode index." };
|
||||||
|
}
|
||||||
|
|
||||||
const series = await prisma.series.findUnique({ where: { id: seriesId } });
|
const series = await prisma.series.findUnique({ where: { id: seriesId } });
|
||||||
if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." };
|
if (!series || (series.userId !== session.user.id && session.user.role !== "admin")) {
|
||||||
|
return { ok: false, error: "Not allowed." };
|
||||||
|
}
|
||||||
|
|
||||||
const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? [];
|
const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? [];
|
||||||
const item = episodes[index];
|
const item = episodes[index];
|
||||||
if (!item) return { ok: false, error: "Episode not found in plan." };
|
if (!item) return { ok: false, error: "Episode not found in plan." };
|
||||||
|
|
||||||
|
// Bill the generation against the org that OWNS the series (the resource being
|
||||||
|
// acted on), and stamp the new episode with that same org, so the billing
|
||||||
|
// subject and the episode's organizationId are always consistent — regardless
|
||||||
|
// of the caller's currently-active org. getEffectivePlan verifies membership of
|
||||||
|
// series.organizationId internally and falls back to the user subject otherwise.
|
||||||
|
const orgId = series.organizationId ?? undefined;
|
||||||
|
const { subjectId, subjectType } = await getEffectivePlan(session.user.id, orgId);
|
||||||
|
|
||||||
|
// Reserve quota atomically up front (a series generation consumes script +
|
||||||
|
// audio). The worker won't re-meter; refund below if create/enqueue fails.
|
||||||
|
const reserved: UsageMetric[] = [];
|
||||||
|
const refundReserved = async () => {
|
||||||
|
for (const m of reserved) await refundUsage(subjectId, subjectType, m);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId);
|
await reserveLimit(session.user.id, "script", orgId);
|
||||||
await enforceLimit(session.user.id, "audio", session.session.activeOrganizationId);
|
reserved.push("script");
|
||||||
|
await reserveLimit(session.user.id, "audio", orgId);
|
||||||
|
reserved.push("audio");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await refundReserved();
|
||||||
if (err instanceof LimitExceededError) {
|
if (err instanceof LimitExceededError) {
|
||||||
return { ok: false, error: `Monthly ${err.check.metric} limit reached.` };
|
return { ok: false, error: `Monthly ${err.check.metric} limit reached.` };
|
||||||
}
|
}
|
||||||
@@ -85,10 +111,11 @@ export async function generateFromSeriesAction(
|
|||||||
elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
|
elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
const episode = await prisma.episode.create({
|
const episode = await prisma.episode.create({
|
||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
organizationId: series.organizationId ?? undefined,
|
organizationId: orgId,
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
topic: item.topic,
|
topic: item.topic,
|
||||||
@@ -106,4 +133,8 @@ export async function generateFromSeriesAction(
|
|||||||
|
|
||||||
revalidatePath(`/series/${seriesId}`);
|
revalidatePath(`/series/${seriesId}`);
|
||||||
return { ok: true, episodeId: episode.id };
|
return { ok: true, episodeId: episode.id };
|
||||||
|
} catch (err) {
|
||||||
|
await refundReserved();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { getServerSession } from "@/lib/auth/guards";
|
import { getServerSession } from "@/lib/auth/guards";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -42,20 +43,22 @@ export async function GET(
|
|||||||
const isAdmin = session.user.role === "admin";
|
const isAdmin = session.user.role === "admin";
|
||||||
if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 });
|
if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 });
|
||||||
|
|
||||||
const exists = await storage().exists(key);
|
// Stream off disk instead of buffering the whole file into memory.
|
||||||
if (!exists) return new Response("Not found", { status: 404 });
|
const total = await storage().size(key);
|
||||||
|
if (total === null) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
const data = await storage().get(key);
|
|
||||||
const ext = key.split(".").pop()?.toLowerCase() ?? "";
|
const ext = key.split(".").pop()?.toLowerCase() ?? "";
|
||||||
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
||||||
|
|
||||||
const download = req.nextUrl.searchParams.get("download");
|
const download = req.nextUrl.searchParams.get("download");
|
||||||
const filename = key.split("/").pop() ?? "asset";
|
const filename = key.split("/").pop() ?? "asset";
|
||||||
|
|
||||||
return new Response(data as BodyInit, {
|
const node = storage().createReadStream!(key);
|
||||||
|
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
|
||||||
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": String(data.byteLength),
|
"Content-Length": String(total),
|
||||||
"Cache-Control": "private, max-age=3600",
|
"Cache-Control": "private, max-age=3600",
|
||||||
...(download
|
...(download
|
||||||
? { "Content-Disposition": `attachment; filename="${filename}"` }
|
? { "Content-Disposition": `attachment; filename="${filename}"` }
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||||
import { storage } from "@/lib/storage";
|
import { storage } from "@/lib/storage";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Best-effort client IP for anonymous rate limiting. */
|
||||||
|
function clientKey(req: NextRequest): string {
|
||||||
|
const fwd = req.headers.get("x-forwarded-for");
|
||||||
|
if (fwd) return fwd.split(",")[0].trim();
|
||||||
|
return req.headers.get("x-real-ip") ?? "anon";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream an episode's MP3 to anonymous visitors, authorized purely by a valid,
|
* Stream an episode's MP3 to anonymous visitors, authorized purely by a valid,
|
||||||
* still-enabled public `shareId` (NOT a session). Returns 404 when the share is
|
* still-enabled public `shareId` (NOT a session). Returns 404 when the share is
|
||||||
* disabled or the audio is missing so we never disclose private episode state.
|
* disabled or the audio is missing so we never disclose private episode state.
|
||||||
*
|
*
|
||||||
* Supports HTTP Range requests so the audio element can seek/scrub.
|
* Supports HTTP Range requests so the audio element can seek/scrub. The file is
|
||||||
|
* streamed off disk (never buffered whole) to avoid memory-amplification DoS.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ shareId: string }> }
|
{ params }: { params: Promise<{ shareId: string }> }
|
||||||
) {
|
) {
|
||||||
|
// Rate-limit by client IP (never by shareId alone).
|
||||||
|
const rl = await rateLimit("public-audio", clientKey(req), LIMITS.publicMedia);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return new Response("Too many requests", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": String(rl.retryAfterSec ?? 1) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { shareId } = await params;
|
const { shareId } = await params;
|
||||||
|
|
||||||
const episode = await prisma.episode.findUnique({
|
const episode = await prisma.episode.findUnique({
|
||||||
@@ -24,10 +43,9 @@ export async function GET(
|
|||||||
const key = episode?.audioAsset?.storageKey;
|
const key = episode?.audioAsset?.storageKey;
|
||||||
if (!key) return new Response("Not found", { status: 404 });
|
if (!key) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
|
const total = await storage().size(key);
|
||||||
|
if (total === null) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
const data = await storage().get(key);
|
|
||||||
const total = data.byteLength;
|
|
||||||
const contentType = "audio/mpeg";
|
const contentType = "audio/mpeg";
|
||||||
|
|
||||||
const range = req.headers.get("range");
|
const range = req.headers.get("range");
|
||||||
@@ -37,12 +55,13 @@ export async function GET(
|
|||||||
const start = Number(match[1]);
|
const start = Number(match[1]);
|
||||||
const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
|
const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
|
||||||
if (start <= end && start < total) {
|
if (start <= end && start < total) {
|
||||||
const chunk = data.subarray(start, end + 1);
|
const node = storage().createReadStream!(key, { start, end });
|
||||||
return new Response(chunk as BodyInit, {
|
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
|
||||||
|
return new Response(body, {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": String(chunk.byteLength),
|
"Content-Length": String(end - start + 1),
|
||||||
"Content-Range": `bytes ${start}-${end}/${total}`,
|
"Content-Range": `bytes ${start}-${end}/${total}`,
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Cache-Control": "public, max-age=3600",
|
"Cache-Control": "public, max-age=3600",
|
||||||
@@ -52,7 +71,9 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(data as BodyInit, {
|
const node = storage().createReadStream!(key);
|
||||||
|
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
|
||||||
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": String(total),
|
"Content-Length": String(total),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||||
import { storage } from "@/lib/storage";
|
import { storage } from "@/lib/storage";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -11,15 +13,32 @@ const CONTENT_TYPES: Record<string, string> = {
|
|||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Best-effort client IP for anonymous rate limiting. */
|
||||||
|
function clientKey(req: NextRequest): string {
|
||||||
|
const fwd = req.headers.get("x-forwarded-for");
|
||||||
|
if (fwd) return fwd.split(",")[0].trim();
|
||||||
|
return req.headers.get("x-real-ip") ?? "anon";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve an episode's cover art to anonymous visitors, authorized by a valid,
|
* Serve an episode's cover art to anonymous visitors, authorized by a valid,
|
||||||
* still-enabled public `shareId`. Used as a fallback when the storage provider
|
* still-enabled public `shareId`. Used as a fallback when the storage provider
|
||||||
* doesn't expose a directly-fetchable public URL for cover art.
|
* doesn't expose a directly-fetchable public URL for cover art. The file is
|
||||||
|
* streamed off disk rather than buffered whole to avoid memory-amplification DoS.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ shareId: string }> }
|
{ params }: { params: Promise<{ shareId: string }> }
|
||||||
) {
|
) {
|
||||||
|
// Rate-limit by client IP (never by shareId alone).
|
||||||
|
const rl = await rateLimit("public-cover", clientKey(req), LIMITS.publicMedia);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return new Response("Too many requests", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": String(rl.retryAfterSec ?? 1) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { shareId } = await params;
|
const { shareId } = await params;
|
||||||
|
|
||||||
const episode = await prisma.episode.findUnique({
|
const episode = await prisma.episode.findUnique({
|
||||||
@@ -28,14 +47,17 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
const key = episode?.coverArt?.storageKey;
|
const key = episode?.coverArt?.storageKey;
|
||||||
if (!key) return new Response("Not found", { status: 404 });
|
if (!key) return new Response("Not found", { status: 404 });
|
||||||
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
|
|
||||||
|
|
||||||
const data = await storage().get(key);
|
const total = await storage().size(key);
|
||||||
|
if (total === null) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
const ext = key.split(".").pop()?.toLowerCase() ?? "png";
|
const ext = key.split(".").pop()?.toLowerCase() ?? "png";
|
||||||
return new Response(data as BodyInit, {
|
const node = storage().createReadStream!(key);
|
||||||
|
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
|
||||||
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": CONTENT_TYPES[ext] ?? "image/png",
|
"Content-Type": CONTENT_TYPES[ext] ?? "image/png",
|
||||||
"Content-Length": String(data.byteLength),
|
"Content-Length": String(total),
|
||||||
"Cache-Control": "public, max-age=3600",
|
"Cache-Control": "public, max-age=3600",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/co
|
|||||||
import { refundUsage } from "@/lib/usage/meter";
|
import { refundUsage } from "@/lib/usage/meter";
|
||||||
import { isFlagEnabled } from "@/lib/flags";
|
import { isFlagEnabled } from "@/lib/flags";
|
||||||
import { moderateText, moderationReason } from "@/lib/ai/moderation";
|
import { moderateText, moderationReason } from "@/lib/ai/moderation";
|
||||||
import { sendEmail, emailLayout } from "@/lib/email";
|
import { sendEmail, emailLayout, escapeHtml } from "@/lib/email";
|
||||||
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
|
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
|
||||||
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
|
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
|
||||||
import type { GenerationType } from "@/lib/queue/jobs";
|
import type { GenerationType } from "@/lib/queue/jobs";
|
||||||
@@ -262,13 +262,16 @@ async function generateArt(episode: EpisodeWithRelations) {
|
|||||||
|
|
||||||
async function notifyReady(episode: EpisodeWithRelations) {
|
async function notifyReady(episode: EpisodeWithRelations) {
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||||
|
// `emailLayout` interpolates the body as RAW HTML, so user-controlled fields
|
||||||
|
// (the episode title) must be escaped before being placed into it.
|
||||||
|
const safeTitle = escapeHtml(episode.title ?? "");
|
||||||
try {
|
try {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: episode.user.email,
|
to: episode.user.email,
|
||||||
subject: `🎙️ "${episode.title}" is ready`,
|
subject: `🎙️ "${episode.title}" is ready`,
|
||||||
html: emailLayout(
|
html: emailLayout(
|
||||||
"Your episode is ready",
|
"Your episode is ready",
|
||||||
`“${episode.title}” has finished generating — script, audio, and cover art are all set.`,
|
`“${safeTitle}” has finished generating — script, audio, and cover art are all set.`,
|
||||||
{ label: "Open episode", url: `${appUrl}/episodes/${episode.id}` }
|
{ label: "Open episode", url: `${appUrl}/episodes/${episode.id}` }
|
||||||
),
|
),
|
||||||
text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`,
|
text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`,
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ const TTS_MODEL = process.env.ELEVENLABS_TTS_MODEL ?? "eleven_multilingual_v2";
|
|||||||
const DIALOGUE_MODEL = process.env.ELEVENLABS_DIALOGUE_MODEL ?? "eleven_v3";
|
const DIALOGUE_MODEL = process.env.ELEVENLABS_DIALOGUE_MODEL ?? "eleven_v3";
|
||||||
const OUTPUT_FORMAT = "mp3_44100_128";
|
const OUTPUT_FORMAT = "mp3_44100_128";
|
||||||
|
|
||||||
|
/** ElevenLabs voice IDs are opaque alphanumeric tokens; reject anything else. */
|
||||||
|
const VOICE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
function apiKey(): string {
|
function apiKey(): string {
|
||||||
const k = process.env.ELEVENLABS_API_KEY;
|
const k = process.env.ELEVENLABS_API_KEY;
|
||||||
if (!k) throw new Error("ELEVENLABS_API_KEY is not set");
|
if (!k) throw new Error("ELEVENLABS_API_KEY is not set");
|
||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate a voice ID before it is interpolated into a request URL path. */
|
||||||
|
function safeVoiceId(voiceId: string): string {
|
||||||
|
if (!VOICE_ID_PATTERN.test(voiceId)) {
|
||||||
|
throw new Error(`Invalid ElevenLabs voiceId: ${voiceId}`);
|
||||||
|
}
|
||||||
|
return encodeURIComponent(voiceId);
|
||||||
|
}
|
||||||
|
|
||||||
interface ElevenVoice {
|
interface ElevenVoice {
|
||||||
voice_id: string;
|
voice_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,7 +39,7 @@ export class ElevenLabsAudioProvider implements AudioProvider {
|
|||||||
_opts?: { language?: string }
|
_opts?: { language?: string }
|
||||||
): Promise<{ audio: Buffer; characters: number }> {
|
): Promise<{ audio: Buffer; characters: number }> {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API}/text-to-speech/${voiceId}?output_format=${OUTPUT_FORMAT}`,
|
`${API}/text-to-speech/${safeVoiceId(voiceId)}?output_format=${OUTPUT_FORMAT}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
+17
-9
@@ -9,10 +9,19 @@ const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
|||||||
|
|
||||||
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
|
|
||||||
// Fail fast in production if the signing secret is missing — sessions/cookies are
|
// Fail fast in production if the signing secret is missing, too short, or a known
|
||||||
// only secure when BETTER_AUTH_SECRET is set. Stay frictionless in dev/test.
|
// placeholder — sessions/cookies are only secure when BETTER_AUTH_SECRET is a strong,
|
||||||
if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV === "production") {
|
// non-default value. Stay frictionless in dev/test.
|
||||||
throw new Error("BETTER_AUTH_SECRET must be set in production.");
|
const KNOWN_WEAK_SECRETS = new Set([
|
||||||
|
"dev-secret-please-change-0123456789abcdef",
|
||||||
|
]);
|
||||||
|
const authSecret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
const secretIsWeak =
|
||||||
|
!authSecret || authSecret.length < 32 || KNOWN_WEAK_SECRETS.has(authSecret);
|
||||||
|
if (secretIsWeak && process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error(
|
||||||
|
"BETTER_AUTH_SECRET must be set in production to a strong value (>= 32 chars, not a known placeholder)."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
@@ -27,11 +36,10 @@ export const auth = betterAuth({
|
|||||||
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// SECURITY GATE (currently OPEN): unverified emails can sign in. Flip this to
|
// SECURITY GATE: unverified emails CANNOT sign in. Verification emails are sent
|
||||||
// `true` once email delivery is confirmed working in prod. Left `false` for now
|
// on signup (see emailVerification.sendOnSignUp below), so users can verify before
|
||||||
// so existing dev accounts aren't locked out. Verification emails ARE sent on
|
// their first login.
|
||||||
// signup (see emailVerification.sendOnSignUp below), so users can verify already.
|
requireEmailVerification: true,
|
||||||
requireEmailVerification: false,
|
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
async sendResetPassword({ user, url }) {
|
async sendResetPassword({ user, url }) {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export async function createPaypalSubscription(args: {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`PayPal create subscription ${res.status}: ${await res.text()}`);
|
if (!res.ok) {
|
||||||
|
// Log the full upstream detail server-side, but never surface it to clients.
|
||||||
|
console.error(`[paypal] create subscription ${res.status}: ${await res.text()}`);
|
||||||
|
throw new Error("PayPal request failed");
|
||||||
|
}
|
||||||
const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] };
|
const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] };
|
||||||
const approveUrl = data.links.find((l) => l.rel === "approve")?.href;
|
const approveUrl = data.links.find((l) => l.rel === "approve")?.href;
|
||||||
if (!approveUrl) throw new Error("PayPal did not return an approval URL");
|
if (!approveUrl) throw new Error("PayPal did not return an approval URL");
|
||||||
@@ -94,7 +98,9 @@ export async function cancelPaypalSubscription(id: string, reason = "Customer re
|
|||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
throw new Error(`PayPal cancel subscription ${res.status}: ${await res.text()}`);
|
// Log the full upstream detail server-side, but never surface it to clients.
|
||||||
|
console.error(`[paypal] cancel subscription ${res.status}: ${await res.text()}`);
|
||||||
|
throw new Error("PayPal request failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-12
@@ -22,16 +22,6 @@ export interface UpsertSubscriptionInput {
|
|||||||
* provider subscription id, so duplicate/replayed webhooks converge on one row.
|
* provider subscription id, so duplicate/replayed webhooks converge on one row.
|
||||||
*/
|
*/
|
||||||
export async function upsertSubscription(input: UpsertSubscriptionInput) {
|
export async function upsertSubscription(input: UpsertSubscriptionInput) {
|
||||||
const existing = input.stripeSubscriptionId
|
|
||||||
? await prisma.subscription.findFirst({
|
|
||||||
where: { stripeSubscriptionId: input.stripeSubscriptionId },
|
|
||||||
})
|
|
||||||
: input.paypalSubscriptionId
|
|
||||||
? await prisma.subscription.findFirst({
|
|
||||||
where: { paypalSubscriptionId: input.paypalSubscriptionId },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
plan: input.plan,
|
plan: input.plan,
|
||||||
@@ -47,9 +37,25 @@ export async function upsertSubscription(input: UpsertSubscriptionInput) {
|
|||||||
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined,
|
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
// Atomic upsert keyed on whichever provider subscription id is present on the
|
||||||
return prisma.subscription.update({ where: { id: existing.id }, data });
|
// incoming record. The DB-level @@unique on these columns lets concurrent
|
||||||
|
// webhook retries converge on a single row instead of racing into duplicates.
|
||||||
|
if (input.stripeSubscriptionId) {
|
||||||
|
return prisma.subscription.upsert({
|
||||||
|
where: { stripeSubscriptionId: input.stripeSubscriptionId },
|
||||||
|
create: { referenceId: input.referenceId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (input.paypalSubscriptionId) {
|
||||||
|
return prisma.subscription.upsert({
|
||||||
|
where: { paypalSubscriptionId: input.paypalSubscriptionId },
|
||||||
|
create: { referenceId: input.referenceId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Safe fallback: neither provider id is present (no unique key to upsert on),
|
||||||
|
// so create a fresh row.
|
||||||
return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } });
|
return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +91,19 @@ export async function getEffectivePlan(
|
|||||||
activeOrgId?: string | null
|
activeOrgId?: string | null
|
||||||
): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> {
|
): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> {
|
||||||
if (activeOrgId) {
|
if (activeOrgId) {
|
||||||
|
// Only grant the org's plan if the user is an actual member of that org.
|
||||||
|
// A stale/forged activeOrganizationId must not elevate a non-member.
|
||||||
|
const membership = await prisma.member.findUnique({
|
||||||
|
where: { organizationId_userId: { organizationId: activeOrgId, userId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (membership) {
|
||||||
const key = await getSubjectPlanKey(activeOrgId);
|
const key = await getSubjectPlanKey(activeOrgId);
|
||||||
if (key !== "free") {
|
if (key !== "free") {
|
||||||
return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" };
|
return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const key = await getSubjectPlanKey(userId);
|
const key = await getSubjectPlanKey(userId);
|
||||||
return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" };
|
return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { prisma } from "@/lib/db";
|
|||||||
/** True if we've already handled this provider event (idempotency). */
|
/** True if we've already handled this provider event (idempotency). */
|
||||||
export async function alreadyProcessed(eventId: string): Promise<boolean> {
|
export async function alreadyProcessed(eventId: string): Promise<boolean> {
|
||||||
const existing = await prisma.webhookEvent.findUnique({ where: { eventId } });
|
const existing = await prisma.webhookEvent.findUnique({ where: { eventId } });
|
||||||
return !!existing;
|
// Only a successfully "processed" event is considered handled. Rows logged as
|
||||||
|
// "failed" (or "skipped") must be reprocessable when the provider retries.
|
||||||
|
return existing?.status === "processed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a webhook delivery for the admin log (best-effort; unique on eventId). */
|
/** Record a webhook delivery for the admin log (best-effort; unique on eventId). */
|
||||||
|
|||||||
+14
-3
@@ -12,19 +12,30 @@ const FROM = process.env.EMAIL_FROM ?? "Podcast Distribution AI <noreply@podcast
|
|||||||
* console (useful in local dev before RESEND_API_KEY is set).
|
* console (useful in local dev before RESEND_API_KEY is set).
|
||||||
*/
|
*/
|
||||||
export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise<void> {
|
export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise<void> {
|
||||||
|
// Defense-in-depth header hygiene: strip CR/LF and other control chars so a
|
||||||
|
// user-controlled subject/recipient can't inject extra email headers.
|
||||||
|
const safeSubject = stripControlChars(subject).replace(/[\r\n]+/g, " ").trim();
|
||||||
|
const safeTo = stripControlChars(to).replace(/[\r\n]+/g, " ").trim();
|
||||||
|
|
||||||
const apiKey = process.env.RESEND_API_KEY;
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.info(`[email:dev] To: ${to} | Subject: ${subject}\n${text ?? html}`);
|
console.info(`[email:dev] To: ${safeTo} | Subject: ${safeSubject}\n${text ?? html}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { Resend } = await import("resend");
|
const { Resend } = await import("resend");
|
||||||
const resend = new Resend(apiKey);
|
const resend = new Resend(apiKey);
|
||||||
const { error } = await resend.emails.send({ from: FROM, to, subject, html, text });
|
const { error } = await resend.emails.send({ from: FROM, to: safeTo, subject: safeSubject, html, text });
|
||||||
if (error) throw new Error(`Resend error: ${error.message}`);
|
if (error) throw new Error(`Resend error: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove CR/LF and other ASCII control characters (header-injection defense). */
|
||||||
|
function stripControlChars(value: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return value.replace(/[\x00-\x1f\x7f]+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
/** Escape text for safe interpolation into HTML/attribute contexts. */
|
/** Escape text for safe interpolation into HTML/attribute contexts. */
|
||||||
function escapeHtml(value: string): string {
|
export function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
|
|||||||
+31
-17
@@ -1,5 +1,7 @@
|
|||||||
/** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */
|
/** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const QUEUES = {
|
export const QUEUES = {
|
||||||
generateEpisode: "episode.generate",
|
generateEpisode: "episode.generate",
|
||||||
generateSeries: "series.generate",
|
generateSeries: "series.generate",
|
||||||
@@ -10,29 +12,41 @@ export const QUEUES = {
|
|||||||
|
|
||||||
export type QueueName = (typeof QUEUES)[keyof typeof QUEUES];
|
export type QueueName = (typeof QUEUES)[keyof typeof QUEUES];
|
||||||
|
|
||||||
export type GenerationType = "full" | "script" | "audio" | "art" | "section" | "repurpose";
|
export const generationTypeSchema = z.enum([
|
||||||
|
"full",
|
||||||
|
"script",
|
||||||
|
"audio",
|
||||||
|
"art",
|
||||||
|
"section",
|
||||||
|
"repurpose",
|
||||||
|
]);
|
||||||
|
export type GenerationType = z.infer<typeof generationTypeSchema>;
|
||||||
|
|
||||||
export interface GenerateEpisodePayload {
|
export const generateEpisodePayloadSchema = z.object({
|
||||||
episodeId: string;
|
episodeId: z.string().min(1),
|
||||||
/** "full" runs the whole pipeline; the others re-run a single stage. */
|
/** "full" runs the whole pipeline; the others re-run a single stage. */
|
||||||
type?: GenerationType;
|
type: generationTypeSchema.optional(),
|
||||||
/** For type="section", the script section to regenerate. */
|
/** For type="section", the script section to regenerate. */
|
||||||
sectionId?: string;
|
sectionId: z.string().optional(),
|
||||||
}
|
});
|
||||||
|
export type GenerateEpisodePayload = z.infer<typeof generateEpisodePayloadSchema>;
|
||||||
|
|
||||||
export interface RepurposePayload {
|
export const repurposePayloadSchema = z.object({
|
||||||
episodeId: string;
|
episodeId: z.string().min(1),
|
||||||
format: "blog" | "social_thread" | "newsletter";
|
format: z.enum(["blog", "social_thread", "newsletter"]),
|
||||||
}
|
});
|
||||||
|
export type RepurposePayload = z.infer<typeof repurposePayloadSchema>;
|
||||||
|
|
||||||
export interface GenerateSeriesPayload {
|
export const generateSeriesPayloadSchema = z.object({
|
||||||
seriesId: string;
|
seriesId: z.string().min(1),
|
||||||
}
|
});
|
||||||
|
export type GenerateSeriesPayload = z.infer<typeof generateSeriesPayloadSchema>;
|
||||||
|
|
||||||
export interface EchoPayload {
|
export const echoPayloadSchema = z.object({
|
||||||
message: string;
|
message: z.string(),
|
||||||
episodeId?: string;
|
episodeId: z.string().optional(),
|
||||||
}
|
});
|
||||||
|
export type EchoPayload = z.infer<typeof echoPayloadSchema>;
|
||||||
|
|
||||||
/** All queues that must exist before send/work. */
|
/** All queues that must exist before send/work. */
|
||||||
export const ALL_QUEUES: QueueName[] = Object.values(QUEUES);
|
export const ALL_QUEUES: QueueName[] = Object.values(QUEUES);
|
||||||
|
|||||||
+51
-5
@@ -1,13 +1,58 @@
|
|||||||
import { RateLimiterMemory } from "rate-limiter-flexible";
|
import { Pool } from "pg";
|
||||||
|
import {
|
||||||
|
RateLimiterMemory,
|
||||||
|
RateLimiterPostgres,
|
||||||
|
type RateLimiterAbstract,
|
||||||
|
} from "rate-limiter-flexible";
|
||||||
|
|
||||||
// In-memory limiters (no Redis). Fine for a single-instance Plesk deployment;
|
/**
|
||||||
// swap for RateLimiterPostgres if the app is ever scaled to multiple nodes.
|
* Backend selection (default = in-memory):
|
||||||
const limiters = new Map<string, RateLimiterMemory>();
|
*
|
||||||
|
* - DEFAULT: RateLimiterMemory — per-process counters. Fine for a single
|
||||||
|
* instance (the Plesk VPS). No DB connection is ever opened in this mode.
|
||||||
|
* - OPT-IN: set RATE_LIMIT_BACKEND="postgres" *and* provide DATABASE_URL to
|
||||||
|
* share limits across multiple instances via RateLimiterPostgres. The
|
||||||
|
* limiter table ("rate_limits") is created automatically by
|
||||||
|
* rate-limiter-flexible on first use, so no migration is required.
|
||||||
|
*
|
||||||
|
* The pg Pool is created lazily on first consume — importing this module never
|
||||||
|
* connects to the database, so memory mode stays connection-free.
|
||||||
|
*/
|
||||||
|
const usePostgres =
|
||||||
|
process.env.RATE_LIMIT_BACKEND === "postgres" && !!process.env.DATABASE_URL;
|
||||||
|
|
||||||
function getLimiter(name: string, points: number, durationSec: number): RateLimiterMemory {
|
const limiters = new Map<string, RateLimiterAbstract>();
|
||||||
|
|
||||||
|
// Lazily-created shared pg Pool. Constructing it does NOT open a connection
|
||||||
|
// (pg connects on first query), and it is only built when a Postgres-backed
|
||||||
|
// limiter is first needed — so memory mode never creates a pool.
|
||||||
|
let pgPool: Pool | null = null;
|
||||||
|
function getPool(): Pool {
|
||||||
|
if (!pgPool) {
|
||||||
|
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
}
|
||||||
|
return pgPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLimiter(name: string, points: number, durationSec: number): RateLimiterAbstract {
|
||||||
let limiter = limiters.get(name);
|
let limiter = limiters.get(name);
|
||||||
if (!limiter) {
|
if (!limiter) {
|
||||||
|
if (usePostgres) {
|
||||||
|
// The constructor opens the (lazy) pool and ensures the backing table
|
||||||
|
// exists; ready before the first consume() thanks to the insurance limiter.
|
||||||
|
limiter = new RateLimiterPostgres({
|
||||||
|
storeClient: getPool(),
|
||||||
|
tableName: "rate_limits",
|
||||||
|
keyPrefix: name,
|
||||||
|
points,
|
||||||
|
duration: durationSec,
|
||||||
|
// If Postgres is briefly unreachable, degrade to per-process counting
|
||||||
|
// instead of failing the request outright.
|
||||||
|
insuranceLimiter: new RateLimiterMemory({ points, duration: durationSec }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
limiter = new RateLimiterMemory({ points, duration: durationSec });
|
limiter = new RateLimiterMemory({ points, duration: durationSec });
|
||||||
|
}
|
||||||
limiters.set(name, limiter);
|
limiters.set(name, limiter);
|
||||||
}
|
}
|
||||||
return limiter;
|
return limiter;
|
||||||
@@ -41,4 +86,5 @@ export const LIMITS = {
|
|||||||
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes)
|
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes)
|
||||||
read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key
|
read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key
|
||||||
stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user
|
stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user
|
||||||
|
publicMedia: { points: 120, durationSec: 60 }, // anon audio/cover (Range) reqs / min / IP
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs, createReadStream as fsCreateReadStream } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { StorageProvider } from "./types";
|
import type { StorageProvider } from "./types";
|
||||||
|
|
||||||
@@ -29,6 +29,17 @@ export class LocalStorageProvider implements StorageProvider {
|
|||||||
return fs.readFile(resolveSafe(key));
|
return fs.readFile(resolveSafe(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a file (optionally an inclusive byte range) without buffering it all
|
||||||
|
* into memory. Goes through resolveSafe so path-traversal protection holds.
|
||||||
|
*/
|
||||||
|
createReadStream(key: string, range?: { start: number; end: number }): NodeJS.ReadableStream {
|
||||||
|
const full = resolveSafe(key);
|
||||||
|
return range
|
||||||
|
? fsCreateReadStream(full, { start: range.start, end: range.end })
|
||||||
|
: fsCreateReadStream(full);
|
||||||
|
}
|
||||||
|
|
||||||
async exists(key: string): Promise<boolean> {
|
async exists(key: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fs.access(resolveSafe(key));
|
await fs.access(resolveSafe(key));
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ export interface StorageProvider {
|
|||||||
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
||||||
/** Read the full object as a Buffer. */
|
/** Read the full object as a Buffer. */
|
||||||
get(key: string): Promise<Buffer>;
|
get(key: string): Promise<Buffer>;
|
||||||
|
/**
|
||||||
|
* Stream the object (optionally a byte range, inclusive) instead of buffering
|
||||||
|
* it all into memory — used by the audio/asset routes to avoid memory
|
||||||
|
* amplification under concurrent load. Optional: providers may omit it, in
|
||||||
|
* which case callers fall back to get().
|
||||||
|
*/
|
||||||
|
createReadStream?(
|
||||||
|
key: string,
|
||||||
|
range?: { start: number; end: number }
|
||||||
|
): NodeJS.ReadableStream;
|
||||||
/** Whether an object exists at `key`. */
|
/** Whether an object exists at `key`. */
|
||||||
exists(key: string): Promise<boolean>;
|
exists(key: string): Promise<boolean>;
|
||||||
/** Remove the object (no-op if missing). */
|
/** Remove the object (no-op if missing). */
|
||||||
|
|||||||
+65
-15
@@ -4,35 +4,85 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
// "__Secure-" variant is used when cookies are served over HTTPS in production.
|
// "__Secure-" variant is used when cookies are served over HTTPS in production.
|
||||||
const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"];
|
const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"];
|
||||||
|
|
||||||
|
// Authed surfaces that require an optimistic session-cookie check. Anonymous users
|
||||||
|
// hitting these are redirected to /sign-in. Public/marketing/auth routes are NOT
|
||||||
|
// listed here, so they are never redirected (CSP still applies to them, below).
|
||||||
|
const AUTHED_PREFIXES = [
|
||||||
|
"/dashboard",
|
||||||
|
"/episodes",
|
||||||
|
"/series",
|
||||||
|
"/usage",
|
||||||
|
"/billing",
|
||||||
|
"/team",
|
||||||
|
"/api-keys",
|
||||||
|
"/settings",
|
||||||
|
"/admin",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optimistic edge gate: redirect anonymous users away from authed surfaces.
|
* Runs on every request (see matcher). Two responsibilities:
|
||||||
* Only checks for the *presence* of a session cookie — real session validation
|
*
|
||||||
* (and admin/role checks) happen in the route-group layouts. Reading the cookie
|
* 1. CSP/nonce (all routes): generate a per-request base64 nonce with the Web Crypto
|
||||||
* directly keeps the middleware bundle free of the auth/jose internals.
|
* API (Edge-safe — no node:crypto), expose it on the inbound `x-nonce` request
|
||||||
|
* header, and set a nonce-based Content-Security-Policy response header. Next.js
|
||||||
|
* auto-applies this nonce to its own framework <script> tags when the `x-nonce`
|
||||||
|
* request header is present; the root layout may also read it via `headers()` to
|
||||||
|
* nonce any manual inline scripts.
|
||||||
|
*
|
||||||
|
* 2. Optimistic edge gate (authed prefixes only): redirect anonymous users away from
|
||||||
|
* authed surfaces. Only checks for the *presence* of a session cookie — real
|
||||||
|
* session validation (and admin/role checks) happen in the route-group layouts.
|
||||||
*/
|
*/
|
||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
|
|
||||||
const { pathname, search } = req.nextUrl;
|
const { pathname, search } = req.nextUrl;
|
||||||
|
|
||||||
|
// Per-request nonce (base64). randomUUID is Edge-runtime safe and unguessable.
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: https://oaidalleapiprodscus.blob.core.windows.net https://images.unsplash.com",
|
||||||
|
"media-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
// Optimistic auth gate for the previously-matched authed prefixes only.
|
||||||
|
const isAuthedPath = AUTHED_PREFIXES.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(p + "/")
|
||||||
|
);
|
||||||
|
if (isAuthedPath) {
|
||||||
|
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
const signIn = new URL("/sign-in", req.url);
|
const signIn = new URL("/sign-in", req.url);
|
||||||
signIn.searchParams.set("redirect", pathname + search);
|
signIn.searchParams.set("redirect", pathname + search);
|
||||||
return NextResponse.redirect(signIn);
|
return NextResponse.redirect(signIn);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
// Forward the nonce to the app via a request header, and set the CSP on the response.
|
||||||
|
const requestHeaders = new Headers(req.headers);
|
||||||
|
requestHeaders.set("x-nonce", nonce);
|
||||||
|
requestHeaders.set("Content-Security-Policy", csp);
|
||||||
|
|
||||||
|
const res = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
|
res.headers.set("Content-Security-Policy", csp);
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/dashboard/:path*",
|
// Run on every request EXCEPT static assets so CSP applies app-wide while
|
||||||
"/episodes/:path*",
|
// avoiding unnecessary work on prefetched/static files.
|
||||||
"/series/:path*",
|
{
|
||||||
"/usage/:path*",
|
source: "/((?!_next/static|_next/image|favicon.ico).*)",
|
||||||
"/billing/:path*",
|
missing: [
|
||||||
"/team/:path*",
|
{ type: "header", key: "next-router-prefetch" },
|
||||||
"/api-keys/:path*",
|
{ type: "header", key: "purpose", value: "prefetch" },
|
||||||
"/settings/:path*",
|
],
|
||||||
"/admin/:path*",
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-16
@@ -19,22 +19,10 @@ const nextConfig = {
|
|||||||
// Server-only packages that should be required at runtime, not bundled by webpack.
|
// Server-only packages that should be required at runtime, not bundled by webpack.
|
||||||
// better-auth ships internal adapters (kysely) that break webpack's ESM analysis.
|
// better-auth ships internal adapters (kysely) that break webpack's ESM analysis.
|
||||||
serverExternalPackages: ["pg-boss", "@prisma/client", "better-auth"],
|
serverExternalPackages: ["pg-boss", "@prisma/client", "better-auth"],
|
||||||
// Security headers applied to every route.
|
// Static security headers applied to every route. The Content-Security-Policy is
|
||||||
|
// intentionally NOT set here — it is built per-request with a nonce in middleware.ts
|
||||||
|
// so script-src can drop 'unsafe-inline'/'unsafe-eval' (nonce + 'strict-dynamic').
|
||||||
async headers() {
|
async headers() {
|
||||||
// Pragmatic CSP: Next.js's inline/runtime bootstrap currently requires
|
|
||||||
// 'unsafe-inline'/'unsafe-eval' in script-src. Future improvement: tighten
|
|
||||||
// to per-request nonces (and drop 'unsafe-*') once the app is migrated.
|
|
||||||
const csp = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"img-src 'self' data: https://*.blob.core.windows.net https://images.unsplash.com",
|
|
||||||
"media-src 'self'",
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
].join("; ");
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/:path*",
|
source: "/:path*",
|
||||||
@@ -48,7 +36,6 @@ const nextConfig = {
|
|||||||
key: "Strict-Transport-Security",
|
key: "Strict-Transport-Security",
|
||||||
value: "max-age=63072000; includeSubDomains; preload",
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
},
|
},
|
||||||
{ key: "Content-Security-Policy", value: csp },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+10
-2
@@ -211,8 +211,16 @@ model Subscription {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([referenceId])
|
@@index([referenceId])
|
||||||
@@index([stripeSubscriptionId])
|
// Provider subscription ids are unique so concurrent/replayed webhooks can't
|
||||||
@@index([paypalSubscriptionId])
|
// create duplicate rows (atomic upsert keys on these). Nullable: Postgres
|
||||||
|
// treats multiple NULLs as distinct, so existing free/null rows are unaffected.
|
||||||
|
// @@unique already creates a backing index, so no separate @@index is needed.
|
||||||
|
// MIGRATION REQUIRED: these new @@unique constraints must be generated and
|
||||||
|
// applied separately by the operator (`prisma migrate dev` / `migrate deploy`).
|
||||||
|
// The migration will FAIL if duplicate non-null values already exist in the
|
||||||
|
// table — de-dupe those rows first before applying.
|
||||||
|
@@unique([stripeSubscriptionId])
|
||||||
|
@@unique([paypalSubscriptionId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("subscription")
|
@@map("subscription")
|
||||||
|
|||||||
+23
-3
@@ -1,6 +1,12 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { getBoss } from "@/lib/queue/pgboss";
|
import { getBoss } from "@/lib/queue/pgboss";
|
||||||
import { QUEUES, type GenerateEpisodePayload, type EchoPayload } from "@/lib/queue/jobs";
|
import {
|
||||||
|
QUEUES,
|
||||||
|
generateEpisodePayloadSchema,
|
||||||
|
echoPayloadSchema,
|
||||||
|
type GenerateEpisodePayload,
|
||||||
|
type EchoPayload,
|
||||||
|
} from "@/lib/queue/jobs";
|
||||||
import { runEpisodeGeneration, refundEpisodeUsage } from "@/lib/ai/pipeline/generate-episode";
|
import { runEpisodeGeneration, refundEpisodeUsage } from "@/lib/ai/pipeline/generate-episode";
|
||||||
import { setEpisodeStatus } from "@/lib/episodes/status";
|
import { setEpisodeStatus } from "@/lib/episodes/status";
|
||||||
import { recordHeartbeat } from "@/lib/queue/health";
|
import { recordHeartbeat } from "@/lib/queue/health";
|
||||||
@@ -21,7 +27,14 @@ async function main() {
|
|||||||
|
|
||||||
// Proof-of-loop queue used by health checks / verification.
|
// Proof-of-loop queue used by health checks / verification.
|
||||||
await boss.work<EchoPayload>(QUEUES.echo, { batchSize: 1 }, async (jobs) => {
|
await boss.work<EchoPayload>(QUEUES.echo, { batchSize: 1 }, async (jobs) => {
|
||||||
for (const job of jobs) console.log("[echo]", job.data);
|
for (const job of jobs) {
|
||||||
|
const parsed = echoPayloadSchema.safeParse(job.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("[echo] invalid payload — skipping job", parsed.error.issues);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log("[echo]", parsed.data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Episode generation. batchSize 1 = independent retries per job.
|
// Episode generation. batchSize 1 = independent retries per job.
|
||||||
@@ -49,7 +62,14 @@ async function handleGenerate(job: {
|
|||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
retryLimit?: number;
|
retryLimit?: number;
|
||||||
}) {
|
}) {
|
||||||
const { episodeId, type } = job.data;
|
// Runtime-validate the payload at the consume boundary. An invalid payload is
|
||||||
|
// not retryable, so skip it rather than throwing into the work loop.
|
||||||
|
const parsed = generateEpisodePayloadSchema.safeParse(job.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("[generate] invalid payload — skipping job", parsed.error.issues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { episodeId, type } = parsed.data;
|
||||||
try {
|
try {
|
||||||
await runEpisodeGeneration(episodeId, type ?? "full");
|
await runEpisodeGeneration(episodeId, type ?? "full");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user