diff --git a/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts b/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts index c5d6090d0..9372840fa 100644 --- a/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts +++ b/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts @@ -13,6 +13,14 @@ export const mapModeAtom = atom("private"); */ export const isPublicMapRouteAtom = atom(false); +/** + * `true` on the read-only shared map page (`/share/[token]`), `false` + * everywhere else. Components use `useMapEditable()` to hide editing + * affordances, and `useMapViews` skips server writes so view-config + * changes (map style, timeline) stay client-side only. + */ +export const isReadOnlyRouteAtom = atom(false); + /** Derived: the navbar is visible exactly when we are NOT on the public route. */ export const showNavbarAtom = atom( (get) => !get(isPublicMapRouteAtom), diff --git a/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx b/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx index be9da4591..da095dbc1 100644 --- a/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx +++ b/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMapEditable } from "../../hooks/useMapEditable"; import { useOpenInspectorConfig } from "../../hooks/useOpenInspectorConfig"; import DataRecordsPanel from "./DataRecordsPanel"; import { InspectorConfigModal } from "./InspectorConfigModal"; @@ -20,6 +21,7 @@ export default function ConfigurableDataRecordsPanel({ }) { const { config, isModalOpen, setIsModalOpen, openConfig, onUpdateConfig } = useOpenInspectorConfig(dataSourceId); + const editable = useMapEditable(); return ( <> @@ -29,7 +31,7 @@ export default function ConfigurableDataRecordsPanel({ isLoading={isLoading} defaultExpanded={defaultExpanded} hint={hint} - onClickConfigure={openConfig} + onClickConfigure={editable ? openConfig : undefined} /> {config && ( {dataSource.name}

- + {editable && ( + + )} )} @@ -311,15 +315,17 @@ export default function InspectorDataTab() { defaultExpanded={index === 0} /> ))} - + {editable && ( + + )} diff --git a/src/app/(private)/map/[id]/components/InspectorPanel/InspectorPanel.tsx b/src/app/(private)/map/[id]/components/InspectorPanel/InspectorPanel.tsx index 4f2b36d7f..3048920c5 100644 --- a/src/app/(private)/map/[id]/components/InspectorPanel/InspectorPanel.tsx +++ b/src/app/(private)/map/[id]/components/InspectorPanel/InspectorPanel.tsx @@ -17,6 +17,7 @@ import { useDisplayAreaStat } from "@/app/(private)/map/[id]/hooks/useDisplayAre import { useInspectorContent } from "@/app/(private)/map/[id]/hooks/useInspector"; import { useInspectorState } from "@/app/(private)/map/[id]/hooks/useInspectorState"; import { useMapRef } from "@/app/(private)/map/[id]/hooks/useMapCore"; +import { useMapEditable } from "@/app/(private)/map/[id]/hooks/useMapEditable"; import { useSelectedAreas } from "@/app/(private)/map/[id]/hooks/useSelectedAreas"; import { useSelectedSecondaryArea } from "@/app/(private)/map/[id]/hooks/useSelectedSecondaryArea"; import { useTable } from "@/app/(private)/map/[id]/hooks/useTable"; @@ -55,6 +56,7 @@ export default function InspectorPanel() { const [selectedSecondaryArea] = useSelectedSecondaryArea(); const [selectedAreas, setSelectedAreas] = useSelectedAreas(); const organisationId = useOrganisationId(); + const editable = useMapEditable(); // Selecting something new re-opens a minimised inspector const contentKey = `${type ?? ""}:${String(inspectorContent?.name ?? "")}`; @@ -365,14 +367,16 @@ export default function InspectorPanel() { {type === LayerType.Boundary && (
- + {editable && ( + + )} )} - {dataSource && ( + {dataSource && editable && ( + ))} +
+ ); +} diff --git a/src/app/(private)/map/[id]/components/readonly/ReadOnlyNavbar.tsx b/src/app/(private)/map/[id]/components/readonly/ReadOnlyNavbar.tsx new file mode 100644 index 000000000..f994cd12d --- /dev/null +++ b/src/app/(private)/map/[id]/components/readonly/ReadOnlyNavbar.tsx @@ -0,0 +1,42 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Navbar from "@/components/layout/Navbar"; +import { useMapId } from "../../hooks/useMapCore"; +import { useMapQuery } from "../../hooks/useMapQuery"; +import ReadOnlyMapViews from "./ReadOnlyMapViews"; + +/** + * Navbar for the read-only shared map page: map name, view switcher and + * area search. None of the private navbar's editing affordances — and + * crucially none of its side effects (initial-view creation, thumbnail + * upload), which write to the map. + */ +export default function ReadOnlyNavbar() { + const mapId = useMapId(); + const { data: map } = useMapQuery(mapId); + + return ( +
+ +
+
+

+ {map ? map.name : "Loading..."} +

+ +
+ +
+
+
+ ); +} + +const SearchBox = dynamic( + () => import("../SearchBox").then((mod) => ({ default: mod.SearchBox })), + { + ssr: false, + loading: () => null, + }, +); diff --git a/src/app/(private)/map/[id]/hooks/useMapEditable.ts b/src/app/(private)/map/[id]/hooks/useMapEditable.ts new file mode 100644 index 000000000..f2e161b93 --- /dev/null +++ b/src/app/(private)/map/[id]/hooks/useMapEditable.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { isReadOnlyRouteAtom } from "../atoms/mapStateAtoms"; + +/** `true` on the read-only shared map page (`/share/[token]`). */ +export function useIsReadOnlyRoute() { + return useAtomValue(isReadOnlyRouteAtom); +} + +/** + * Whether the current viewer may edit the map. `false` only on the + * read-only shared map page — components use this to hide editing + * affordances (inspector config, add-to-areas, view-in-table, etc.). + */ +export function useMapEditable() { + return !useAtomValue(isReadOnlyRouteAtom); +} diff --git a/src/app/(private)/map/[id]/hooks/useMapViews.ts b/src/app/(private)/map/[id]/hooks/useMapViews.ts index 6c21441dc..9eeef07bc 100644 --- a/src/app/(private)/map/[id]/hooks/useMapViews.ts +++ b/src/app/(private)/map/[id]/hooks/useMapViews.ts @@ -12,6 +12,7 @@ import { createNewViewConfig } from "../utils/mapView"; import { getNewLastPosition } from "../utils/position"; import { useDebouncedCallback } from "./useDebouncedCallback"; import { useMapId } from "./useMapCore"; +import { useIsReadOnlyRoute } from "./useMapEditable"; import { useMapQuery } from "./useMapQuery"; import type { View } from "../types"; @@ -23,6 +24,11 @@ export function useMapViews() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: mapData } = useMapQuery(mapId); + // On the read-only shared map page, view-config changes (map style, + // timeline range) update the query cache for instant feedback but are + // never persisted — anonymous viewers cannot write, and their tweaks + // should reset on reload. + const isReadOnlyRoute = useIsReadOnlyRoute(); // Get views directly from cache const views = mapData?.views; @@ -73,7 +79,7 @@ export function useMapViews() { const insertView = useCallback( (view: Omit) => { - if (!mapId) return; + if (!mapId || isReadOnlyRoute) return; const newView = { ...view, @@ -102,6 +108,7 @@ export function useMapViews() { }, [ mapId, + isReadOnlyRoute, views, setViewId, setDirtyViewIds, @@ -155,7 +162,9 @@ export function useMapViews() { const updatedViews = views?.map((v) => (v.id === view.id ? view : v)) || []; - setDirtyViewIds((ids) => ids.concat([view.id])); + if (!isReadOnlyRoute) { + setDirtyViewIds((ids) => ids.concat([view.id])); + } // Synchronously update cache BEFORE calling mutation for instant UI feedback queryClient.setQueryData(trpc.map.byId.queryKey({ mapId }), (old) => { @@ -171,10 +180,13 @@ export function useMapViews() { }; }); - updateViewMutate({ mapId, views: updatedViews }); + if (!isReadOnlyRoute) { + updateViewMutate({ mapId, views: updatedViews }); + } }, [ mapId, + isReadOnlyRoute, setDirtyViewIds, queryClient, trpc.map.byId, @@ -247,7 +259,7 @@ export function useMapViews() { const deleteView = useCallback( (viewId: string) => { - if (!mapId) return; + if (!mapId || isReadOnlyRoute) return; // Synchronously update cache BEFORE calling mutation for instant UI feedback queryClient.setQueryData(trpc.map.byId.queryKey({ mapId }), (old) => { @@ -260,7 +272,7 @@ export function useMapViews() { deleteViewMutate({ mapId, viewId }); }, - [mapId, queryClient, trpc.map.byId, deleteViewMutate], + [mapId, isReadOnlyRoute, queryClient, trpc.map.byId, deleteViewMutate], ); return { diff --git a/src/app/(private)/map/[id]/hooks/useMarkerQueries.ts b/src/app/(private)/map/[id]/hooks/useMarkerQueries.ts index d3495a480..271a145fc 100644 --- a/src/app/(private)/map/[id]/hooks/useMarkerQueries.ts +++ b/src/app/(private)/map/[id]/hooks/useMarkerQueries.ts @@ -41,8 +41,9 @@ export function useMarkerQueries() { // Columns used by this view's marker styling must be present on the // marker features for Mapbox match expressions to read them. Only - // honoured by the server for authenticated readers. Sorted so the - // query key is stable regardless of config field order. + // honoured by the server for authenticated readers and shared-map + // viewers whose grant covers the data source. Sorted so the query + // key is stable regardless of config field order. const visualisation = view?.config.markerVisualisations?.[dataSourceId]; const propertyColumns = [ ...new Set( diff --git a/src/app/(private)/map/[id]/providers/MapJotaiProvider.tsx b/src/app/(private)/map/[id]/providers/MapJotaiProvider.tsx index cd3fd0835..46e155114 100644 --- a/src/app/(private)/map/[id]/providers/MapJotaiProvider.tsx +++ b/src/app/(private)/map/[id]/providers/MapJotaiProvider.tsx @@ -6,6 +6,7 @@ import { useSearchParams } from "next/navigation"; import { type MapMode, isPublicMapRouteAtom, + isReadOnlyRouteAtom, mapIdAtom, mapModeAtom, viewIdAtom, @@ -18,16 +19,21 @@ import type { ReactNode } from "react"; * `isPublicMapRoute` is `true` only on the standalone public page (`/public/[host]`). * The navbar visibility and editor-mode flag are derived from this single boolean, * and `mapMode` is derived from the URL. + * + * `isReadOnlyRoute` is `true` only on the shared map page (`/share/[token]`), + * where anonymous viewers see the private map without editing affordances. */ export default function MapJotaiProvider({ mapId, viewId, isPublicMapRoute = false, + isReadOnlyRoute = false, children, }: { mapId: string; viewId?: string; isPublicMapRoute?: boolean; + isReadOnlyRoute?: boolean; children: ReactNode; }) { return ( @@ -36,6 +42,7 @@ export default function MapJotaiProvider({ mapId={mapId} viewId={viewId} isPublicMapRoute={isPublicMapRoute} + isReadOnlyRoute={isReadOnlyRoute} > {children} @@ -47,11 +54,13 @@ function HydrateAtoms({ mapId, viewId, isPublicMapRoute, + isReadOnlyRoute, children, }: { mapId: string; viewId?: string; isPublicMapRoute: boolean; + isReadOnlyRoute: boolean; children: ReactNode; }) { // On the private route, derive mapMode + viewId from the URL so the very @@ -71,6 +80,7 @@ function HydrateAtoms({ [viewIdAtom, resolvedViewId || null], [mapModeAtom, resolvedMapMode], [isPublicMapRouteAtom, isPublicMapRoute], + [isReadOnlyRouteAtom, isReadOnlyRoute], ]); return children; diff --git a/src/app/api/data-sources/[id]/markers/route.ts b/src/app/api/data-sources/[id]/markers/route.ts index c07e75b59..be49ca651 100644 --- a/src/app/api/data-sources/[id]/markers/route.ts +++ b/src/app/api/data-sources/[id]/markers/route.ts @@ -4,9 +4,13 @@ import { getShareGrants } from "@/auth/shareGrants"; import { MARKER_MATCHED_COLUMN } from "@/constants"; import { streamDataRecordsByDataSource } from "@/server/repositories/DataRecord"; import { findDataSourceById } from "@/server/repositories/DataSource"; +import { findMapShareVisualisingDataSource } from "@/server/repositories/MapShare"; import { findOrganisationForUser } from "@/server/repositories/Organisation"; import { findPublicMapByViewId } from "@/server/repositories/PublicMap"; -import { canReadDataSource } from "@/server/utils/auth"; +import { + canReadDataSource, + getValidShareGrantMapIds, +} from "@/server/utils/auth"; import { closeRecordStream } from "@/server/utils/stream"; import { buildName, @@ -72,15 +76,28 @@ export async function GET( // Marker styling (icon/size/colour by column) needs the raw column values on // the features. The column list is client-supplied but only honoured for - // authenticated users: `canReadDataSource` passes anonymous requests for - // public data sources and published public maps, and those must stay on - // minimal properties. Authenticated readers can already fetch full record - // JSON via tRPC, so this grants them nothing new. - const includeProperties = currentUser?.id - ? parseIncludeProperties( - request?.nextUrl?.searchParams.get("properties") || null, - ) - : []; + // authenticated users and shared-map viewers whose grant covers this data + // source: `canReadDataSource` also passes anonymous requests for public + // data sources and published public maps, and those must stay on minimal + // properties. Grant holders and authenticated readers can already fetch + // full record JSON via tRPC, so this grants them nothing new. + const sharedMapIds = currentUser?.id + ? [] + : await getValidShareGrantMapIds(shareGrants); + const grantCoversDataSource = + sharedMapIds.length > 0 && + Boolean( + await findMapShareVisualisingDataSource({ + dataSourceId: dataSource.id, + mapIds: sharedMapIds, + }), + ); + const includeProperties = + currentUser?.id || grantCoversDataSource + ? parseIncludeProperties( + request?.nextUrl?.searchParams.get("properties") || null, + ) + : []; // Timeline filtering: when the data source declares a date column, expose // the record's month key, derived from the date parsed at import time diff --git a/src/app/api/share/[token]/claim/route.ts b/src/app/api/share/[token]/claim/route.ts new file mode 100644 index 000000000..db7df4f2c --- /dev/null +++ b/src/app/api/share/[token]/claim/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { addShareGrant } from "@/auth/shareGrants"; +import { findMapShareByToken } from "@/server/repositories/MapShare"; +import type { NextRequest } from "next/server"; + +/** + * Mints the grant cookie for a passwordless share. Called by the share + * page's `ShareClaim` component (Next.js forbids setting cookies during + * page render); the client then refreshes so the server component sees + * the grant. Password-protected shares are never minted here — the page + * shows the password form, and the verify endpoint mints instead. + */ +export async function POST( + _request: NextRequest, + args: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await args.params; + const share = await findMapShareByToken(token); + if (!share || !share.enabled) { + return new NextResponse("Not found", { status: 404 }); + } + if (share.passwordHash) { + return new NextResponse("Password required", { status: 401 }); + } + + await addShareGrant({ shareId: share.id, mapId: share.mapId }); + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/share/[token]/verify/route.ts b/src/app/api/share/[token]/verify/route.ts new file mode 100644 index 000000000..c24017028 --- /dev/null +++ b/src/app/api/share/[token]/verify/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { addShareGrant } from "@/auth/shareGrants"; +import { findMapShareByToken } from "@/server/repositories/MapShare"; +import { + checkSharePasswordAttempt, + getClientIp, +} from "@/server/services/ratelimit"; +import { verifyPassword } from "@/server/utils/auth"; +import type { NextRequest } from "next/server"; + +/** + * Verifies a shared map's password and mints the grant cookie on success. + * Rate-limited per IP + token to resist password guessing. + */ +export async function POST( + request: NextRequest, + args: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await args.params; + const share = await findMapShareByToken(token); + if (!share || !share.enabled) { + return new NextResponse("Not found", { status: 404 }); + } + + // If the password was removed while the form was open, the claim is free + if (!share.passwordHash) { + await addShareGrant({ shareId: share.id, mapId: share.mapId }); + return new NextResponse(null, { status: 204 }); + } + + const ip = getClientIp(request); + const allowed = await checkSharePasswordAttempt(ip, token); + if (!allowed) { + return new NextResponse("Too many attempts", { status: 429 }); + } + + let password = ""; + try { + const body: unknown = await request.json(); + if ( + body !== null && + typeof body === "object" && + "password" in body && + typeof body.password === "string" + ) { + password = body.password; + } + } catch { + // Malformed body: treated as an empty password below + } + + const valid = password + ? await verifyPassword(password, share.passwordHash) + : false; + if (!valid) { + return new NextResponse("Incorrect password", { status: 401 }); + } + + await addShareGrant({ shareId: share.id, mapId: share.mapId }); + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/share/[token]/components/ShareClaim.tsx b/src/app/share/[token]/components/ShareClaim.tsx new file mode 100644 index 000000000..aa7c6878f --- /dev/null +++ b/src/app/share/[token]/components/ShareClaim.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { LoaderPinwheel } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState, useTransition } from "react"; + +// A claim attempted this recently that still yielded no grant means the +// browser is not storing our cookies +const RECENT_CLAIM_WINDOW_MS = 5000; + +// Scoped per token: a successful claim on one share must not mark a +// different share's link, opened moments later in the same tab, as blocked +const claimAttemptKey = (token: string) => `shareClaimAttemptedAt:${token}`; + +/** + * The invisible claim step for a passwordless share: on mount, asks the + * claim endpoint to mint the grant cookie, then refreshes so the server + * component re-renders with the grant — the same mechanism as + * `SharePasswordForm`, minus the form. If the refreshed page still has no + * grant, the browser is blocking cookies: show a message instead of + * claiming again. + */ +export default function ShareClaim({ token }: { token: string }) { + const router = useRouter(); + const didRun = useRef(false); + const [isRefreshing, startTransition] = useTransition(); + const [status, setStatus] = useState<"claiming" | "claimed" | "blocked">( + "claiming", + ); + + useEffect(() => { + if (didRun.current) { + return; + } + didRun.current = true; + + try { + const lastAttempt = Number( + sessionStorage.getItem(claimAttemptKey(token)), + ); + if (Date.now() - lastAttempt < RECENT_CLAIM_WINDOW_MS) { + setStatus("blocked"); + return; + } + sessionStorage.setItem(claimAttemptKey(token), String(Date.now())); + } catch { + // Storage being unavailable means cookies are blocked too + setStatus("blocked"); + return; + } + + const claim = async () => { + try { + await fetch(`/api/share/${token}/claim`, { method: "POST" }); + } catch { + // Let the refreshed page decide what went wrong (404, password + // form, or the blocked-cookies message below) + } + startTransition(() => router.refresh()); + setStatus("claimed"); + }; + void claim(); + }, [token, router]); + + // Cookie-stick detection, shared with `SharePasswordForm`: the grant + // cookie is httpOnly and `router.refresh()` reports nothing back, so the + // only way to learn whether the cookie stuck is to see what the server + // renders. `isRefreshing` spans the full refresh round-trip, and a + // refresh preserves client component state — so once the claim's refresh + // has finished, either the server saw the grant and swapped in the map + // (unmounting us), or this same instance is still mounted, meaning the + // cookie did not stick. + const blocked = + status === "blocked" || (status === "claimed" && !isRefreshing); + + if (blocked) { + return ( +
+

