Files
podcastdistributiona/app/(public)/p/[shareId]/page.tsx
T
Leon SerfatyandClaude Opus 5 3e9ba07175 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>
2026-09-07 11:10:55 -04:00

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>
);
}