Skip to content
Merged
20 changes: 12 additions & 8 deletions apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ interface AddDataDialogProps {
* clicked PostGIS table.
*/
initialPostgres?: OpenAddDataPostgres;
/** Service URL supplied by a browser-extension deep link. */
initialUrl?: string;
}

/**
Expand All @@ -55,20 +57,21 @@ function renderSource(
kind: AddDataKind,
initialDeckVizKind: string | undefined,
initialPostgres: OpenAddDataPostgres | undefined,
initialUrl: string | undefined,
) {
switch (kind) {
case "xyz":
return <XyzSource />;
return <XyzSource initialUrl={initialUrl} />;
case "wms":
return <WmsSource />;
return <WmsSource initialUrl={initialUrl} />;
case "wfs":
return <WfsSource />;
return <WfsSource initialUrl={initialUrl} />;
case "wmts":
return <WmtsSource />;
return <WmtsSource initialUrl={initialUrl} />;
case "ogc-features":
return <OgcFeaturesSource />;
return <OgcFeaturesSource initialUrl={initialUrl} />;
case "ogc-vector-tiles":
return <OgcVectorTilesSource />;
return <OgcVectorTilesSource initialUrl={initialUrl} />;
case "gpx":
return <GpxSource />;
case "georss":
Expand All @@ -84,7 +87,7 @@ function renderSource(
case "mbtiles":
return <MbtilesSource />;
case "arcgis":
return <ArcGISSource />;
return <ArcGISSource initialUrl={initialUrl} />;
case "postgres":
return <PostgresSource initialPostgres={initialPostgres} />;
case "video":
Expand All @@ -107,6 +110,7 @@ export function AddDataDialog({
onOpenChange,
initialDeckVizKind,
initialPostgres,
initialUrl,
}: AddDataDialogProps) {
const { t } = useTranslation();
const open = kind !== null;
Expand Down Expand Up @@ -158,7 +162,7 @@ export function AddDataDialog({

{kind ? (
<AddDataShellProvider value={contextValue}>
{renderSource(kind, initialDeckVizKind, initialPostgres)}
{renderSource(kind, initialDeckVizKind, initialPostgres, initialUrl)}
</AddDataShellProvider>
) : null}
</DialogContent>
Expand Down
10 changes: 9 additions & 1 deletion apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import { IS_MAS_BUILD } from "../../lib/build-flags";
import { masHidesDataSource } from "../../lib/mas-build";
import { IS_STORE_BUILD } from "../../lib/updates";
import { AddDataDialog, type AddDataKind } from "./AddDataDialog";
import { serviceUrlParameter } from "../../lib/data-url";
import {
OPEN_ADD_DATA_EVENT,
type OpenAddDataDetail,
Expand Down Expand Up @@ -1029,7 +1030,12 @@ export function TopToolbar({
{} as Record<ToolbarMapControl, boolean>,
),
);
const [addDataKind, setAddDataKind] = useState<AddDataKind | null>(null);
const [initialService, setInitialService] = useState(() =>
viewer || typeof window === "undefined" ? null : serviceUrlParameter(window.location.search),
);
const [addDataKind, setAddDataKind] = useState<AddDataKind | null>(
() => (initialService?.kind as AddDataKind | undefined) ?? null,
);
Comment thread
giswqs marked this conversation as resolved.
Outdated
const [addDataTargetGroupId, setAddDataTargetGroupId] = useState<string | null>(null);
const addDataInitialLayerIdsRef = useRef<Set<string>>(new Set());
// Every path that opens the dialog outside the OPEN_ADD_DATA_EVENT listener
Expand Down Expand Up @@ -2085,6 +2091,7 @@ export function TopToolbar({
mapControllerRef={mapControllerRef}
initialDeckVizKind={addDataDeckVizKind}
initialPostgres={addDataPostgres}
initialUrl={addDataKind === initialService?.kind ? initialService.url : undefined}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
onOpenChange={(open: boolean) => {
if (!open) {
if (addDataTargetGroupId) {
Expand All @@ -2097,6 +2104,7 @@ export function TopToolbar({
}
}
setAddDataKind(null);
setInitialService(null);
setAddDataTargetGroupId(null);
setAddDataDeckVizKind(undefined);
setAddDataPostgres(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ const URL_PLACEHOLDER_KEYS = {
"image-service": "addData.arcgis.imageServiceUrlPlaceholder",
} as const satisfies Record<ArcGISLayerType, string>;

export function ArcGISSource() {
export function ArcGISSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.arcgis.defaultName"));
const [arcgisLayerType, setArcgisLayerType] = useState<ArcGISLayerType>("feature");
const [arcgisSourceType, setArcgisSourceType] = useState<ArcGISSourceType>("url");
const [arcgisUrl, setArcgisUrl] = useState("");
const [arcgisUrl, setArcgisUrl] = useState(initialUrl);
const [arcgisItemId, setArcgisItemId] = useState("");
const [arcgisPortalUrl, setArcgisPortalUrl] = useState("");
const [arcgisAccessToken, setArcgisAccessToken] = useState("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ interface OgcFeaturesSample {
* feature count is reached, because a single `/items` request returns only one
* server-sized page.
*/
export function OgcFeaturesSource() {
export function OgcFeaturesSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.ogcFeatures.defaultName"));
const [endpoint, setEndpoint] = useState(ogcFeaturesFormCache?.endpoint ?? "");
const [endpoint, setEndpoint] = useState(initialUrl || ogcFeaturesFormCache?.endpoint || "");
const [collectionId, setCollectionId] = useState(ogcFeaturesFormCache?.collectionId ?? "");
Comment thread
giswqs marked this conversation as resolved.
Outdated
const [maxFeatures, setMaxFeatures] = useState(
ogcFeaturesFormCache?.maxFeatures ?? String(DEFAULT_OGC_FEATURES_MAX_FEATURES),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ interface OgcSample {
* API TileJSON often omits them). Rendering uses GeoLibre's default per-source
* layer styling; the style's own paint is not applied.
*/
export function OgcVectorTilesSource() {
export function OgcVectorTilesSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.ogcVectorTiles.defaultName"));
const [tilesUrl, setTilesUrl] = useState("");
const [tilesUrl, setTilesUrl] = useState(initialUrl);
const [styleUrl, setStyleUrl] = useState("");
const [sourceLayersText, setSourceLayersText] = useState("");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ interface WfsFormCache {
}
let wfsFormCache: WfsFormCache | null = null;

export function WfsSource() {
export function WfsSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.wfs.defaultName"));
const [wfsEndpoint, setWfsEndpoint] = useState(wfsFormCache?.endpoint ?? "");
const [wfsEndpoint, setWfsEndpoint] = useState(initialUrl || wfsFormCache?.endpoint || "");
const [wfsTypeName, setWfsTypeName] = useState(wfsFormCache?.typeName ?? "");
const [wfsVersion, setWfsVersion] = useState(wfsFormCache?.version ?? "2.0.0");
const [wfsOutputFormat, setWfsOutputFormat] = useState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ interface WmsFormCache {
}
let wmsFormCache: WmsFormCache | null = null;

export function WmsSource() {
export function WmsSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.wms.defaultName"));
const [wmsEndpoint, setWmsEndpoint] = useState(wmsFormCache?.endpoint ?? "");
const [wmsEndpoint, setWmsEndpoint] = useState(initialUrl || wmsFormCache?.endpoint || "");
const [wmsLayers, setWmsLayers] = useState(wmsFormCache?.layers ?? "");
const [wmsStyles, setWmsStyles] = useState(wmsFormCache?.styles ?? "");
const [wmsFormat, setWmsFormat] = useState(wmsFormCache?.format ?? "image/png");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { ServiceLibrarySection } from "../ServiceLibrarySection";
import { serviceFieldString, type ServiceFields } from "../service-library";
import { AddDataSourceForm, SampleDataSelect, useAddDataSource } from "../shared";

export function WmtsSource() {
export function WmtsSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.wmts.defaultName"));
const [wmtsUrl, setWmtsUrl] = useState("");
const [wmtsUrl, setWmtsUrl] = useState(initialUrl);
const [wmtsTileSize, setWmtsTileSize] = useState("256");

const getFields = (): ServiceFields => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import { ServiceLibrarySection } from "../ServiceLibrarySection";
import { serviceFieldBoolean, serviceFieldString, type ServiceFields } from "../service-library";
import { AddDataSourceForm, SampleDataSelect, useAddDataSource } from "../shared";

export function XyzSource() {
export function XyzSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.xyz.defaultName"));
const [xyzUrl, setXyzUrl] = useState("");
const [xyzUrl, setXyzUrl] = useState(initialUrl);
const [xyzTileSize, setXyzTileSize] = useState("256");
const [xyzShortUrl, setXyzShortUrl] = useState(false);

Expand Down
3 changes: 2 additions & 1 deletion apps/geolibre-desktop/src/hooks/useStartupProject.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAppStore, type MapProjection } from "@geolibre/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { dataUrlParameters } from "../lib/data-url";
import { dataUrlParameters, serviceUrlParameter } from "../lib/data-url";
import { isTauri } from "../lib/is-tauri";
import { projectUrlFromLocation } from "../lib/project-url";
import { planStartup, startupDefaultProjection, type StartupPlan } from "../lib/startup-project";
Expand Down Expand Up @@ -36,6 +36,7 @@ const RESTORE_GATE_TIMEOUT_MS = 10_000;
function hasExplicitLaunchPayload(): boolean {
if (projectUrlFromLocation() !== null) return true;
if (dataUrlParameters(window.location.search) !== null) return true;
if (serviceUrlParameter(window.location.search) !== null) return true;
return false;
}

Expand Down
17 changes: 17 additions & 0 deletions apps/geolibre-desktop/src/lib/data-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ export interface DataUrlParameter {
dataUrl: string;
styleUrl: string | null;
}
const SERVICE_KINDS = new Set([
"xyz",
"wms",
"wmts",
"wfs",
"ogc-features",
"ogc-vector-tiles",
"arcgis",
]);

export function serviceUrlParameter(search: string): { kind: string; url: string } | null {
const params = new URLSearchParams(search);
const kind = params.get("add");
const rawUrl = params.get("serviceUrl");
const url = httpUrl(rawUrl)?.replace(/%7B/gi, "{").replace(/%7D/gi, "}") ?? null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serviceUrlParameter restores %7B/%7D{/} across the whole decoded serviceUrl, for every service kind — not just xyz/wmts/ogc-vector-tiles, which are the only kinds that actually use {z}/{x}/{y} tile templates. For wms, wfs, ogc-features, and arcgis deep links, this blanket replace can silently corrupt a URL whose query string legitimately contains those percent-encoded sequences for an unrelated reason (e.g. an auth token or signed parameter that happens to encode {/}), turning them into literal braces the origin server never sent.

Consider scoping the placeholder restoration to only the kinds that need it:

Suggested change
const url = httpUrl(rawUrl)?.replace(/%7B/gi, "{").replace(/%7D/gi, "}") ?? null;
const needsTileBraces = kind === "xyz" || kind === "wmts" || kind === "ogc-vector-tiles";
const url = httpUrl(rawUrl);
const restoredUrl =
url && needsTileBraces ? url.replace(/%7B/gi, "{").replace(/%7D/gi, "}") : url;

(and use restoredUrl below instead of url, with the !kind || !SERVICE_KINDS.has(kind) check moved before this to know kind first).

Confidence: medium — narrow real-world trigger, but a real correctness gap introduced by this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #1969 (landed after this PR merged) — brace restoration is now limited to xyz/wmts/ogc-vector-tiles, with a test asserting a WMS token keeps its encoded braces.

return kind && SERVICE_KINDS.has(kind) && url ? { kind, url } : null;
}
export interface RemoteGeoJsonLayer {
data: FeatureCollection;
name: string;
Expand Down
17 changes: 10 additions & 7 deletions extensions/geolibre-chrome/PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
# Privacy policy — Open data in GeoLibre

Last updated: August 15, 2026
Last updated: August 16, 2026

Open data in GeoLibre does not independently collect, retain, or sell personal
information, browsing history, page contents, or usage analytics. It does
forward the complete dataset and style URLs that the user explicitly selects,
information, page contents, or usage analytics. It does forward the complete
dataset, service, and style URLs that the user explicitly selects,
as described below.

The extension uses Chrome's `activeTab` permission to inspect links and
structured metadata on the current page only after the user clicks the
extension's toolbar icon. Inspection happens locally in the browser. Results
are held in memory only while the popup is open.
extension's toolbar icon. The extension also observes completed HTTP(S)
requests locally to identify geospatial services used by interactive maps. It
does not inspect response bodies. Detected service URLs are held in Chrome's
in-memory session storage and removed when their tab closes.

When the user chooses **Open in GeoLibre**, the complete selected HTTP(S)
dataset and style URLs are placed in the query string of a new
Expand All @@ -23,7 +25,8 @@ The navigation request exposes its URL and the user's IP address to GeoLibre's
web-hosting infrastructure, where standard service logs may retain them. The
navigation may also appear in browser history. See the current
[GeoLibre privacy policy](https://geolibre.app/privacy/) for the service's data
practices. The extension itself does not retain the URLs after its popup closes.
practices. The extension itself does not persist URLs beyond the browser tab's
session.

The extension does not fetch or upload the datasets. GeoLibre requests them
directly from their original servers, subject to those servers' privacy
Expand All @@ -32,7 +35,7 @@ from the source page are not forwarded, although credentials embedded directly
in a selected URL are part of the URL and are forwarded.

The extension uses no remote code, advertising, analytics, tracking pixels,
cookies, accounts, or extension storage.
cookies, accounts, or persistent extension storage.

Questions may be submitted through the GeoLibre repository:
<https://github.com/opengeos/GeoLibre/issues>.
35 changes: 31 additions & 4 deletions extensions/geolibre-chrome/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Open data in GeoLibre

A Manifest V3 Chrome extension that finds supported geospatial dataset links on
the current page and opens selected files together in GeoLibre using repeated
`data` query parameters.
the current page, observes geospatial service requests made by interactive maps,
and opens selected data in GeoLibre.

## Install locally

Expand All @@ -11,8 +11,10 @@ the current page and opens selected files together in GeoLibre using repeated
3. Choose **Load unpacked**.
4. Select this `extensions/geolibre-chrome` directory.

The extension requests `activeTab` and `scripting` only. It scans a page after
you click its toolbar icon and does not retain page contents or browsing data.
The extension scans document links after you click its toolbar icon. It also
observes completed HTTP(S) requests locally so it can recognize services used
by interactive web maps. Detected service URLs remain only in session storage
for the lifetime of their tab.

## Package for the Chrome Web Store

Expand Down Expand Up @@ -41,6 +43,31 @@ virtualized, canonicalizes links to `data.source.coop`, and removes duplicate
page/download links. The popup can filter discovered files by vector or raster
type without changing the current selection.

The request watcher recognizes WMS, WMTS, WFS, OGC API Features, ArcGIS Feature
Services, XYZ/TMS image tiles, and PBF/MVT vector tiles. Tile requests are
collapsed into reusable `{z}/{x}/{y}` templates, and repeated requests from the
same service appear once.

## Sample websites for manual testing

Open a sample, wait for its map to load, and pan or zoom to generate network
requests. Then open the extension and confirm that it lists the expected
service. Selecting the result should open the matching GeoLibre Add Data dialog
with the service URL filled in.

| Service | Sample website | Expected result |
| --- | --- | --- |
| XYZ raster tiles | [OpenStreetMap](https://www.openstreetmap.org/) | An XYZ URL template such as `https://tile.openstreetmap.org/{z}/{x}/{y}.png` |
| PBF/MVT vector tiles | [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) | A vector tile URL template ending in `.pbf` or `.mvt` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| WMTS | [OpenLayers WMTS example](https://openlayers.org/en/latest/examples/wmts.html) | A WMTS service URL without tile-coordinate parameters |
| WMS | [OpenLayers WMS GetFeatureInfo example](https://openlayers.org/en/latest/examples/getfeatureinfo-tile.html) | A WMS service endpoint without request-specific parameters |
| OGC API Features | [OpenLayers OGC API Features example](https://openlayers.org/en/latest/examples/mapserver-ogc-features.html) | An OGC API Features collection or items URL |
| ArcGIS Feature Service | [ArcGIS FeatureLayer sample](https://developers.arcgis.com/javascript/latest/sample-code/layers-featurelayer/) | An ArcGIS `FeatureServer` service or layer URL |

Some samples also use an XYZ basemap, so the target service and a basemap may
both appear. After selecting a result, add the layer in GeoLibre and verify that
the browser console does not report a static-file CORS error.

Comment thread
giswqs marked this conversation as resolved.
Outdated
Remote servers must allow GeoLibre to fetch the selected URLs through CORS.
Complete HTTP(S) URLs, including signed query parameters, are forwarded to
GeoLibre. Cookies and other browser-session credentials are not forwarded, so
Expand Down
20 changes: 14 additions & 6 deletions extensions/geolibre-chrome/STORE_LISTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Open data in GeoLibre

## Summary

Find geospatial datasets on a webpage and open selected files together in GeoLibre.
Find geospatial datasets and map services on a webpage and open them in GeoLibre.

## Detailed description

Expand All @@ -22,9 +22,13 @@ The extension also reads schema.org download metadata, understands existing
GeoLibre links, pairs matching GeoLibre style files, and discovers the complete
file inventory on virtualized Source Cooperative repository pages.

Privacy is deliberately narrow: the extension scans only the active page after
you click it. It stores no browsing data, runs no analytics, and requests no
permanent access to websites.
Interactive maps are supported too. The extension recognizes completed WMS,
WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile
requests made by the current tab.

Detected service URLs stay in temporary browser session storage only until the
tab closes. The extension runs no analytics and sends no browsing activity to
GeoLibre unless you explicitly select an item and open it.

Dataset servers must allow browser access through CORS. Complete HTTP(S) URLs,
including signed query parameters, are forwarded to GeoLibre. Cookies and other
Expand All @@ -45,6 +49,10 @@ English
- `activeTab`: grants temporary access to the page only after the user invokes
the extension, so its dataset links can be inspected.
- `scripting`: injects the local, packaged dataset scanner into that active tab.
- `webRequest` and HTTP(S) host access: observes completed requests locally to
identify geospatial services used by interactive maps.
- `storage`: holds detected service URLs in session-only storage until their tab
closes so the popup can display them.

The extension does not request host permissions, storage, browsing history,
downloads, cookies, or remote code.
The extension does not request browsing history, downloads, cookies, or remote
code.
Loading
Loading