Ownership was checked ad hoc: some handlers loaded-and-compared ownerId, some lib mutators enforced it in WHERE clauses and silently no-op'd for non-owners, and nothing tied the two together. Two real authorization bugs hid in the gaps: linkActivityToRoute ignored its ownerId parameter entirely (any logged-in user could relink any activity), and createRouteFromActivity loaded any activity without an ownership check (a non-owner could copy a private activity's GPX into their own route). PUT /api/v1/routes/:id also returned ok:true for non-owners without updating anything. ownership.server.ts is now the single enforcement point: - loadOwnedRoute / loadOwnedActivity (non-throwing, for callers with their own error vocabulary) and requireOwnedRoute / requireOwnedActivity (throwing data() 404/403 for web handlers; 404 by default so guessed ids don't leak existence) - the returned entities carry an Owned<> brand; mutators (updateRoute, deleteRoute, deleteActivity, updateActivityVisibility, linkActivityToRoute, createRouteFromActivity) now require an OwnedRef, so skipping the check is a compile error - vouchOwnership is the explicit, greppable escape hatch for the one non-session authorization path (the Planner JWT callback) - WHERE ownerId clauses stay in the mutators as defense in depth Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
49 lines
1.8 KiB
TypeScript
49 lines
1.8 KiB
TypeScript
// Server-only loader/action for /routes/:id/edit. See `home.server.ts`.
|
|
|
|
import { redirect } from "react-router";
|
|
import { requireSessionUser } from "~/lib/auth/session.server";
|
|
import { updateRoute } from "~/lib/routes.server";
|
|
import { requireOwnedRoute } from "~/lib/ownership.server";
|
|
import type { Visibility } from "@trails-cool/db/schema/journal";
|
|
|
|
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
|
|
|
export async function loadRouteEdit(request: Request, id: string | undefined) {
|
|
const user = await requireSessionUser(request);
|
|
|
|
const route = await requireOwnedRoute(id ?? "", user.id, { notOwnerStatus: 403 });
|
|
|
|
return {
|
|
route: {
|
|
id: route.id,
|
|
name: route.name,
|
|
description: route.description,
|
|
visibility: route.visibility,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function routeEditAction(request: Request, id: string | undefined) {
|
|
const user = await requireSessionUser(request);
|
|
const routeId = id ?? "";
|
|
|
|
const formData = await request.formData();
|
|
const name = formData.get("name") as string;
|
|
const description = formData.get("description") as string;
|
|
const gpxFile = formData.get("gpx") as File | null;
|
|
const visibilityRaw = formData.get("visibility") as string | null;
|
|
|
|
const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {};
|
|
if (name) input.name = name;
|
|
if (description !== null) input.description = description;
|
|
if (gpxFile && gpxFile.size > 0) {
|
|
input.gpx = await gpxFile.text();
|
|
}
|
|
if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) {
|
|
input.visibility = visibilityRaw as Visibility;
|
|
}
|
|
|
|
const route = await requireOwnedRoute(routeId, user.id, { notOwnerStatus: 403 });
|
|
await updateRoute(route, input);
|
|
return redirect(`/routes/${routeId}`);
|
|
}
|