The Save-to-Journal flow had the browser fetch the journal with a
\`Bearer \${callbackToken}\` header. The JWT was visible in DevTools,
exfiltratable via any XSS or browser extension, and the planner's
\`loader\` shipped it down to the client as part of the page payload.
Now:
- **New action**: \`POST /api/save-to-journal\` (\`routes/api.save-to-journal.ts\`).
Body: \`{ sessionId, gpx }\`. The action loads \`callbackUrl\` +
\`callbackToken\` from \`planner.sessions\` (set at /new time when the
user came from the journal), POSTs to the journal server-to-server
with the Bearer, and forwards the response.
- **\`SaveToJournalButton\`**: drops the \`callbackUrl\` + \`callbackToken\`
props. Takes \`sessionId\` only and POSTs to the planner action.
- **\`session.\$id.tsx\` loader**: stops returning \`callbackUrl\` /
\`callbackToken\` to the client. Returns a single \`hasJournalCallback\`
boolean so the button still knows whether to render.
- **\`SessionView\`**: same prop simplification.
Trust model is unchanged: the same \`sessionId\` that grants Yjs
membership grants save authority. Knowing the URL = ability to act.
The action only adds a server-side hop so the JWT never reaches
browser JS.
Phase B (jti single-use enforcement on the journal side) follows in
a separate PR — needs a journal DB column + verifier change.
Full repo: pnpm typecheck / lint / test all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
import { useParams, useSearchParams } from "react-router";
|
|
import type { Route } from "./+types/session.$id";
|
|
import { getSession } from "~/lib/sessions";
|
|
import { data } from "react-router";
|
|
import { withDb } from "@trails-cool/db";
|
|
import { ClientOnly } from "~/components/ClientOnly";
|
|
import { lazy, Suspense } from "react";
|
|
|
|
const SessionView = lazy(() =>
|
|
import("~/components/SessionView").then((m) => ({ default: m.SessionView })),
|
|
);
|
|
|
|
export function meta(_args: Route.MetaArgs) {
|
|
return [{ title: "trails.cool Planner — Session" }];
|
|
}
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
return withDb(async () => {
|
|
const session = await getSession(params.id);
|
|
if (!session) {
|
|
throw data({ error: "Session not found" }, { status: 404 });
|
|
}
|
|
// Don't leak the JWT token to the client. The save flow uses
|
|
// /api/save-to-journal, which loads token + URL from the DB
|
|
// server-side. The browser only needs to know whether the button
|
|
// should render.
|
|
return data({
|
|
sessionId: session.id,
|
|
hasJournalCallback: Boolean(session.callbackUrl && session.callbackToken),
|
|
});
|
|
});
|
|
}
|
|
|
|
// URL-param caps prevent a hostile link from feeding multi-MB JSON
|
|
// into JSON.parse on the client (and from there into the Yjs doc).
|
|
// Picked generously enough to accept realistic multi-day routes
|
|
// (~hundreds of waypoints) but small enough to refuse abuse.
|
|
const MAX_WAYPOINTS_LEN = 50_000;
|
|
const MAX_NOGO_LEN = 50_000;
|
|
const MAX_NOTES_LEN = 10_000;
|
|
const MAX_RETURN_URL_LEN = 2048;
|
|
|
|
function safeParseJson<T>(raw: string, maxLen: number): T | undefined {
|
|
if (raw.length > maxLen) return undefined;
|
|
try { return JSON.parse(raw) as T; } catch { return undefined; }
|
|
}
|
|
|
|
function safeReturnUrl(raw: string | null): string | undefined {
|
|
if (!raw || raw.length > MAX_RETURN_URL_LEN) return undefined;
|
|
// Same-origin relative path or http(s) absolute URL only. Rejects
|
|
// `javascript:` and protocol-relative `//host` so the value is safe
|
|
// to drop into an `<a href>`.
|
|
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
|
|
try {
|
|
const parsed = new URL(raw);
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") return raw;
|
|
} catch { /* fall through */ }
|
|
return undefined;
|
|
}
|
|
|
|
export default function SessionPage({ loaderData }: Route.ComponentProps) {
|
|
const { id } = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
const returnUrl = safeReturnUrl(searchParams.get("returnUrl"));
|
|
const waypointsParam = searchParams.get("waypoints");
|
|
const initialWaypoints = waypointsParam
|
|
? safeParseJson<Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>>(waypointsParam, MAX_WAYPOINTS_LEN)
|
|
: undefined;
|
|
const noGoParam = searchParams.get("noGoAreas");
|
|
const initialNoGoAreas = noGoParam
|
|
? safeParseJson<Array<{ points: Array<{ lat: number; lon: number }> }>>(noGoParam, MAX_NOGO_LEN)
|
|
: undefined;
|
|
const notesRaw = searchParams.get("notes");
|
|
const initialNotes = notesRaw && notesRaw.length <= MAX_NOTES_LEN ? notesRaw : undefined;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<ClientOnly
|
|
fallback={
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-gray-500">Loading planner...</p>
|
|
</div>
|
|
}
|
|
>
|
|
{() => (
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-gray-500">Loading planner...</p>
|
|
</div>
|
|
}
|
|
>
|
|
<SessionView
|
|
sessionId={id!}
|
|
hasJournalCallback={loaderData.hasJournalCallback}
|
|
returnUrl={returnUrl}
|
|
initialWaypoints={initialWaypoints}
|
|
initialNoGoAreas={initialNoGoAreas}
|
|
initialNotes={initialNotes}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</ClientOnly>
|
|
</div>
|
|
);
|
|
}
|