diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 7a6837a..565e95c 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -6,10 +6,8 @@ import { type UIMessage, } from "ai"; -import { publishedGameKey } from "~/lib/sandbox-paths"; import { isValidThreadId } from "~/lib/thread-id"; import { recordGameVersion } from "~/server/db/games"; -import { publicObjectUrl } from "~/server/r2"; import { prepareLessonSandbox } from "~/server/sandbox/prepare"; import { createLessonAgent } from "~/mastra/agents/lesson-agent"; import { @@ -82,9 +80,13 @@ export async function POST(req: Request) { userId, model, sandboxPromise, - // Stable for the life of the thread: every publish overwrites this one key, - // so the teacher's link never changes. - publishedUrl: publicObjectUrl(publishedGameKey(userId, threadId)), + // Stable for the life of the thread: /play proxies current/index.html, + // the one key every publish overwrites, so the teacher's link never + // changes. The app route rather than the bucket URL on purpose — the raw + // key embeds the Clerk user id, and this URL is what the model pastes + // into chat. Origin comes from the request so dev and preview deploys + // hand out links on their own host. + publishedUrl: new URL(`/play/${threadId}`, req.url).toString(), // Bound to this thread and user here, so the tool cannot record a version // against anyone else's game — the model never supplies either id. recordVersion: ({ version, label }) => diff --git a/src/app/play/[threadId]/route.ts b/src/app/play/[threadId]/route.ts new file mode 100644 index 0000000..7e3ab84 --- /dev/null +++ b/src/app/play/[threadId]/route.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from "next/server"; + +import { publishedGameKey } from "~/lib/sandbox-paths"; +import { isValidThreadId } from "~/lib/thread-id"; +import { latestGameVersionByThread } from "~/server/db/games"; +import { publicObjectUrl } from "~/server/r2"; + +/** + * The teacher's share link: `/play/`. + * + * The published game physically lives at + * `games///current/index.html` in the bucket — a path that + * bakes the owner's Clerk user id into every URL. Handing that URL out leaks + * the id to whole classrooms, so this route is the one the app shares instead: + * it resolves the owner from the publish index and streams the game through, + * keeping the bucket layout (and the user id) server-side. + * + * Public on purpose (see `middleware.ts`): a share link is opened by students + * who hold no session. The thread id is the only capability, same as the + * bucket's unguessable-path model, and it serves nothing but the built game. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ threadId: string }> }, +) { + const { threadId } = await params; + if (!isValidThreadId(threadId)) return new Response(null, { status: 404 }); + + const row = await latestGameVersionByThread(threadId); + if (!row) return new Response(null, { status: 404 }); + + // `current/index.html`, not the versioned key — the share link must keep + // tracking the newest publish, exactly like the raw current/ URL did. + const url = publicObjectUrl(publishedGameKey(row.userId, threadId)); + if (!url) return new Response(null, { status: 503 }); + + const upstream = await fetch(url, { cache: "no-store" }); + if (!upstream.ok || !upstream.body) return new Response(null, { status: 404 }); + + return new Response(upstream.body, { + headers: { + "Content-Type": "text/html; charset=utf-8", + // A class of thirty opening the link at once should hit the CDN, but a + // republish should show up within a minute — current/ is mutable. + "Cache-Control": "public, max-age=0, s-maxage=60", + // Share links are for people who hold them, not search results. + "X-Robots-Tag": "noindex", + }, + }); +} diff --git a/src/components/chat/chat-header.tsx b/src/components/chat/chat-header.tsx index ab0deea..78a208d 100644 --- a/src/components/chat/chat-header.tsx +++ b/src/components/chat/chat-header.tsx @@ -9,9 +9,11 @@ import { api } from "~/trpc/react"; /** * Thread title, plus the teacher's share link once a game exists. * - * The link is `current/index.html` — the one key every publish overwrites — so - * it keeps working as the game is revised and can be handed to a class once. - * The preview pane deliberately uses the versioned URL instead; see + * The link is `/play/` — the app route that proxies + * `current/index.html`, the one key every publish overwrites — so it keeps + * working as the game is revised, can be handed to a class once, and never + * exposes the bucket path (which embeds the owner's Clerk user id). The + * preview pane deliberately uses the versioned bucket URL instead; see * `GamePreview`. */ export function ChatHeader({ @@ -23,12 +25,16 @@ export function ChatHeader({ }) { const latest = api.games.latest.useQuery({ threadId }); const [copied, setCopied] = useState(false); - const shareUrl = latest.data?.shareUrl ?? null; + const sharePath = latest.data?.sharePath ?? null; const copy = async () => { - if (!shareUrl) return; + if (!sharePath) return; try { - await navigator.clipboard.writeText(shareUrl); + // Absolute at copy time — the clipboard leaves this origin, the href + // below doesn't have to. + await navigator.clipboard.writeText( + new URL(sharePath, window.location.origin).toString(), + ); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { @@ -45,7 +51,7 @@ export function ChatHeader({ {title} - {shareUrl ? ( + {sharePath ? (
v{latest.data?.version} @@ -54,7 +60,7 @@ export function ChatHeader({ {copied ? "Copied" : "Copy link"} diff --git a/src/middleware.ts b/src/middleware.ts index f51903f..5464cfd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -12,6 +12,9 @@ const isPublicRoute = createRouteMatcher([ "/", "/privacy", "/terms", + // The share link. Students opening a teacher's game hold no session, and the + // route serves nothing but the published build — see app/play/[threadId]. + "/play(.*)", "/sign-in(.*)", "/sign-up(.*)", "/robots.txt", diff --git a/src/server/api/routers/games.ts b/src/server/api/routers/games.ts index 928b475..74af595 100644 --- a/src/server/api/routers/games.ts +++ b/src/server/api/routers/games.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { publishedGameKey } from "~/lib/sandbox-paths"; import { isValidThreadId } from "~/lib/thread-id"; import { latestGameVersion, listGameVersions } from "~/server/db/games"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; @@ -31,9 +30,14 @@ export const gamesRouter = createTRPCRouter({ // The VERSIONED key, not current/index.html. Two reasons: this URL is // immutable, so the CDN can cache it forever and no cache-buster is // needed; and the pane can show any version, not only the newest. - // current/ stays the stable link a teacher shares — see `shareUrl`. + // current/ stays the stable target a teacher shares — see `sharePath`. url: publicObjectUrl(row.htmlKey), - shareUrl: publicObjectUrl(publishedGameKey(ctx.userId, input.threadId)), + // A same-origin path, NOT the bucket URL: the raw key embeds the + // Clerk user id, and a share link travels to whole classrooms. The + // /play route proxies current/index.html and keeps the id server-side. + // A path (not an absolute URL) so localhost and preview deploys hand + // out links on their own origin. + sharePath: `/play/${input.threadId}`, }; }), diff --git a/src/server/db/games.ts b/src/server/db/games.ts index aed7239..b2c69a4 100644 --- a/src/server/db/games.ts +++ b/src/server/db/games.ts @@ -62,6 +62,22 @@ export async function latestGameVersion(threadId: string, userId: string) { return row ?? null; } +/** + * The newest version by thread id alone — for the public /play route, where + * there is no signed-in user to filter on. The thread id is an unguessable + * UUID and the row is only ever used to locate the built game in the bucket, + * so this widens what a share link serves, not what a stranger can enumerate. + */ +export async function latestGameVersionByThread(threadId: string) { + const [row] = await db + .select() + .from(gameVersions) + .where(eq(gameVersions.threadId, threadId)) + .orderBy(desc(gameVersions.version)) + .limit(1); + return row ?? null; +} + /** Every version of a thread's game, newest first. */ export async function listGameVersions(threadId: string, userId: string) { return db