Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }) =>
Expand Down
50 changes: 50 additions & 0 deletions src/app/play/[threadId]/route.ts
Original file line number Diff line number Diff line change
@@ -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/<threadId>`.
*
* The published game physically lives at
* `games/<userId>/<threadId>/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",
},
});
}
22 changes: 14 additions & 8 deletions src/components/chat/chat-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<threadId>` — 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({
Expand All @@ -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 {
Expand All @@ -45,7 +51,7 @@ export function ChatHeader({
{title}
</span>
</div>
{shareUrl ? (
{sharePath ? (
<div className="flex shrink-0 items-center gap-1 px-3">
<span className="text-muted-foreground hidden text-[11px] lg:inline">
v{latest.data?.version}
Expand All @@ -54,7 +60,7 @@ export function ChatHeader({
{copied ? "Copied" : "Copy link"}
</Button>
<Button asChild size="sm" variant="ghost">
<a href={shareUrl} rel="noopener noreferrer" target="_blank">
<a href={sharePath} rel="noopener noreferrer" target="_blank">
Open
</a>
</Button>
Expand Down
3 changes: 3 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions src/server/api/routers/games.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`,
};
}),

Expand Down
16 changes: 16 additions & 0 deletions src/server/db/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading