Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/geolibre-desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
]
},
{
"comment": "Geocoding (place search / reverse geocode) is routed through the native HTTP client so it bypasses the WebView's CORSpublic Nominatim's CDN intermittently omits the CORS header on cached responses, which the WebView rejects as 'Search failed'. Also lets us send a User-Agent as Nominatim's policy requires. Scoped to the built-in geocoder provider hosts only (keep in sync with the GEOCODING_PROVIDERS registry / lib/geocoding-fetch.ts); custom/self-hosted endpoints fall back to the browser fetch.",
"comment": "Hosts routed through the native HTTP client to bypass the WebView's CORS. Geocoding (place search / reverse geocode): public Nominatim's CDN intermittently omits the CORS header on cached responses (rejected as 'Search failed'), and the native client can send the User-Agent Nominatim's policy requires — keep the geocoder hosts in sync with the GEOCODING_PROVIDERS registry / lib/geocoding-fetch.ts. share.geolibre.app (project Share + gallery): its CORS policy allows the web origin but not the Tauri WebView origin, so browser fetches fail as 'Could not reach share.geolibre.app' — see lib/share-fetch.ts. Any other host (custom/self-hosted geocoder, third-party project URL) falls back to the browser fetch.",
"identifier": "http:default",
"allow": [
{ "url": "https://nominatim.openstreetmap.org/*" },
{ "url": "https://api.geocode.earth/*" },
{ "url": "https://geocode.arcgis.com/*" },
{ "url": "https://api.mapbox.com/*" },
{ "url": "https://maps.googleapis.com/*" }
{ "url": "https://maps.googleapis.com/*" },
{ "url": "https://share.geolibre.app/*" }
]
}
]
Expand Down
84 changes: 84 additions & 0 deletions apps/geolibre-desktop/src/lib/share-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// The fetch used by the share.geolibre.app client: project upload
// (`share-geolibre.ts`) and the gallery reads (`share-gallery.ts`).
//
// Defaults to the WebView's browser `fetch`. The desktop build swaps in a
// native-HTTP-backed fetch (`installNativeShareFetch`) that bypasses the
// WebView's CORS enforcement for the share host — the share server's CORS
// policy allows the web origin but not the Tauri WebView origin
// (`tauri://localhost` / `http://tauri.localhost`), so a plain browser `fetch`
// from the desktop app throws a `TypeError` that surfaces to the user as
// "Could not reach share.geolibre.app." This mirrors the geocoding fix in
// `geocoding-fetch.ts`.

import { resolveShareBaseUrl } from "./share-geolibre";

/**
* The active share fetch. Browser `fetch` by default; the desktop build
* overrides it via {@link installNativeShareFetch}. Callers read it lazily
* through {@link getShareFetch} so the override applies even to modules imported
* before install runs.
*/
let shareFetch: typeof globalThis.fetch = (input, init) => fetch(input, init);

/** The fetch the share client should use; the desktop build overrides it. */
export function getShareFetch(): typeof globalThis.fetch {
return shareFetch;
}

/** Override the share fetch. Exposed for {@link installNativeShareFetch} and tests. */
export function setShareFetch(fetchImpl: typeof globalThis.fetch): void {
shareFetch = fetchImpl;
}

/** Restore the default browser `fetch` (used to reset state between tests). */
export function resetShareFetch(): void {
shareFetch = (input, init) => fetch(input, init);
}

/** The request URL's host, or null when it cannot be parsed. */
function requestHost(input: RequestInfo | URL): string | null {
Comment on lines +38 to +39

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.

Minor duplication: requestHost is byte-for-byte identical to the helper of the same name in geocoding-fetch.ts. Not a functional issue, but if a third native-fetch host gets added later this logic will likely be copy-pasted a third time — could be worth hoisting into a small shared util alongside the two lazily-installed fetch overrides.

Confidence: low — pure style nit.

try {
const href =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
return new URL(href).host;
} catch {
return null;
}
}

