-
-
Notifications
You must be signed in to change notification settings - Fork 570
fix: route share.geolibre.app through native HTTP on desktop #1109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The routed host is derived from 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); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import assert from "node:assert/strict"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coverage gap: these tests cover 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"); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor duplication:
requestHostis byte-for-byte identical to the helper of the same name ingeocoding-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.