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
25 changes: 22 additions & 3 deletions apps/geolibre-desktop/src/lib/share-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,12 @@ function carriesOwnData(layer: GeoLibreLayer, embeddedLayerIds?: ReadonlySet<str
if (layer.geojson) return true;
const metadata = layer.metadata ?? {};
if (metadata.embeddedGeoJSON) return true;
return isPlainObject((layer.source ?? {}).data);
const data = (layer.source ?? {}).data;
// An object is an inline GeoJSON payload; an array is the row set a
// non-GeoJSON deck.gl visualization (arc, heatmap, hexagon built from a CSV)
// keeps in `source.data`. Both travel inside the project file. Only a *string*
// `data` is a URL, and that is a reference like any other.
return isPlainObject(data) || Array.isArray(data);
}

/**
Expand Down Expand Up @@ -513,8 +518,22 @@ async function probeTarget(
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);
try {
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 HEAD already proved the host answers and lets this origin read the
// response, so a rejection here is about the ranged request rather than
// the host. `Range` is CORS-safelisted only for a simple byte range, and
// an older webview may preflight it and get no matching
// `Access-Control-Allow-Headers` back, even though the plain GET a
// renderer issues would succeed. Fall back to what HEAD said instead of
// reporting a working host as unreachable.
return outcomeForStatus(head.status);
}
} catch (error) {
const failure = classifyFetchFailure(error);
if (failure.kind === "abort") return { status: "unchecked", reason: "aborted" };
Expand Down
70 changes: 70 additions & 0 deletions tests/share-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ function fakeFetch(routes: Record<string, number | Error>) {
return { fn, calls };
}

/**
* Answers HEAD with `headStatus` and rejects the ranged GET, recording both
* attempts so a test can prove the retry actually ran.
*/
function rejectingRangedGet(headStatus: number) {
const attempts: { method?: string; range?: string }[] = [];
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
attempts.push({
method: init?.method,
range: (init?.headers as Record<string, string> | undefined)?.Range,
});
if (init?.method === "HEAD") return new Response(null, { status: headStatus });
throw new TypeError("Failed to fetch");
}) as unknown as typeof fetch;
return { fn, attempts };
}

describe("isPrivateHostname", () => {
it("recognizes loopback, private ranges, and reserved suffixes", () => {
for (const host of [
Expand Down Expand Up @@ -194,6 +211,23 @@ describe("collectShareSources", () => {
assert.equal(refs[0].probeUrl, "https://tiles.example.com/tileset.json");
});

it("skips a deck.gl visualization whose rows are inlined as an array", () => {
const refs = collectShareSources({
layers: [
layer({
id: "a",
name: "Arcs from CSV",
type: "deckgl-viz",
source: { type: "deckgl-viz", data: [{ lat: 1, lon: 2 }] },
// Set when the layer is built from a local file, and not a reference
// a recipient needs: the rows travel in `source.data`.
sourcePath: "/home/me/flows.csv",
}),
],
});
assert.deepEqual(refs, []);
});

it("reports a query-backed layer that names no reference at all", () => {
const refs = collectShareSources({
layers: [
Expand Down Expand Up @@ -332,6 +366,42 @@ describe("probeShareSources", () => {
]);
});

it("falls back to the HEAD verdict when only the ranged GET is rejected", async () => {
const refs = collectShareSources({
layers: [
layer({ id: "a", name: "A", type: "cog", source: { url: "https://s3.example.com/a.tif" } }),
],
});
// HEAD answers 405, so the host is up and readable cross-origin; the ranged
// GET is rejected on its own (an older webview preflighting `Range`). That
// must not turn a working host into a "blocked" verdict.
const { fn, attempts } = rejectingRangedGet(405);
const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn });
assert.equal(probed[0].status, "reachable");
// Without this the test would still pass if the retry were dropped
// entirely, since a bare HEAD 405 also reads as reachable.
assert.deepEqual(attempts, [
{ method: "HEAD", range: undefined },
{ method: "GET", range: "bytes=0-0" },
]);
});

it("keeps a HEAD 403 credentialed when the ranged GET is also rejected", async () => {
const refs = collectShareSources({
layers: [
layer({ id: "a", name: "A", type: "cog", source: { url: "https://s3.example.com/a.tif" } }),
],
});
const { fn, attempts } = rejectingRangedGet(403);
const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn });
assert.equal(probed[0].status, "credentialed");
assert.equal(probed[0].reason, "auth-required");
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: [
Expand Down
Loading