/**
* Route requests to the share host through Tauri's native HTTP client instead of
* the WebView's `fetch`, bypassing browser CORS enforcement. Requests to any
* other host keep the browser `fetch` unchanged, so the native, CORS-exempt
* client stays scoped to the single share host — which must also be listed in
* the `http:default` capability scope (`src-tauri/capabilities/default.json`).
*
* The host is resolved from {@link resolveShareBaseUrl} (the configured or
* production share URL) at install time, so a `VITE_GEOLIBRE_SHARE_URL` override
* is honored.
*
* Loaded lazily and only in the desktop build so the web/embedded bundles never
* pull in `@tauri-apps/plugin-http`.
*/
export async function installNativeShareFetch(): Promise<void> {
let shareHost: string | null;
try {
shareHost = new URL(resolveShareBaseUrl()).host;
Comment on lines +60 to +70

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.

The routed host is derived from resolveShareBaseUrl(), which honors the runtime-configurable VITE_GEOLIBRE_SHARE_URL env var (including http://localhost/127.0.0.1 overrides for local dev). But the http:default capability scope in default.json hard-codes only https://share.geolibre.app/*. If that env var is ever set to anything else, shareHost will match a host that Tauri's native HTTP plugin isn't permitted to reach, so tauriFetch(...) will fail with a permission error — a regression versus today's behavior, where a plain browser fetch would at least attempt the request (subject to the dev server's own CORS config). The doc comment's claim that the override "is honored" is misleading in the desktop build; consider scoping native routing to the production host only (mirroring how geocoding-fetch.ts fixes NATIVE_FETCH_HOSTS to the known provider list) or noting that overriding the share URL for a Tauri build also requires updating the capability file.

Confidence: medium — this only manifests for non-default builds (self-hosted/dev share servers), which may not be a supported/exercised configuration today.

} catch {
shareHost = null;
}
if (!shareHost) return;
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
setShareFetch((input, init) => {
if (requestHost(input) !== shareHost) {
// Not the share host (e.g. a third-party thumbnail or project URL): keep
// the browser fetch, unchanged and outside the native capability scope.
return fetch(input, init);
}
return tauriFetch(input, init);
});
}
16 changes: 12 additions & 4 deletions apps/geolibre-desktop/src/lib/share-gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// with a personal API token to also return the signed-in user's `unlisted` and
// `private` projects.

import { getShareFetch } from "./share-fetch";
import { resolveShareBaseUrl } from "./share-geolibre";

/**
Expand Down Expand Up @@ -190,7 +191,9 @@ export async function fetchSharedProjects(
options: FetchSharedProjectsOptions = {},
): Promise<FetchSharedProjectsResult> {
const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, "");
const fetchImpl = options.fetchImpl ?? fetch;
// See share-fetch.ts: on desktop this routes the share host through Tauri's
// native HTTP client so the gallery listing isn't blocked by WebView CORS.
const fetchImpl = options.fetchImpl ?? getShareFetch();

const params = new URLSearchParams();
if (options.limit != null) params.set("limit", String(options.limit));
Expand Down Expand Up @@ -308,9 +311,14 @@ export async function fetchMyProjects(
options: FetchMyProjectsOptions,
): Promise<SharedProject[]> {
const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, "");
// One auth path for both production and tests: the injected fetch (if any)
// flows through the same same-origin gating as the global fetch.
const authFetch = shareAuthorizedFetch(options.token, base, options.fetchImpl);
// One auth path for both production and tests: the injected fetch (or the
// share fetch, which the desktop build routes natively to bypass CORS — see
// share-fetch.ts) flows through the same same-origin token gating.
const authFetch = shareAuthorizedFetch(
options.token,
base,
options.fetchImpl ?? getShareFetch(),
);

const timeout = AbortSignal.timeout(LISTING_TIMEOUT_MS);
const signal = options.signal
Expand Down
7 changes: 5 additions & 2 deletions apps/geolibre-desktop/src/lib/share-geolibre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// user created on the website. Used by the Project > Share action.

import { DEFAULT_PROJECT_NAME } from "@geolibre/core";
import { getShareFetch } from "./share-fetch";

export type ShareVisibility = "public" | "unlisted" | "private";

Expand Down Expand Up @@ -54,7 +55,7 @@ export interface ShareUploadOptions {
/** Override the share host; defaults to the configured/production URL. */
baseUrl?: string;
signal?: AbortSignal;
/** Injected for testing; defaults to the global fetch. */
/** Injected for testing; defaults to the share fetch (see share-fetch.ts). */
fetchImpl?: typeof fetch;
}

Expand Down Expand Up @@ -140,7 +141,9 @@ export async function uploadProjectToShare(
}

const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, "");
const fetchImpl = options.fetchImpl ?? fetch;
// Defaults to the share fetch, which the desktop build routes through Tauri's
// native HTTP client to bypass WebView CORS (see share-fetch.ts).
const fetchImpl = options.fetchImpl ?? getShareFetch();

