diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index 56ed79dd3..48fc827a6 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -1,3 +1,5 @@ +import { useAppStore } from "@geolibre/core"; +import { isEmbeddableLocalVectorLayer } from "@geolibre/plugins"; import { Button, Dialog, @@ -9,7 +11,17 @@ import { Label, Select, } from "@geolibre/ui"; -import { Check, Copy, ExternalLink, KeyRound, Loader2, Share2 } from "lucide-react"; +import type { TFunction } from "i18next"; +import { + Check, + CircleCheck, + Copy, + ExternalLink, + KeyRound, + Loader2, + Share2, + TriangleAlert, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings"; @@ -25,6 +37,11 @@ import { type ShareUploadResult, type ShareVisibility, } from "../../lib/share-geolibre"; +import { + checkShareReadiness, + type ShareReadinessItem, + type ShareReadinessReport, +} from "../../lib/share-readiness"; import { openSettingsSection } from "./SettingsDialog"; interface ShareProjectDialogProps { @@ -54,6 +71,68 @@ function accountSettingsUrl(): string | null { return base ? `${base}/settings` : null; } +/** + * The row's heading: a layer's own name, or a translated label for the two + * project-level references (the basemap style and a plugin manifest), which the + * check reports without a name of their own so it never has to be handed the + * translation function. + */ +function readinessLabel(item: ShareReadinessItem, t: TFunction): string { + if (item.label) return item.label; + return item.field === "basemapStyleUrl" + ? t("share.readinessBasemapLabel") + : t("share.readinessPluginLabel"); +} + +/** + * The plain-language reason shown for a verdict, and what the author can do + * about it. Keyed off the reason rather than the status so an unreachable host + * and a stripped credential read differently even though both are fatal for a + * recipient. An `unchecked` verdict short-circuits: whatever reason it carries, + * the honest thing to say is that the check did not settle it. + */ +function readinessCopyKeys(item: ShareReadinessItem) { + if (item.status === "unchecked") { + return { reason: "share.readinessReasonUnchecked", advice: null } as const; + } + switch (item.reason) { + case "credential-stripped": + return { + reason: "share.readinessReasonCredentialStripped", + advice: "share.readinessAdviceCredential", + } as const; + case "auth-required": + return { + reason: "share.readinessReasonAuthRequired", + advice: "share.readinessAdviceCredential", + } as const; + case "cors": + return { reason: "share.readinessReasonCors", advice: "share.readinessAdviceCors" } as const; + case "not-found": + return { + reason: "share.readinessReasonNotFound", + advice: "share.readinessAdviceNotFound", + } as const; + case "local-file": + return { + reason: "share.readinessReasonLocalFile", + advice: "share.readinessAdviceLocal", + } as const; + case "private-host": + return { + reason: "share.readinessReasonPrivateHost", + advice: "share.readinessAdviceLocal", + } as const; + case "no-source": + return { + reason: "share.readinessReasonNoSource", + advice: "share.readinessAdviceLocal", + } as const; + default: + return { reason: "share.readinessReasonUnchecked", advice: null } as const; + } +} + export function ShareProjectDialog({ open, onOpenChange, @@ -75,9 +154,14 @@ export function ShareProjectDialog({ const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); const [redactedCount, setRedactedCount] = useState(0); + const [readiness, setReadiness] = useState(null); + const [readinessState, setReadinessState] = useState<"idle" | "checking" | "failed">("idle"); const abortRef = useRef(null); const copyTimeoutRef = useRef(null); + const hasToken = shareToken.trim().length > 0; + const titleValid = isShareableTitle(title); + // Reset transient state whenever the dialog is (re)opened so a prior result or // error never lingers into a new share. Seed the title from the current // project name, but leave it blank when the project still has its default @@ -98,6 +182,46 @@ export function ShareProjectDialog({ } }, [open, currentTitle]); + // Pre-flight the project's data sources when the dialog opens, so the author + // learns that a layer will be empty for everyone else *before* the upload + // rather than when a recipient tells them (if they tell them). + // + // Advisory only: it never gates the Share button. An author sharing an + // intranet map with intranet colleagues is doing the right thing. + useEffect(() => { + if (!open || !hasToken) return; + const controller = new AbortController(); + setReadinessState("checking"); + setReadiness(null); + // Read the live layers once rather than subscribing: the dialog is modal, + // so the snapshot it opens on is the project that will be uploaded. + const state = useAppStore.getState(); + void checkShareReadiness( + { + layers: state.layers, + basemapStyleUrl: state.basemapVisible ? state.basemapStyleUrl : null, + pluginManifestUrls: state.projectPlugins?.manifestUrls ?? [], + // The publish path embeds these layers' features, so their local origin + // costs the recipient nothing. Taken from the same predicate that path + // uses so the two cannot drift. + embeddedLayerIds: new Set( + state.layers.filter(isEmbeddableLocalVectorLayer).map((layer) => layer.id), + ), + }, + { signal: controller.signal }, + ) + .then((report) => { + if (controller.signal.aborted) return; + setReadiness(report); + setReadinessState("idle"); + }) + .catch(() => { + if (controller.signal.aborted) return; + setReadinessState("failed"); + }); + return () => controller.abort(); + }, [open, hasToken]); + // Cancel a pending "copied" reset if the dialog unmounts mid-window. useEffect( () => () => { @@ -108,9 +232,6 @@ export function ShareProjectDialog({ [], ); - const hasToken = shareToken.trim().length > 0; - const titleValid = isShareableTitle(title); - const handleShare = async () => { // Guard re-entry synchronously: a second click before the disabled state // renders would otherwise start a concurrent, non-idempotent upload. @@ -301,6 +422,49 @@ export function ShareProjectDialog({ + {readinessState === "checking" ? ( +

+ + {t("share.readinessChecking")} +

+ ) : readinessState === "failed" ? ( +

{t("share.readinessUnavailable")}

+ ) : readiness && readiness.problems.length > 0 ? ( +
+

+ + {t("share.readinessTitle")} +

+

{t("share.readinessNote")}

+
    + {readiness.problems.map((item) => { + const copy = readinessCopyKeys(item); + return ( +
  • +

    + {readinessLabel(item, t)} +

    +

    + {t(copy.reason)} + {copy.advice ? ` ${t(copy.advice)}` : ""} +

    +
  • + ); + })} +
+ {readiness.truncated ? ( +

+ {t("share.readinessTruncated", { count: readiness.probeCount })} +

+ ) : null} +
+ ) : readiness && readiness.items.length > 0 ? ( +

+ + {t("share.readinessAllReachable", { count: readiness.items.length })} +

+ ) : null} + {errorCode === "username-required" ? (
; +} + +export interface ShareProbeOptions { + fetchImpl?: typeof fetch; + signal?: AbortSignal; + /** Per-request budget. Kept short: the dialog must not hang on a slow host. */ + timeoutMs?: number; + /** Cap on distinct targets requested. */ + maxProbes?: number; +} + +/** + * Short enough that a whole check finishes while the author is still reading + * the title field, and short enough that one dead host cannot stall the rest. + */ +export const SHARE_PROBE_TIMEOUT_MS = 6000; + +/** + * Distinct targets to request. Templates collapse to their origin and every + * target is de-duplicated, so a large project usually stays well under this; + * the cap only bites on a project that genuinely spans many hosts, where the + * remainder is reported as unchecked rather than silently dropped. + */ +export const SHARE_MAX_PROBES = 16; + +/** Worst first. Drives both the aggregate verdict and the report ordering. */ +const STATUS_SEVERITY: Record = { + local: 5, + credentialed: 4, + missing: 3, + blocked: 2, + unchecked: 1, + reachable: 0, +}; + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Whether a credential-named field actually holds something. */ +function isPopulated(value: unknown): boolean { + if (typeof value === "string") return value.trim() !== ""; + if (Array.isArray(value)) return value.length > 0; + if (isPlainObject(value)) return Object.keys(value).length > 0; + return value !== null && value !== undefined; +} + +/** + * Whether a layer's configuration carries a populated credential field. Those + * fields are removed by `redactProjectCredentials` on the way out, so whatever + * they unlock is unavailable to the recipient even though the URL survives + * intact. An empty `headers: {}` is skipped: redaction drops it too, but it + * unlocks nothing, and warning about it would be noise. + */ +function hasCredentialField(value: unknown, depth = 0): boolean { + // Exactly as deep as the redaction pass descends, so a credential nested + // deeply enough to escape this scan but not that one cannot exist. + if (depth >= MAX_REDACT_DEPTH) return false; + if (Array.isArray(value)) return value.some((item) => hasCredentialField(item, depth + 1)); + if (!isPlainObject(value)) return false; + for (const [key, nested] of Object.entries(value)) { + if (isCredentialFieldName(key) && isPopulated(nested)) return true; + if (hasCredentialField(nested, depth + 1)) return true; + } + return false; +} + +/** + * Whether a hostname only resolves on the author's machine or network. + * + * Covers loopback, the RFC 1918 and link-local ranges, the RFC 6598 + * carrier-grade NAT range that some corporate networks use for internal + * addressing, IPv6 unique-local and link-local literals, the reserved intranet + * suffixes, and a bare single-label hostname (`gis-server`), which by + * definition needs the author's search domain to resolve. + */ +export function isPrivateHostname(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); + if (host === "") return false; + if (host === "localhost" || host.endsWith(".localhost")) return true; + if (host === "::1" || host === "0.0.0.0") return true; + if ( + host.endsWith(".local") || + host.endsWith(".internal") || + host.endsWith(".intranet") || + host.endsWith(".home.arpa") + ) { + return true; + } + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (ipv4) { + const first = Number(ipv4[1]); + const second = Number(ipv4[2]); + if (first === 10 || first === 127) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 168) return true; + if (first === 169 && second === 254) return true; + // RFC 6598, 100.64.0.0/10. + if (first === 100 && second >= 64 && second <= 127) return true; + return false; + } + // Only an IPv6 literal can carry these prefixes; a registered domain may + // legitimately start with "fd" or "fe80". + if (host.includes(":")) { + return /^f[cd][0-9a-f]{0,2}:/.test(host) || host.startsWith("fe80:"); + } + // A single-label name has no public DNS answer. + return !host.includes("."); +} + +/** Whether a URL still holds a tile/service placeholder such as `{z}`. */ +function isTemplateUrl(url: string): boolean { + return /\{[a-z0-9_-]+\}/i.test(url); +} + +/** + * What to actually request for a reference. + * + * A tile template cannot be fetched literally, and substituting a nominal + * `0/0/0` tile would 404 on any service whose data starts deeper, reporting a + * healthy basemap as missing. The origin answers the questions this check + * actually asks anyway: is the host up, does it send cross-origin headers, does + * it demand a credential. Collapsing to the origin is also what makes the probe + * budget hold for a project with dozens of tile layers on one host. + */ +export function probeTargetFor(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return isTemplateUrl(url) ? parsed.origin : parsed.toString(); +} + +interface Classification { + status: ShareSourceStatus; + reason: ShareSourceReason; + probeUrl: string | null; +} + +/** + * Settle what can be settled from the reference alone. Returns null for a + * reference that carries no information for a recipient (an inline `data:` + * payload, an app-relative path), which the caller drops rather than reports. + */ +function classifyReference(url: string): Classification | null { + const value = url.trim(); + if (value === "") return null; + // Inline payloads travel inside the project file; nothing to check. + if (value.startsWith("data:")) return null; + // A blob URL is this session's copy of a file the recipient does not have. + if (value.startsWith("blob:")) { + return { status: "local", reason: "local-file", probeUrl: null }; + } + if (value.startsWith("file://") || isAbsoluteFilesystemPath(value)) { + return { status: "local", reason: "local-file", probeUrl: null }; + } + if (!/^https?:\/\//i.test(value)) { + // A relative reference resolves against wherever the project is opened, so + // it is neither obviously broken nor checkable. Say nothing about it. + return null; + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return null; + } + if (isPrivateHostname(parsed.hostname)) { + return { status: "local", reason: "private-host", probeUrl: null }; + } + // The upload strips these, so the recipient gets the URL without the secret. + // Probing would only confirm what the redaction rules already guarantee. + if (redactUrlCredentials(value) !== value) { + return { status: "credentialed", reason: "credential-stripped", probeUrl: null }; + } + if (isGooglePhotorealisticTilesetUrl(value)) { + // The key rides in a request header that is stripped before persisting, so + // the tileset is authored-working and recipient-broken by design. + return { status: "credentialed", reason: "credential-stripped", probeUrl: null }; + } + return { status: "unchecked", reason: "ok", probeUrl: probeTargetFor(value) }; +} + +/** Every place a layer can hide a reference a renderer will actually fetch. */ +function layerReferences(layer: GeoLibreLayer): { field: string; url: string }[] { + const source = layer.source ?? {}; + const metadata = layer.metadata ?? {}; + const found: { field: string; url: string }[] = []; + const push = (field: string, value: unknown) => { + if (nonEmptyString(value)) found.push({ field, url: value.trim() }); + }; + + push("source.url", source.url); + // `data` is either a URL or an inline FeatureCollection; only the former is a + // reference, and the latter is already excluded by the string check. + push("source.data", source.data); + push("source.baseUrl", source.baseUrl); + push("source.arcgisQueryUrl", source.arcgisQueryUrl); + if (Array.isArray(source.tiles)) { + source.tiles.forEach((tile, index) => push(`source.tiles[${index}]`, tile)); + } + if (Array.isArray(source.urls)) { + source.urls.forEach((entry, index) => push(`source.urls[${index}]`, entry)); + } + // The pre-resolution template, and the source of truth on reopen for an XYZ + // layer whose `source.url` was rewritten this session. + push("metadata.originalUrl", metadata.originalUrl); + push("metadata.tileUrl", metadata.tileUrl); + push("metadata.localFilePath", metadata.localFilePath); + push("metadata.localBytesUrl", metadata.localBytesUrl); + push("layer.sourcePath", layer.sourcePath); + + // De-duplicate: an XYZ layer commonly repeats one template across `source.url`, + // `source.tiles[0]`, and `metadata.originalUrl`, and reporting it three times + // would bury the layers that actually differ. + const seen = new Set(); + return found.filter((entry) => { + if (seen.has(entry.url)) return false; + seen.add(entry.url); + return true; + }); +} + +/** Whether the layer's features travel inside the project file. */ +function carriesOwnData(layer: GeoLibreLayer, embeddedLayerIds?: ReadonlySet): boolean { + if (embeddedLayerIds?.has(layer.id)) return true; + if (layer.geojson) return true; + const metadata = layer.metadata ?? {}; + if (metadata.embeddedGeoJSON) return true; + return isPlainObject((layer.source ?? {}).data); +} + +/** + * Walk the project and bucket every reference, settling everything that does + * not need the network. References left `unchecked` with a non-null `probeUrl` + * are what {@link probeShareSources} resolves. + */ +export function collectShareSources(input: ShareReadinessInput): ShareSourceRef[] { + const refs: ShareSourceRef[] = []; + + for (const layer of input.layers) { + const embedded = carriesOwnData(layer, input.embeddedLayerIds); + const references = layerReferences(layer); + if (embedded) { + // Its data ships with the project, so whatever it also points at is not + // what a recipient will render. + continue; + } + if (references.length === 0) { + // A query-backed layer (PostGIS, a DuckDB SQL layer, a sidecar result) + // that neither embeds data nor names a URL resolves only where it was + // authored. + refs.push({ + layerId: layer.id, + label: layer.name, + field: "source", + url: "", + probeUrl: null, + status: "local", + reason: "no-source", + }); + continue; + } + const credentialField = hasCredentialField(layer.source) || hasCredentialField(layer.metadata); + for (const reference of references) { + const classified = classifyReference(reference.url); + if (!classified) continue; + refs.push({ + layerId: layer.id, + label: layer.name, + field: reference.field, + url: reference.url, + ...(credentialField && classified.status === "unchecked" + ? { + probeUrl: null, + status: "credentialed" as const, + reason: "credential-stripped" as const, + } + : classified), + }); + } + } + + if (nonEmptyString(input.basemapStyleUrl)) { + const classified = classifyReference(input.basemapStyleUrl); + if (classified) { + refs.push({ + layerId: null, + label: "", + field: "basemapStyleUrl", + url: input.basemapStyleUrl.trim(), + ...classified, + }); + } + } + + for (const [index, manifestUrl] of (input.pluginManifestUrls ?? []).entries()) { + // Only absolute references: a bundled drop-in is served from the app itself + // and resolves wherever the project is opened. + if (!nonEmptyString(manifestUrl) || !/^https?:\/\//i.test(manifestUrl)) continue; + const classified = classifyReference(manifestUrl); + if (!classified) continue; + refs.push({ + layerId: null, + label: "", + field: `plugins.manifestUrls[${index}]`, + url: manifestUrl.trim(), + ...classified, + }); + } + + return refs; +} + +type ProbeOutcome = Pick; + +/** Statuses a HEAD may reject on while the resource itself is fine over GET. */ +const RETRY_WITH_RANGED_GET = new Set([400, 403, 405, 501]); + +function outcomeForStatus(status: number): ProbeOutcome { + if (status === 401 || status === 403 || status === 407) { + return { status: "credentialed", reason: "auth-required" }; + } + if (status === 404 || status === 410) { + return { status: "missing", reason: "not-found" }; + } + if (status >= 500 || status === 429) { + // Reachable, cross-origin headers present, but the service is unwell right + // now. That is not a property of the shared project, so do not accuse it. + return { status: "unchecked", reason: "ok" }; + } + // Everything else — including a 400 from a service endpoint asked for its + // bare URL — means the host answered and the browser was allowed to read it. + return { status: "reachable", reason: "ok" }; +} + +async function probeTarget( + target: string, + fetchImpl: typeof fetch, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + // One deadline for the whole target rather than one per attempt, so a slow + // host that refuses HEAD cannot spend the budget twice over. + const timeout = AbortSignal.timeout(timeoutMs); + const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout; + const request = async (method: "HEAD" | "GET"): Promise => + fetchImpl(target, { + method, + // Withhold the author's ambient authority: the check must see what a + // recipient sees, not what the author's cookies unlock. + credentials: "omit", + cache: "no-store", + redirect: "follow", + // One byte is enough to learn the status. Without the range, a + // HEAD-refusing host would have a whole multi-gigabyte COG pulled down. + ...(method === "GET" ? { headers: { Range: "bytes=0-0" } } : {}), + signal: deadline, + }); + + try { + const head = await request("HEAD"); + if (!RETRY_WITH_RANGED_GET.has(head.status)) return outcomeForStatus(head.status); + // Plenty of object stores and CDNs refuse HEAD while serving GET happily, + // so a one-byte ranged GET decides it rather than a false "needs a login". + const ranged = await request("GET"); + return outcomeForStatus(ranged.status); + } catch (error) { + const failure = classifyFetchFailure(error); + if (failure.kind === "abort") return { status: "unchecked", reason: "aborted" }; + if (failure.kind === "timeout") return { status: "unchecked", reason: "timeout" }; + // The browser collapses a cross-origin rejection, a TLS failure, and an + // unreachable host into one opaque error. All three mean the recipient's + // browser cannot read this, which is the verdict that matters here. + if (failure.kind === "network") return { status: "blocked", reason: "cors" }; + return { status: "unchecked", reason: "ok" }; + } +} + +/** + * Resolve the references {@link collectShareSources} left open, one request per + * distinct target, in parallel and capped. Never throws: a check that fails is + * reported as unchecked rather than blocking the share. + */ +export async function probeShareSources( + refs: readonly ShareSourceRef[], + options: ShareProbeOptions = {}, +): Promise<{ refs: ShareSourceRef[]; probeCount: number; truncated: boolean }> { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? SHARE_PROBE_TIMEOUT_MS; + const maxProbes = options.maxProbes ?? SHARE_MAX_PROBES; + + // Insertion-ordered so the budget, when it bites, keeps the sources the + // author sees first in the layer list rather than an arbitrary subset. + const targets = new Set(); + for (const ref of refs) { + if (ref.status !== "unchecked" || !ref.probeUrl) continue; + targets.add(ref.probeUrl); + } + const probed = [...targets].slice(0, maxProbes); + const truncated = targets.size > probed.length; + + const outcomes = new Map(); + if (typeof fetchImpl === "function") { + const results = await Promise.all( + probed.map((target) => probeTarget(target, fetchImpl, timeoutMs, options.signal)), + ); + probed.forEach((target, index) => outcomes.set(target, results[index])); + } + + return { + refs: refs.map((ref) => { + if (ref.status !== "unchecked" || !ref.probeUrl) return ref; + const outcome = outcomes.get(ref.probeUrl); + if (!outcome) return { ...ref, reason: "probe-budget" }; + return { ...ref, ...outcome }; + }), + probeCount: outcomes.size, + truncated, + }; +} + +/** + * Fold the per-reference verdicts into one row per layer (or project field), + * keeping the worst. A layer with three tile mirrors is one line in the dialog, + * not three. + */ +export function summarizeShareSources(refs: readonly ShareSourceRef[]): ShareReadinessItem[] { + const byOwner = new Map(); + for (const ref of refs) { + const key = ref.layerId ?? `${ref.field}:${ref.url}`; + const existing = byOwner.get(key); + const candidate: ShareReadinessItem = { + layerId: ref.layerId, + label: ref.label, + field: ref.field, + status: ref.status, + reason: ref.reason, + url: ref.url, + }; + if (!existing || STATUS_SEVERITY[candidate.status] > STATUS_SEVERITY[existing.status]) { + byOwner.set(key, candidate); + } + } + return [...byOwner.values()]; +} + +/** Collect, probe, and summarize. What the Share dialog calls. */ +export async function checkShareReadiness( + input: ShareReadinessInput, + options: ShareProbeOptions = {}, +): Promise { + const collected = collectShareSources(input); + const { refs, probeCount, truncated } = await probeShareSources(collected, options); + const items = summarizeShareSources(refs); + const problems = items + .filter((item) => item.status !== "reachable") + .sort((a, b) => STATUS_SEVERITY[b.status] - STATUS_SEVERITY[a.status]); + return { items, problems, probeCount, truncated }; +} diff --git a/docs/features.md b/docs/features.md index 56b6ba258..2a3a693d0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -200,6 +200,7 @@ kepler.gl, see the [Comparison](comparison.md). ## Projects and sharing - Project menu to create, open, save, and Save As `.geolibre.json` projects, export a project to a single standalone interactive HTML file that runs offline with no server, and a project gallery for browsing and opening shared projects with one click +- Share-readiness check in the Share dialog: before the upload, every data source the project references is classified and probed anonymously from the browser, and the ones a recipient could not load are listed with a plain-language reason and a fix, covering credential-gated services, hosts with no cross-origin headers, expired or moved links, and local or private-network sources. It informs rather than blocks. See [Projects](user-guide/projects.md#share-readiness-check) - Autosave with a browsable project history. See [Projects](user-guide/projects.md#project-history-and-crash-recovery) - Snapshots are written to local device storage a few seconds after each change settles, and listed newest first with their layer count and zoom - Restoring a snapshot is an undoable step diff --git a/docs/user-guide/projects.md b/docs/user-guide/projects.md index db08c886b..e0600f618 100644 --- a/docs/user-guide/projects.md +++ b/docs/user-guide/projects.md @@ -66,6 +66,17 @@ An ArcGIS Pro project can contain several maps; GeoLibre imports its first 2D ma **Project → Share...** uploads the current project to `share.geolibre.app` and returns a public URL you can send to anyone or open in the live viewer. Sharing uses a personal API token, which you set once as the **Share.GeoLibre API token** in **Settings → Environment Variables**. The shared file is the same `.geolibre.json` the app saves locally, so anyone who opens the link sees the same layers, styles, and map view. See the [Sharing & Embedding tutorial](../tutorials/sharing-embedding.md). +### Share-readiness check + +A project file is mostly references, so a project can upload cleanly and still draw nothing for the person you sent it to. When the Share dialog opens it checks the data sources the project points at and lists the ones a recipient will not be able to load, with the reason and what to do about it: + +- **Uses a credential that is removed when sharing.** Tokens and API keys are stripped from the upload, so the recipient gets the URL without the secret. Make the service public, or tell them to supply their own key. +- **A browser cannot fetch this host.** The host sends no cross-origin (CORS) headers, or it did not answer. Layers like this keep working in the desktop app, which is not subject to browser CORS, but stay empty in the browser viewer. +- **The service answered not found.** A signed URL that has expired, or a file that moved. +- **Points at a file on your machine, or at a private network address.** Local vector data is embedded in the upload automatically, but a local raster, an intranet service, or a database-backed layer only resolves where you authored it. + +The check runs in the browser, without your credentials attached, so it sees what a recipient sees. It never blocks the upload: sharing an intranet map with intranet colleagues is a normal thing to do, and the list is there to inform you, not to stop you. + ## Export as HTML **Project → Export as HTML...** writes the whole project to a single standalone HTML file that runs offline with no server. Host it anywhere, or open it straight from disk. diff --git a/packages/core/src/credentials.ts b/packages/core/src/credentials.ts index e2031d38d..3dbc41a1d 100644 --- a/packages/core/src/credentials.ts +++ b/packages/core/src/credentials.ts @@ -95,7 +95,13 @@ const URL_CREDENTIAL_PARAMS = new Set( "skoid", ].map(normalizeCredentialName), ); -const MAX_REDACT_DEPTH = 12; +/** + * Depth at which the redaction pass stops descending and fails closed. + * Exported so a caller that predicts what redaction will remove (the Share + * dialog's readiness check) scans exactly as deep as this pass does, instead of + * keeping a second, shallower cap that would silently disagree. + */ +export const MAX_REDACT_DEPTH = 12; /** Whether an object key in layer/plugin configuration holds a credential. */ export function isCredentialFieldName(name: string): boolean { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c127c9557..3251c50f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,8 @@ export { stripGoogleMapsApiKeyHeader, } from "./three-d-tiles"; export { + isCredentialFieldName, + MAX_REDACT_DEPTH, PROJECT_CREDENTIAL_FIELDS, PUBLISHABLE_PLUGIN_SETTINGS, redactCredentials, diff --git a/tests/share-readiness.test.ts b/tests/share-readiness.test.ts new file mode 100644 index 000000000..358d69ffb --- /dev/null +++ b/tests/share-readiness.test.ts @@ -0,0 +1,473 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { GeoLibreLayer } from "../packages/core/src/types"; +import { + checkShareReadiness, + collectShareSources, + isPrivateHostname, + probeShareSources, + probeTargetFor, + summarizeShareSources, +} from "../apps/geolibre-desktop/src/lib/share-readiness"; + +function layer(overrides: Partial = {}): GeoLibreLayer { + return { + id: "layer-1", + name: "Layer 1", + type: "geojson", + source: {}, + visible: true, + opacity: 1, + style: {}, + metadata: {}, + ...overrides, + } as GeoLibreLayer; +} + +/** + * Records every request so a test can assert what was (and was not) asked for, + * and answers from a target → status/throw table. + */ +function fakeFetch(routes: Record) { + const calls: { url: string; method: string; credentials?: string }[] = []; + const fn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ + url, + method: init?.method ?? "GET", + credentials: init?.credentials, + }); + const route = routes[url]; + if (route === undefined) throw new TypeError("Failed to fetch"); + if (route instanceof Error) throw route; + return new Response(null, { status: route }); + }) as unknown as typeof fetch; + return { fn, calls }; +} + +describe("isPrivateHostname", () => { + it("recognizes loopback, private ranges, and reserved suffixes", () => { + for (const host of [ + "localhost", + "app.localhost", + "127.0.0.1", + "10.1.2.3", + "172.16.0.9", + "172.31.255.1", + "192.168.1.10", + "169.254.10.1", + // RFC 6598 carrier-grade NAT. + "100.64.0.1", + "100.127.255.254", + "::1", + "fd00::1", + "fe80::1", + "gis-server", + "tiles.local", + "maps.internal", + ]) { + assert.equal(isPrivateHostname(host), true, host); + } + }); + + it("leaves public hosts alone, including ones that merely look private", () => { + for (const host of [ + "tiles.openfreemap.org", + "172.32.0.1", + "172.15.0.1", + "11.0.0.1", + "192.169.1.1", + // Just outside 100.64.0.0/10 on either side. + "100.63.255.255", + "100.128.0.1", + // A registered domain may start with the IPv6 unique-local prefix. + "fd-services.com", + "fe80.example.com", + ]) { + assert.equal(isPrivateHostname(host), false, host); + } + }); +}); + +describe("probeTargetFor", () => { + it("collapses a tile template to its origin", () => { + assert.equal( + probeTargetFor("https://tile.example.com/data/{z}/{x}/{y}.png"), + "https://tile.example.com", + ); + }); + + it("keeps a concrete URL intact so an expired link is still caught", () => { + assert.equal( + probeTargetFor("https://data.example.com/dem.tif"), + "https://data.example.com/dem.tif", + ); + }); + + it("returns null for a non-HTTP reference", () => { + assert.equal(probeTargetFor("/home/me/dem.tif"), null); + assert.equal(probeTargetFor("ftp://example.com/dem.tif"), null); + }); +}); + +describe("collectShareSources", () => { + it("skips a layer whose data travels inside the project", () => { + const refs = collectShareSources({ + layers: [ + layer({ geojson: { type: "FeatureCollection", features: [] } }), + layer({ id: "b", name: "B", metadata: { embeddedGeoJSON: { type: "FeatureCollection" } } }), + layer({ id: "c", name: "C", source: { url: "https://x.example.com/a.fgb" } }), + ], + embeddedLayerIds: new Set(["c"]), + }); + assert.deepEqual(refs, []); + }); + + it("flags a local path and a private host without probing them", () => { + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "DEM", type: "cog", source: { url: "/home/me/dem.tif" } }), + layer({ + id: "b", + name: "Intranet tiles", + type: "xyz", + source: { tiles: ["http://192.168.1.20:8080/{z}/{x}/{y}.png"] }, + }), + ], + }); + assert.deepEqual( + refs.map((ref) => [ref.layerId, ref.status, ref.reason, ref.probeUrl]), + [ + ["a", "local", "local-file", null], + ["b", "local", "private-host", null], + ], + ); + }); + + it("flags a URL whose credential the upload strips", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "Keyed tiles", + type: "xyz", + source: { url: "https://api.example.com/{z}/{x}/{y}.png?apiKey=secret" }, + }), + ], + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].status, "credentialed"); + assert.equal(refs[0].reason, "credential-stripped"); + assert.equal(refs[0].probeUrl, null); + }); + + it("flags a layer whose configuration carries a credential field", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "Private tileset", + type: "3d-tiles", + source: { + url: "https://tiles.example.com/tileset.json", + requestHeaders: { "X-Token": "abc" }, + }, + }), + ], + }); + assert.equal(refs[0].status, "credentialed"); + assert.equal(refs[0].probeUrl, null); + }); + + it("ignores an empty credential field, which unlocks nothing", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "Public tileset", + type: "3d-tiles", + source: { url: "https://tiles.example.com/tileset.json", requestHeaders: {} }, + }), + ], + }); + assert.equal(refs[0].status, "unchecked"); + assert.equal(refs[0].probeUrl, "https://tiles.example.com/tileset.json"); + }); + + it("reports a query-backed layer that names no reference at all", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "PostGIS parcels", + type: "duckdb-query", + source: { sql: "select * from parcels" }, + metadata: { sourceKind: "sql-query" }, + }), + ], + }); + assert.equal(refs[0].status, "local"); + assert.equal(refs[0].reason, "no-source"); + }); + + it("de-duplicates one template repeated across source and metadata", () => { + const template = "https://tile.example.com/{z}/{x}/{y}.png"; + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "XYZ", + type: "xyz", + source: { url: template, tiles: [template] }, + metadata: { originalUrl: template }, + }), + ], + }); + assert.equal(refs.length, 1); + }); + + it("includes the basemap and absolute plugin manifests, not bundled ones", () => { + const refs = collectShareSources({ + layers: [], + basemapStyleUrl: "https://tiles.openfreemap.org/styles/liberty", + pluginManifestUrls: [ + "https://plugins.example.com/p/plugin.json", + "/plugins/local/plugin.json", + ], + }); + assert.deepEqual( + refs.map((ref) => ref.field), + ["basemapStyleUrl", "plugins.manifestUrls[0]"], + ); + // Project-level rows carry no label: the dialog translates one from `field`, + // so the check never needs the translation function. + assert.deepEqual( + refs.map((ref) => ref.label), + ["", ""], + ); + }); + + it("says nothing about an inline data: payload", () => { + const refs = collectShareSources({ + layers: [layer({ id: "a", type: "image", source: { url: "data:image/png;base64,AAA" } })], + }); + assert.deepEqual(refs, []); + }); +}); + +describe("probeShareSources", () => { + it("probes each distinct target once, anonymously, with HEAD", async () => { + const template = "https://tile.example.com/{z}/{x}/{y}.png"; + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "A", type: "xyz", source: { url: template } }), + layer({ + id: "b", + name: "B", + type: "xyz", + source: { url: "https://tile.example.com/other/{z}/{x}/{y}.png" }, + }), + ], + }); + const { fn, calls } = fakeFetch({ "https://tile.example.com": 200 }); + const result = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, "HEAD"); + assert.equal(calls[0].credentials, "omit"); + assert.equal(result.probeCount, 1); + assert.deepEqual( + result.refs.map((ref) => ref.status), + ["reachable", "reachable"], + ); + }); + + it("maps 401 to credentialed and 404 to missing", async () => { + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "A", type: "cog", source: { url: "https://a.example.com/a.tif" } }), + layer({ id: "b", name: "B", type: "cog", source: { url: "https://b.example.com/b.tif" } }), + ], + }); + const { fn } = fakeFetch({ + "https://a.example.com/a.tif": 401, + "https://b.example.com/b.tif": 404, + }); + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.deepEqual( + probed.map((ref) => [ref.status, ref.reason]), + [ + ["credentialed", "auth-required"], + ["missing", "not-found"], + ], + ); + }); + + it("retries a HEAD-refusing host with a ranged GET before calling it gated", async () => { + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "A", type: "cog", source: { url: "https://s3.example.com/a.tif" } }), + ], + }); + let first = true; + const attempts: { method?: string; range?: string }[] = []; + const fn = (async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + attempts.push({ + method: init?.method, + range: (init?.headers as Record | undefined)?.Range, + }); + if (first && init?.method === "HEAD") { + first = false; + return new Response(null, { status: 403 }); + } + return new Response(null, { status: 206 }); + }) as unknown as typeof fetch; + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(probed[0].status, "reachable"); + // The range matters as much as the method: without it, every HEAD-refusing + // host would have its whole object downloaded by the readiness check. + assert.deepEqual(attempts, [ + { method: "HEAD", range: undefined }, + { method: "GET", range: "bytes=0-0" }, + ]); + }); + + it("reads an opaque browser rejection as browser-blocked", async () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "A", + type: "cog", + source: { url: "https://nocors.example.com/a.tif" }, + }), + ], + }); + const { fn } = fakeFetch({}); + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(probed[0].status, "blocked"); + assert.equal(probed[0].reason, "cors"); + }); + + it("does not blame the project for a 5xx", async () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "A", + type: "cog", + source: { url: "https://down.example.com/a.tif" }, + }), + ], + }); + const { fn } = fakeFetch({ "https://down.example.com/a.tif": 503 }); + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(probed[0].status, "unchecked"); + }); + + it("caps the probe count and reports the remainder as unchecked", async () => { + const layers = Array.from({ length: 4 }, (_unused, index) => + layer({ + id: `l${index}`, + name: `L${index}`, + type: "cog", + source: { url: `https://host${index}.example.com/a.tif` }, + }), + ); + const { fn, calls } = fakeFetch({ + "https://host0.example.com/a.tif": 200, + "https://host1.example.com/a.tif": 200, + "https://host2.example.com/a.tif": 200, + "https://host3.example.com/a.tif": 200, + }); + const result = await probeShareSources(collectShareSources({ layers }), { + fetchImpl: fn, + maxProbes: 2, + }); + assert.equal(calls.length, 2); + assert.equal(result.truncated, true); + assert.deepEqual( + result.refs.map((ref) => ref.status), + ["reachable", "reachable", "unchecked", "unchecked"], + ); + assert.equal(result.refs[3].reason, "probe-budget"); + }); +}); + +describe("summarizeShareSources", () => { + it("keeps the worst verdict per layer", () => { + const items = summarizeShareSources([ + { + layerId: "a", + label: "A", + field: "source.tiles[0]", + url: "https://ok.example.com/a", + probeUrl: null, + status: "reachable", + reason: "ok", + }, + { + layerId: "a", + label: "A", + field: "source.tiles[1]", + url: "https://bad.example.com/a", + probeUrl: null, + status: "blocked", + reason: "cors", + }, + ]); + assert.equal(items.length, 1); + assert.equal(items[0].status, "blocked"); + }); +}); + +describe("checkShareReadiness", () => { + it("orders problems worst first and leaves reachable sources out of them", async () => { + const { fn } = fakeFetch({ "https://ok.example.com/a.tif": 200 }); + const report = await checkShareReadiness( + { + layers: [ + layer({ + id: "ok", + name: "Good", + type: "cog", + source: { url: "https://ok.example.com/a.tif" }, + }), + layer({ + id: "local", + name: "Local DEM", + type: "cog", + source: { url: "/home/me/dem.tif" }, + }), + layer({ + id: "keyed", + name: "Keyed", + type: "xyz", + source: { url: "https://k.example.com/{z}/{x}/{y}.png?apiKey=s" }, + }), + ], + }, + { fetchImpl: fn }, + ); + assert.equal(report.items.length, 3); + assert.deepEqual( + report.problems.map((item) => item.layerId), + ["local", "keyed"], + ); + }); + + it("reports everything as unchecked rather than throwing when fetch is unavailable", async () => { + const original = globalThis.fetch; + // @ts-expect-error deliberately emulating a runtime with no fetch + delete globalThis.fetch; + try { + const report = await checkShareReadiness({ + layers: [layer({ id: "a", type: "cog", source: { url: "https://a.example.com/a.tif" } })], + }); + assert.equal(report.probeCount, 0); + assert.equal(report.items[0].status, "unchecked"); + } finally { + globalThis.fetch = original; + } + }); +});