diff --git a/apps/web/src/app/api/oembed/route.ts b/apps/web/src/app/api/oembed/route.ts new file mode 100644 index 00000000..95e0bb8b --- /dev/null +++ b/apps/web/src/app/api/oembed/route.ts @@ -0,0 +1,103 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { EMBED_HEIGHT, EMBED_WIDTH, SITE_URL, iframeSnippet, joinCodeFromUrl } from '@/lib/embed'; +import type { PublicSessionDetail } from '@pairux/shared-types'; + +/** + * oEmbed provider endpoint (https://oembed.com) for PairUX lives. + * + * Consumers (Slack, Notion, WordPress, Ghost, Discord) discover this via the + * tag on /l/ + * and turn a pasted permalink into the embedded player. + */ + +export const dynamic = 'force-dynamic'; + +/** Height of the title bar under the 16:9 video in the embed page. */ +const CHROME_HEIGHT = EMBED_HEIGHT - Math.round((EMBED_WIDTH * 9) / 16); + +async function getSession(joinCode: string): Promise { + try { + const supabase = await createClient(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + const { data, error } = await (supabase.rpc as any)('get_public_session', { + p_join_code: joinCode, + }); + if (error) return null; + return (data as PublicSessionDetail[] | null)?.[0] ?? null; + } catch { + return null; + } +} + +/** Parse a positive integer query param, ignoring junk. */ +function positiveInt(raw: string | null): number | null { + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const url = searchParams.get('url'); + if (!url) { + return NextResponse.json({ error: 'Missing required "url" parameter' }, { status: 400 }); + } + + // The spec allows providers to support only json; anything else is a 501. + const format = searchParams.get('format'); + if (format && format !== 'json') { + return NextResponse.json({ error: `Unsupported format "${format}"` }, { status: 501 }); + } + + const joinCode = joinCodeFromUrl(url); + if (!joinCode) { + return NextResponse.json({ error: 'Not a PairUX live URL' }, { status: 404 }); + } + + const session = await getSession(joinCode); + if (!session) { + return NextResponse.json({ error: 'Live not found' }, { status: 404 }); + } + + // Honour maxwidth/maxheight, keeping the 16:9 video plus the title bar. + const maxWidth = positiveInt(searchParams.get('maxwidth')); + const maxHeight = positiveInt(searchParams.get('maxheight')); + let width = Math.min(maxWidth ?? EMBED_WIDTH, EMBED_WIDTH); + let height = Math.round((width * 9) / 16) + CHROME_HEIGHT; + if (maxHeight && height > maxHeight) { + height = maxHeight; + width = Math.round(((height - CHROME_HEIGHT) * 16) / 9); + } + + const title = session.subject ?? 'Live on PairUX'; + const authorName = session.channel_name ?? session.host_display_name ?? session.host_username; + const authorUrl = session.channel_handle + ? `${SITE_URL}/@${session.channel_handle}` + : session.host_username + ? `${SITE_URL}/u/${session.host_username}` + : null; + + const payload: Record = { + type: 'video', + version: '1.0', + provider_name: 'PairUX', + provider_url: SITE_URL, + title, + html: iframeSnippet(session.join_code, { width, height, title }), + width, + height, + }; + if (authorName) payload.author_name = authorName; + if (authorUrl) payload.author_url = authorUrl; + if (session.banner_url) payload.thumbnail_url = session.banner_url; + + return NextResponse.json(payload, { + headers: { + // Live state changes; a short cache keeps unfurl services from hammering us + // without pinning a stale "Live now" for long. + 'cache-control': 'public, max-age=60, s-maxage=60', + }, + }); +} diff --git a/apps/web/src/app/c/[handle]/page.tsx b/apps/web/src/app/c/[handle]/page.tsx index cf8532e8..326486fa 100644 --- a/apps/web/src/app/c/[handle]/page.tsx +++ b/apps/web/src/app/c/[handle]/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { Radio, Eye, Circle, PlayCircle, User as UserIcon } from 'lucide-react'; +import { Radio, Eye, Circle, PlayCircle, Rss, User as UserIcon } from 'lucide-react'; import { Header } from '@/components/header'; import { Footer } from '@/components/footer'; import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { renderDescriptionHtml } from '@/lib/markdown'; +import { SITE_URL, clockDuration } from '@/lib/embed'; import type { Channel, ChannelStream, ChannelRecording } from '@pairux/shared-types'; import { SubscribeButton } from './SubscribeButton'; import { ShareButtons } from './ShareButtons'; @@ -66,14 +67,8 @@ async function getRecordings(handle: string): Promise { } } -function formatDuration(seconds: number | null): string | null { - if (!seconds || seconds < 1) return null; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - const pad = (n: number): string => n.toString().padStart(2, '0'); - return h > 0 ? `${String(h)}:${pad(m)}:${pad(s)}` : `${String(m)}:${pad(s)}`; -} +// Shared with the RSS feed's itunes:duration so both read the same. +const formatDuration = clockDuration; export async function generateMetadata({ params }: PageProps): Promise { const { handle } = await params; @@ -91,7 +86,13 @@ export async function generateMetadata({ params }: PageProps): Promise return { title, description, - alternates: { canonical: url }, + alternates: { + canonical: url, + // Feed readers and podcast apps autodiscover the channel's back catalogue. + types: { + 'application/rss+xml': `${SITE_URL}/c/${encodeURIComponent(ch.handle)}/rss.xml`, + }, + }, openGraph: { title, description, @@ -282,10 +283,20 @@ export default async function ChannelPage({ params }: PageProps) { {recordings.length > 0 && (
-

- - Recordings -

+
+

+ + Recordings +

+ + + RSS + +
{recordings.map((r) => { const dur = formatDuration(r.duration_seconds); diff --git a/apps/web/src/app/c/[handle]/rss.xml/route.ts b/apps/web/src/app/c/[handle]/rss.xml/route.ts new file mode 100644 index 00000000..805f7ed3 --- /dev/null +++ b/apps/web/src/app/c/[handle]/rss.xml/route.ts @@ -0,0 +1,126 @@ +import { createClient } from '@/lib/supabase/server'; +import { SITE_URL, clockDuration, escapeXml, liveUrl } from '@/lib/embed'; + +/** + * Per-channel RSS feed of finished recordings. + * + * This is what makes a PairUX channel subscribable outside pairux.com — the + * itunes:* tags and the on each item let podcast apps (Apple + * Podcasts, Overcast, Pocket Casts) and ordinary feed readers treat a channel + * as a show whose episodes are its past lives. + */ + +export const dynamic = 'force-dynamic'; + +interface ChannelRow { + id: string; + handle: string; + name: string; + description: string | null; + avatar_url: string | null; + banner_url: string | null; +} + +interface RecordingRow { + id: string; + join_code: string; + subject: string | null; + banner_url: string | null; + playback_url: string; + duration_seconds: number | null; + created_at: string; +} + +async function getChannel(handle: string): Promise { + try { + const supabase = await createClient(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + const { data, error } = await (supabase.rpc as any)('get_channel', { p_handle: handle }); + if (error) return null; + return (data as ChannelRow[] | null)?.[0] ?? null; + } catch { + return null; + } +} + +async function getRecordings(handle: string): Promise { + try { + const supabase = await createClient(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + const { data, error } = await (supabase.rpc as any)('list_channel_recordings', { + p_handle: handle, + p_limit: 100, + }); + if (error) return []; + return (data as RecordingRow[] | null) ?? []; + } catch { + return []; + } +} + +function buildItem(channel: ChannelRow, r: RecordingRow): string { + const title = r.subject ?? 'Untitled live'; + const permalink = liveUrl(r.join_code); + const image = r.banner_url ?? channel.banner_url ?? channel.avatar_url; + const duration = clockDuration(r.duration_seconds); + + return [ + ' ', + ` ${escapeXml(title)}`, + ` ${escapeXml(permalink)}`, + ` ${escapeXml(r.id)}`, + ` ${new Date(r.created_at).toUTCString()}`, + ` ${escapeXml(`${title} — a live from ${channel.name} on PairUX.`)}`, + // length is required by the RSS spec but the public RPC does not expose + // size_bytes; 0 is the conventional "unknown" and clients tolerate it. + ` `, + duration ? ` ${duration}` : null, + image ? ` ` : null, + ` ${escapeXml(channel.name)}`, + ' ', + ] + .filter((line): line is string => line !== null) + .join('\n'); +} + +export async function GET(_request: Request, { params }: { params: Promise<{ handle: string }> }) { + const { handle } = await params; + + const channel = await getChannel(handle); + if (!channel) { + return new Response('Channel not found', { status: 404 }); + } + + const recordings = await getRecordings(handle); + const channelUrl = `${SITE_URL}/@${channel.handle}`; + const feedUrl = `${SITE_URL}/c/${encodeURIComponent(channel.handle)}/rss.xml`; + const description = channel.description ?? `Past lives from ${channel.name}, recorded on PairUX.`; + const artwork = channel.avatar_url ?? channel.banner_url; + + const xml = [ + '', + '', + ' ', + ` ${escapeXml(channel.name)}`, + ` ${escapeXml(channelUrl)}`, + ` ${escapeXml(description)}`, + ' en', + ` `, + ` ${escapeXml(channel.name)}`, + ` ${escapeXml(description)}`, + ' false', + artwork ? ` ` : null, + ...recordings.map((r) => buildItem(channel, r)), + ' ', + '', + ] + .filter((line): line is string => line !== null) + .join('\n'); + + return new Response(xml, { + headers: { + 'content-type': 'application/rss+xml; charset=utf-8', + 'cache-control': 'public, max-age=300, s-maxage=300', + }, + }); +} diff --git a/apps/web/src/app/embed/[joinCode]/page.tsx b/apps/web/src/app/embed/[joinCode]/page.tsx new file mode 100644 index 00000000..72ce1e0b --- /dev/null +++ b/apps/web/src/app/embed/[joinCode]/page.tsx @@ -0,0 +1,136 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Circle, Play } from 'lucide-react'; +import { createClient } from '@/lib/supabase/server'; +import { SITE_URL, liveUrl } from '@/lib/embed'; +import type { PublicSessionDetail } from '@pairux/shared-types'; + +export const dynamic = 'force-dynamic'; + +interface PageProps { + params: Promise<{ joinCode: string }>; +} + +async function getSession(joinCode: string): Promise { + try { + const supabase = await createClient(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + const { data, error } = await (supabase.rpc as any)('get_public_session', { + p_join_code: joinCode, + }); + if (error) return null; + return (data as PublicSessionDetail[] | null)?.[0] ?? null; + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { joinCode } = await params; + const session = await getSession(joinCode); + return { + title: session?.subject ?? 'PairUX player', + // The permalink at /l/ is the canonical, indexable page. The + // player is a bare duplicate of it, so keep it out of search results. + robots: { index: false, follow: false }, + }; +} + +export default async function EmbedPlayerPage({ params }: PageProps) { + const { joinCode } = await params; + const session = await getSession(joinCode); + if (!session) notFound(); + + const permalink = liveUrl(session.join_code); + const title = session.subject ?? 'Untitled live'; + const byline = session.channel_name ?? session.host_display_name ?? session.host_username ?? null; + // Absolute, because these links open out of the iframe into a new tab. + const channelUrl = session.channel_handle + ? `${SITE_URL}/@${session.channel_handle}` + : session.host_username + ? `${SITE_URL}/u/${session.host_username}` + : permalink; + const showRecording = Boolean(session.recording_url) && !session.is_live; + + return ( +
+
+ {showRecording ? ( + + ) : ( + + {session.banner_url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + +
+ {session.is_live && ( + + + Live + + )} +
+ + {title} + + {byline && ( + + {byline} + + )} +
+ + PairUX + +
+
+ ); +} diff --git a/apps/web/src/app/l/[joinCode]/EmbedButton.tsx b/apps/web/src/app/l/[joinCode]/EmbedButton.tsx new file mode 100644 index 00000000..e08b31aa --- /dev/null +++ b/apps/web/src/app/l/[joinCode]/EmbedButton.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useState } from 'react'; +import { Code2, Copy, Check } from 'lucide-react'; + +interface EmbedButtonProps { + /** Ready-made ` + ); +} + +/** Seconds → ISO-8601 duration (`PT1H2M3S`), as schema.org and RSS expect. */ +export function isoDuration(seconds: number | null | undefined): string | null { + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return null; + const total = Math.floor(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const hourPart = h > 0 ? `${String(h)}H` : ''; + const minutePart = m > 0 ? `${String(m)}M` : ''; + // Always emit a seconds component for sub-minute durations, so we never + // produce a bare "PT". + const secondPart = s > 0 || (h === 0 && m === 0) ? `${String(s)}S` : ''; + return `PT${hourPart}${minutePart}${secondPart}`; +} + +/** Seconds → `H:MM:SS` / `M:SS`, the itunes:duration form podcast apps show. */ +export function clockDuration(seconds: number | null | undefined): string | null { + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return null; + const total = Math.floor(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = (n: number): string => String(n).padStart(2, '0'); + return h > 0 ? `${String(h)}:${pad(m)}:${pad(s)}` : `${String(m)}:${pad(s)}`; +} + +/** + * Pull the join code out of a PairUX permalink or embed URL. Returns null for + * anything that isn't ours — oEmbed consumers can and do send arbitrary URLs. + */ +export function joinCodeFromUrl(rawUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null; + + const allowedHosts = new Set(['pairux.com', 'www.pairux.com']); + try { + allowedHosts.add(new URL(SITE_URL).host); + } catch { + /* SITE_URL is a constant we control; ignore a malformed override */ + } + if (!allowedHosts.has(parsed.host)) return null; + + const match = /^\/(?:l|embed)\/([A-Za-z0-9_-]{1,64})\/?$/.exec(parsed.pathname); + return match?.[1] ?? null; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 619d7a6e..ebb6e3ea 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -6,15 +6,21 @@ import { CORS_HEADERS } from '@/lib/cors'; // can carry a fresh script nonce — that lets us drop 'unsafe-inline' from // script-src. Next.js reads the nonce from the request's CSP header and applies // it to its inline bootstrap scripts; our own inline