// Bound the request so a stalled server can't leave the dialog spinning
// forever; combine it with the caller's abort signal (dialog close).
Expand Down
12 changes: 12 additions & 0 deletions apps/geolibre-desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ if (isTauri()) {
// silent unhandled rejection.
console.error("[GeoLibre] Failed to install native geocoding fetch", error);
});
// Likewise route share.geolibre.app (project Share + gallery) through the
// native HTTP client: the share server's CORS policy allows the web origin but
// not the Tauri WebView origin, so a browser fetch fails as "Could not reach
// share.geolibre.app." Lazy + desktop-only so web/embedded never import the
// Tauri HTTP plugin.
void import("./lib/share-fetch")
.then(({ installNativeShareFetch }) => installNativeShareFetch())
.catch((error: unknown) => {
// On failure the share client stays on the browser fetch (the CORS-blocked
// path this fixes); surface it rather than swallow the rejection.
console.error("[GeoLibre] Failed to install native share fetch", error);
});
}
// Recover from chunks orphaned by a web redeploy (stale lazy import → 404). A
// no-op in the desktop build, whose chunks are bundled locally.
Expand Down
115 changes: 115 additions & 0 deletions tests/share-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";

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.

Coverage gap: these tests cover getShareFetch/setShareFetch/resetShareFetch and that the share client functions call getShareFetch(), but none exercise installNativeShareFetch() itself — the host-matching logic that decides whether a request goes through tauriFetch or falls back to the browser fetch (share-fetch.ts:76-83). A regression there (e.g. a host mismatch due to a resolveShareBaseUrl() change) wouldn't be caught by this suite. (Note: installNativeGeocodingFetch has the same gap already, so this isn't a new pattern — just flagging since it's easy to miss.)

Confidence: low-medium — not blocking, but the core new CORS-bypass logic is currently untested.

import { afterEach, describe, it } from "node:test";
import {
getShareFetch,
resetShareFetch,
setShareFetch,
} from "../apps/geolibre-desktop/src/lib/share-fetch";
import { uploadProjectToShare } from "../apps/geolibre-desktop/src/lib/share-geolibre";
import {
fetchMyProjects,
fetchSharedProjects,
} from "../apps/geolibre-desktop/src/lib/share-gallery";

// A minimal JSON Response for a share endpoint.
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

describe("share fetch override", () => {
afterEach(() => resetShareFetch());

it("defaults to the global fetch and is overridable + resettable", async () => {
const original = globalThis.fetch;
try {
let calledDefault = 0;
globalThis.fetch = (() => {
calledDefault += 1;
return Promise.resolve(new Response("ok"));
}) as typeof fetch;

// Default share fetch delegates to whatever globalThis.fetch is.
await getShareFetch()("https://example.com/");
assert.equal(calledDefault, 1);

// Override wins.
let calledOverride = 0;
setShareFetch((() => {
calledOverride += 1;
return Promise.resolve(new Response("ok"));
}) as typeof fetch);
await getShareFetch()("https://example.com/");
assert.equal(calledOverride, 1);
assert.equal(calledDefault, 1);

// Reset restores the default (global fetch) path.
resetShareFetch();
await getShareFetch()("https://example.com/");
assert.equal(calledDefault, 2);
} finally {
globalThis.fetch = original;
}
});

// Regression guard for the desktop CORS fix: the share client functions must
// route through the installed share fetch when no fetchImpl is passed, so the
// desktop build's native (CORS-exempt) fetch actually gets used.
it("uploadProjectToShare uses the installed share fetch", async () => {
let seen: string | null = null;
setShareFetch(((input: RequestInfo | URL) => {
seen = typeof input === "string" ? input : input.toString();
return Promise.resolve(
jsonResponse({
project: {
projectUrl: "https://share.geolibre.app/u/p",
rawJsonUrl: "https://share.geolibre.app/u/p.geolibre.json",
},
}),
);
}) as typeof fetch);

await uploadProjectToShare({
token: "tok",
filename: "p.geolibre.json",
content: "{}",
visibility: "public",
});
assert.equal(seen, "https://share.geolibre.app/api/projects");
});

it("fetchSharedProjects uses the installed share fetch", async () => {
let seen: string | null = null;
setShareFetch(((input: RequestInfo | URL) => {
seen = typeof input === "string" ? input : input.toString();
return Promise.resolve(jsonResponse({ projects: [] }));
}) as typeof fetch);

await fetchSharedProjects();
assert.equal(seen, "https://share.geolibre.app/api/projects");
});

it("fetchMyProjects uses the installed share fetch (with auth)", async () => {
const seen: string[] = [];
let auth: string | null = null;
setShareFetch(((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
seen.push(url);
auth = new Headers(init?.headers).get("Authorization");
if (url.endsWith("/api/users/me")) {
return Promise.resolve(jsonResponse({ user: { username: "giswqs" } }));
}
return Promise.resolve(jsonResponse({ projects: [] }));
}) as typeof fetch);

await fetchMyProjects({ token: "tok" });
assert.deepEqual(seen, [
"https://share.geolibre.app/api/users/me",
"https://share.geolibre.app/api/users/giswqs/projects",
]);
// The share-host request carries the bearer token via shareAuthorizedFetch.
assert.equal(auth, "Bearer tok");
});
});
Loading