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>
190 lines
7.2 KiB
TypeScript
190 lines
7.2 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import { Mic2 } from "lucide-react";
|
|
import { prisma } from "@/lib/db";
|
|
import { storage } from "@/lib/storage";
|
|
import { getActiveBranding, hexToHslTriplet } from "@/lib/branding";
|
|
import { WaveformPlayer } from "@/components/app/waveform-player";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Logo } from "@/components/ui/logo";
|
|
import { SITE_NAME, absoluteUrl } from "@/lib/seo";
|
|
import type { StructuredScript } from "@/lib/ai/types";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/** Trim to `max` characters on a word boundary, for meta descriptions. */
|
|
function truncate(text: string, max: number): string {
|
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
if (collapsed.length <= max) return collapsed;
|
|
const cut = collapsed.slice(0, max - 1);
|
|
const lastSpace = cut.lastIndexOf(" ");
|
|
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
}
|
|
|
|
async function loadShared(shareId: string) {
|
|
const episode = await prisma.episode.findUnique({
|
|
where: { shareId },
|
|
include: { audioAsset: true, coverArt: true, script: true, speakers: true },
|
|
});
|
|
// 404 when no episode, sharing disabled, or not finished.
|
|
// moderatedAt = taken down by an admin: treat exactly like a missing share.
|
|
if (!episode || !episode.shareId || episode.moderatedAt || episode.status !== "READY") return null;
|
|
return episode;
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ shareId: string }>;
|
|
}): Promise<Metadata> {
|
|
const { shareId } = await params;
|
|
const episode = await loadShared(shareId);
|
|
if (!episode) return { title: "Episode not found", robots: { index: false, follow: false } };
|
|
|
|
const url = absoluteUrl(`/p/${shareId}`);
|
|
const description = truncate(episode.topic, 160);
|
|
// Share links are unlisted by design, so they stay out of the index — but they
|
|
// are made to be pasted into chat and social, so the unfurl has to be complete.
|
|
const cover = episode.coverArt ? absoluteUrl(`/api/public/episodes/${shareId}/cover`) : undefined;
|
|
|
|
return {
|
|
title: episode.title,
|
|
description,
|
|
robots: { index: false, follow: false, nocache: true },
|
|
openGraph: {
|
|
type: "article",
|
|
title: episode.title,
|
|
description,
|
|
url,
|
|
siteName: SITE_NAME,
|
|
...(cover ? { images: [{ url: cover, alt: `Cover art for ${episode.title}` }] } : {}),
|
|
},
|
|
twitter: {
|
|
// The cover is square, so the compact card frames it better than a wide one.
|
|
card: cover ? "summary" : "summary_large_image",
|
|
title: episode.title,
|
|
description,
|
|
...(cover ? { images: [cover] } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function PublicSharePage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ shareId: string }>;
|
|
}) {
|
|
const { shareId } = await params;
|
|
const episode = await loadShared(shareId);
|
|
if (!episode) notFound();
|
|
|
|
// Resolve the owning org's white-label branding (Agency custom_branding only).
|
|
const branding = await getActiveBranding(episode.userId, episode.organizationId);
|
|
const brandHsl = hexToHslTriplet(branding?.primaryColor);
|
|
const brandStyle = brandHsl
|
|
? ({ "--brand": brandHsl, "--ring": brandHsl } as React.CSSProperties)
|
|
: undefined;
|
|
const brandName = branding?.brandName ?? "Podcast Distribution AI";
|
|
const removePoweredBy = branding?.removePoweredBy ?? false;
|
|
|
|
// Prefer a directly-fetchable public URL (e.g. nginx /media); otherwise fall
|
|
// back to the share-authorized public cover route.
|
|
const coverUrl = episode.coverArt
|
|
? storage().publicUrl(episode.coverArt.storageKey) ??
|
|
`/api/public/episodes/${shareId}/cover`
|
|
: null;
|
|
|
|
const speakerNames: Record<string, string> = {};
|
|
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
|
const script = episode.script?.content as unknown as StructuredScript | undefined;
|
|
|
|
return (
|
|
<div style={brandStyle} className="mx-auto max-w-3xl px-6 py-10 sm:py-16">
|
|
{/* Header / brand wordmark */}
|
|
<header className="mb-8 flex items-center gap-2.5">
|
|
{branding?.logoUrl ? (
|
|
<>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={branding.logoUrl} alt={brandName} className="h-7 w-auto" />
|
|
<span className="font-display text-base font-extrabold tracking-tight">{brandName}</span>
|
|
</>
|
|
) : (
|
|
<Logo className="h-7 w-auto" />
|
|
)}
|
|
</header>
|
|
|
|
<article className="space-y-8">
|
|
<div className="grid gap-6 sm:grid-cols-[200px_1fr] sm:items-end">
|
|
<Card className="overflow-hidden">
|
|
<div className="aspect-square bg-muted">
|
|
{coverUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={coverUrl} alt={episode.title} className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
|
<Mic2 className="h-10 w-10" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="space-y-3">
|
|
<Badge variant="brand" className="uppercase tracking-[0.04em]">
|
|
Podcast episode
|
|
</Badge>
|
|
<h1 className="font-display text-3xl font-extrabold leading-[1.1] tracking-tight sm:text-4xl">
|
|
{episode.title}
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "}
|
|
{episode.targetLengthMin} min
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{episode.audioAsset && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<WaveformPlayer
|
|
src={`/api/public/episodes/${shareId}/audio`}
|
|
durationSec={episode.audioAsset.durationSec}
|
|
hideDownloads
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<section className="space-y-3">
|
|
<h2 className="font-display text-xl font-extrabold tracking-tight">About this episode</h2>
|
|
<p className="whitespace-pre-wrap leading-relaxed text-foreground/90">{episode.topic}</p>
|
|
</section>
|
|
|
|
{script && script.sections?.length > 0 && (
|
|
<section className="space-y-3">
|
|
<h2 className="font-display text-xl font-extrabold tracking-tight">Show notes</h2>
|
|
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
|
{script.sections.map((s) => (
|
|
<li key={s.id} className="flex gap-2">
|
|
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
|
{s.title}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</article>
|
|
|
|
{!removePoweredBy && (
|
|
<footer className="mt-16 border-t pt-6 text-center text-xs text-muted-foreground">
|
|
Made with{" "}
|
|
<a href="/" className="font-semibold text-brand hover:underline">
|
|
Podcast Distribution AI
|
|
</a>{" "}
|
|
— turn any topic into a podcast with AI.
|
|
</footer>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|