+ This shared map needs cookies to work. Please enable cookies for this + site and reload the page. +

+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/share/[token]/components/SharePasswordForm.tsx b/src/app/share/[token]/components/SharePasswordForm.tsx new file mode 100644 index 000000000..66af1167d --- /dev/null +++ b/src/app/share/[token]/components/SharePasswordForm.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { LockIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button } from "@/shadcn/ui/button"; +import { Input } from "@/shadcn/ui/input"; + +/** + * The password gate for a protected shared map. Submitting posts to the + * verify endpoint, which sets the grant cookie; the refresh then re-runs + * the server component, which sees the grant and renders the map. + */ +export default function SharePasswordForm({ + token, + mapName, +}: { + token: string; + mapName: string; +}) { + const router = useRouter(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [verified, setVerified] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password || submitting || verified) { + return; + } + setSubmitting(true); + setError(null); + try { + const response = await fetch(`/api/share/${token}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (response.ok) { + setVerified(true); + startTransition(() => router.refresh()); + return; + } + if (response.status === 404) { + // The share was disabled or its token rotated while the form was + // open: refresh so the server renders the real outcome (the 404 + // page) rather than reporting an incorrect password + startTransition(() => router.refresh()); + return; + } + if (response.status === 429) { + setError("Too many attempts. Please try again in a few minutes."); + } else { + setError("Incorrect password. Please try again."); + } + } catch { + setError("Something went wrong. Please try again."); + } finally { + setSubmitting(false); + } + }; + + // Cookie-stick detection, shared with `ShareClaim`: the grant cookie is + // httpOnly and `router.refresh()` reports nothing back, so the only way + // to learn whether the cookie stuck is to see what the server renders. + // `isRefreshing` spans the full refresh round-trip, and a refresh + // preserves client component state — so once a correct password's + // refresh has finished, either the server saw the grant and swapped in + // the map (unmounting us), or this same instance is still mounted, + // meaning the browser is blocking cookies. Without this the form would + // silently re-appear and the viewer would resubmit forever. + if (verified && !isRefreshing) { + return ( +
+

+ This shared map needs cookies to work. Please enable cookies for this + site and reload the page. +

+
+ ); + } + + return ( +
+
+
+
+ +

{mapName}

+
+

+ This map is password protected. Enter the password to view it. +

+
+ setPassword(e.target.value)} + /> + {error &&

{error}

} + +
+
+ ); +} diff --git a/src/app/share/[token]/page.tsx b/src/app/share/[token]/page.tsx new file mode 100644 index 000000000..7d1cd9911 --- /dev/null +++ b/src/app/share/[token]/page.tsx @@ -0,0 +1,100 @@ +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import { notFound } from "next/navigation"; +import { cache } from "react"; +import ReadOnlyMapControls from "@/app/(private)/map/[id]/components/readonly/ReadOnlyMapControls"; +import ReadOnlyNavbar from "@/app/(private)/map/[id]/components/readonly/ReadOnlyNavbar"; +import SharedMap from "@/app/(private)/map/[id]/components/SharedMap"; +import MapJotaiProvider from "@/app/(private)/map/[id]/providers/MapJotaiProvider"; +import { getShareGrants } from "@/auth/shareGrants"; +import { findMapById } from "@/server/repositories/Map"; +import { findMapShareByToken } from "@/server/repositories/MapShare"; +import { findValidShareGrantForMap } from "@/server/utils/auth"; +import { createCaller, getQueryClient, trpc } from "@/services/trpc/server"; +import ShareClaim from "./components/ShareClaim"; +import SharePasswordForm from "./components/SharePasswordForm"; +import type { Metadata } from "next"; + +interface Props { + params: Promise<{ token: string }>; + searchParams: Promise<{ viewId?: string }>; +} + +// Deduplicate the lookups between generateMetadata and the page render +// (memoised per request, and only within this module) +const getShareByToken = cache(findMapShareByToken); +const getMapById = cache(findMapById); + +export async function generateMetadata({ + params, +}: Pick): Promise { + const { token } = await params; + const share = await getShareByToken(token); + const map = share?.enabled ? await getMapById(share.mapId) : null; + return { + title: map ? `${map.name} - Mapped` : "Mapped", + // Share links are unlisted: never index them + robots: { index: false, follow: false }, + }; +} + +export default async function SharedMapPage({ params, searchParams }: Props) { + const { token } = await params; + const { viewId: requestedViewId } = await searchParams; + + const share = await getShareByToken(token); + if (!share || !share.enabled) { + notFound(); + } + + const grants = await getShareGrants(); + const grant = await findValidShareGrantForMap(grants, share.mapId); + + if (!grant) { + if (share.passwordHash) { + const map = await getMapById(share.mapId); + return ( + + ); + } + // Passwordless: the grant cookie is minted by the claim endpoint + // (cookies cannot be set during page render). ShareClaim calls it and + // refreshes, after which the grant check above passes. + return ; + } + + const caller = await createCaller(); + const map = await caller.map.byId({ mapId: share.mapId }); + const views = [...map.views].sort((a, b) => a.position - b.position); + const viewId = + views.find((v) => v.id === requestedViewId)?.id ?? views[0]?.id; + + // Seed the React Query cache so the client's `useMapQuery` picks the map + // up without a separate fetch (same pattern as the public map page) + const queryClient = getQueryClient(); + queryClient.setQueryData(trpc.map.byId.queryKey({ mapId: share.mapId }), map); + + return ( + + +
+ +
+ +
+ {/* Desktop-only message for small screens */} +
+

+ Your screen is too small to view this map. Please use a device + with a larger screen. +

+
+
+ +
+
+
+
+
+
+ ); +} diff --git a/src/hooks/useDataSources.ts b/src/hooks/useDataSources.ts index f4fd6d6c5..dac03c9e4 100644 --- a/src/hooks/useDataSources.ts +++ b/src/hooks/useDataSources.ts @@ -3,7 +3,10 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useCallback, useMemo } from "react"; -import { isPublicMapRouteAtom } from "@/app/(private)/map/[id]/atoms/mapStateAtoms"; +import { + isPublicMapRouteAtom, + isReadOnlyRouteAtom, +} from "@/app/(private)/map/[id]/atoms/mapStateAtoms"; import { useMapConfig } from "@/app/(private)/map/[id]/hooks/useMapConfig"; import { useMapId } from "@/app/(private)/map/[id]/hooks/useMapCore"; import { @@ -18,12 +21,18 @@ export function useDataSources() { const trpc = useTRPC(); const organisationId = useOrganisationId(); const isPublicMapRoute = useAtomValue(isPublicMapRouteAtom); + const isReadOnlyRoute = useAtomValue(isReadOnlyRouteAtom); const isSuperadminDataSourceRoute = useAtomValue( isSuperadminDataSourceRouteAtom, ); const mapId = useMapId(); const viewId = useViewId(); + // Routes serving anonymous viewers (the public map page and the + // read-only shared map page) list only the data sources the map/view + // visualises; `listReadable` requires an authenticated user. + const isAnonymousViewerRoute = isPublicMapRoute || isReadOnlyRoute; + const listPublicQuery = useQuery( trpc.dataSource.listPublic.queryOptions(undefined, { enabled: isSuperadminDataSourceRoute, @@ -35,20 +44,20 @@ export function useDataSources() { { activeOrganisationId: organisationId ?? undefined, }, - { enabled: !isPublicMapRoute && !isSuperadminDataSourceRoute }, + { enabled: !isAnonymousViewerRoute && !isSuperadminDataSourceRoute }, ), ); const listForMapViewQuery = useQuery( trpc.dataSource.listForMapView.queryOptions( { mapId: mapId ?? "", viewId: viewId ?? "" }, - { enabled: isPublicMapRoute && Boolean(mapId) && Boolean(viewId) }, + { enabled: isAnonymousViewerRoute && Boolean(mapId) && Boolean(viewId) }, ), ); const query = isSuperadminDataSourceRoute ? listPublicQuery - : isPublicMapRoute + : isAnonymousViewerRoute ? listForMapViewQuery : listReadableQuery; diff --git a/src/server/services/ratelimit.ts b/src/server/services/ratelimit.ts index 728787eea..8010e9678 100644 --- a/src/server/services/ratelimit.ts +++ b/src/server/services/ratelimit.ts @@ -52,3 +52,13 @@ export async function checkForgotPasswordAttempt(ip: string): Promise { const count = await recordAttempt(`rate_limit:forgot_password:${ip}`); return count <= FORGOT_PASSWORD_MAX_ATTEMPTS; } + +const SHARE_PASSWORD_MAX_ATTEMPTS = 5; + +export async function checkSharePasswordAttempt( + ip: string, + token: string, +): Promise { + const count = await recordAttempt(`rate_limit:share_password:${token}:${ip}`); + return count <= SHARE_PASSWORD_MAX_ATTEMPTS; +}