diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 60be654..63ad849 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -8,12 +8,11 @@ import { waypointFromYMap } from "~/lib/waypoint-ymap"; interface SaveToJournalButtonProps { yjs: YjsState; - callbackUrl: string; - callbackToken: string; + sessionId: string; returnUrl?: string; } -export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) { +export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournalButtonProps) { const { t } = useTranslation("planner"); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -47,14 +46,14 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl const notes = yjs.notes.toString() || undefined; const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); - // POST to Journal callback - const response = await fetch(callbackUrl, { + // POST to the planner's server-side proxy. The proxy attaches the + // journal Bearer token (stored on the session row) and forwards + // the GPX. Token never leaves the planner server — see + // routes/api.save-to-journal.ts. + const response = await fetch("/api/save-to-journal", { method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${callbackToken}`, - }, - body: JSON.stringify({ gpx }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, gpx }), }); if (!response.ok) { @@ -68,7 +67,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } finally { setSaving(false); } - }, [yjs, callbackUrl, callbackToken]); + }, [yjs, sessionId]); return (
diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 0cf1936..c393489 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -148,15 +148,20 @@ function SidebarTabs({ yjs, routeStats, days, onWaypointHover, onWaypointSelect interface SessionViewProps { sessionId: string; - callbackUrl?: string; - callbackToken?: string; + /** + * True when the session was created with a journal callback URL + + * token (i.e. the user came in from /journal/.../edit-in-planner). + * The actual URL + token live server-side; the browser only needs + * to know whether to render the Save-to-Journal button. + */ + hasJournalCallback?: boolean; returnUrl?: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>; initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; initialNotes?: string; } -export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) { +export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) { const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas, initialNotes); @@ -253,11 +258,10 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
- {callbackUrl && callbackToken && ( + {hasJournalCallback && ( )} diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index 3fa2ab5..ee58808 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -7,5 +7,6 @@ export default [ route("api/route", "routes/api.route.ts"), route("api/route-segments", "routes/api.route-segments.ts"), route("api/overpass", "routes/api.overpass.ts"), + route("api/save-to-journal", "routes/api.save-to-journal.ts"), route("session/:id", "routes/session.$id.tsx"), ] satisfies RouteConfig; diff --git a/apps/planner/app/routes/api.save-to-journal.ts b/apps/planner/app/routes/api.save-to-journal.ts new file mode 100644 index 0000000..6f7acc5 --- /dev/null +++ b/apps/planner/app/routes/api.save-to-journal.ts @@ -0,0 +1,74 @@ +// Server-side proxy for "Save to Journal". Looks up the session's +// callbackUrl + callbackToken (stored at /new time when the user came +// from the journal) and POSTs the GPX to the journal as a Bearer. +// +// Why this exists (planner-audit #2, Phase A): the previous flow had +// the browser fetch with the bearer token directly, exposing it in +// DevTools / to any XSS / browser extension. Now the token never +// leaves the planner's server-side trust boundary. +// +// Trust model: the same sessionId that grants Yjs membership grants +// save authority. Knowing the URL = ability to act. This matches the +// existing model — we're not strengthening or weakening it, just +// keeping the JWT off the wire to the browser. + +import { data } from "react-router"; +import type { Route } from "./+types/api.save-to-journal"; +import { getSession } from "~/lib/sessions"; +import { fetchWithTimeout } from "~/lib/http.server"; + +interface SaveRequestBody { + sessionId?: unknown; + gpx?: unknown; +} + +const MAX_GPX_BYTES = 5 * 1024 * 1024; // 5 MB — same ceiling as the Yjs doc cap + +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + + let body: SaveRequestBody; + try { + body = (await request.json()) as SaveRequestBody; + } catch { + return data({ error: "Invalid JSON" }, { status: 400 }); + } + + const sessionId = typeof body.sessionId === "string" ? body.sessionId : ""; + const gpx = typeof body.gpx === "string" ? body.gpx : ""; + + if (!sessionId) return data({ error: "sessionId required" }, { status: 400 }); + if (!gpx) return data({ error: "gpx required" }, { status: 400 }); + if (gpx.length > MAX_GPX_BYTES) { + return data({ error: "gpx too large" }, { status: 413 }); + } + + const session = await getSession(sessionId); + if (!session) return data({ error: "session not found" }, { status: 404 }); + if (!session.callbackUrl || !session.callbackToken) { + return data({ error: "session has no journal callback" }, { status: 400 }); + } + + let resp: Response; + try { + resp = await fetchWithTimeout(session.callbackUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.callbackToken}`, + }, + body: JSON.stringify({ gpx }), + }); + } catch { + return data({ error: "journal unreachable" }, { status: 502 }); + } + + // Forward the journal's response (status + body) so the client UI + // can render the same error/success it would have before. + const text = await resp.text(); + let payload: unknown; + try { payload = JSON.parse(text); } catch { payload = { raw: text }; } + return data(payload, { status: resp.status }); +} diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index 99979e7..738a2ee 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -20,10 +20,13 @@ export async function loader({ params }: Route.LoaderArgs) { 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, - callbackUrl: session.callbackUrl ?? null, - callbackToken: session.callbackToken ?? null, + hasJournalCallback: Boolean(session.callbackUrl && session.callbackToken), }); }); } @@ -89,8 +92,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { >