Skip to content

Commit b3058a2

Browse files
committed
fix(embed): resolve highlight ids against the layer instead of trusting them
Explicit `featureId`/`featureIds` were passed through unchecked, so a `highlightFeature` naming an id no feature carries — a typo, or any layer whose features live in its MapLibre source rather than `layer.geojson` — selected a phantom id, drew no highlight, and still answered `ack {ok: true}`. Resolve ids against the layer's features under the same `String(feature.id ?? index)` convention the map controller uses, and reject a request that names features but resolves to none, leaving the user's existing selection untouched. A request naming nothing is still the documented "clear the highlight" form. Also fix the docs claim that outbound messages are never addressed to `*` (they are, before the handshake, when the `*` wildcard is configured), type the null-geometry test fixtures as `Feature<null>[]` (`Feature` defaults its geometry parameter to `Geometry`), and match the surrounding assertion style in one test.
1 parent ab850e4 commit b3058a2

4 files changed

Lines changed: 62 additions & 14 deletions

File tree

apps/geolibre-desktop/src/hooks/useEmbedApi.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ export function useEmbedApi(mapControllerRef: RefObject<MapController | null>):
125125
const layer = state.layers.find((item) => item.id === target.layerId);
126126
if (!layer) throw new Error(`No layer with id "${target.layerId}"`);
127127
const ids = resolveHighlightIds(layer.geojson?.features ?? [], target);
128+
// A request that names nothing is the documented "clear the highlight"
129+
// form. A request that DOES name features but resolves to none is a
130+
// failure, not a clear: the id may be a typo, or the layer may keep its
131+
// features in its MapLibre source rather than `geojson` (vector tiles,
132+
// source-owned layers), where nothing can be resolved here. Report that
133+
// instead of wiping the user's selection behind an `ok` ack.
134+
const askedForFeatures = target.featureIds.length > 0 || target.filter !== null;
135+
if (askedForFeatures && ids.length === 0) {
136+
throw new Error(
137+
`highlightFeature matched no features in layer "${target.layerId}"` +
138+
(layer.geojson ? "" : " (the layer's features are not readable from the store)"),
139+
);
140+
}
128141
// Drive the store as well as the map so the Attribute table and Layers
129142
// panel agree with what the host highlighted, exactly as a map click does.
130143
state.selectLayer(target.layerId);

apps/geolibre-desktop/src/lib/embed-api.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// GeoLibre does not control — so it is off unless the deployment names the
1313
// origins it trusts, and every message is checked against that list.
1414

15-
import type { Feature } from "geojson";
15+
import type { Feature, Geometry } from "geojson";
1616

1717
/** Protocol version carried by every message in both directions. */
1818
export const EMBED_API_VERSION = 1;
@@ -324,16 +324,24 @@ export function parseEmbedRequest(
324324
/**
325325
* Resolve the feature ids a highlight target names within a layer's features.
326326
*
327-
* Explicit ids are kept as sent (they are matched against the same
328-
* `String(feature.id ?? index)` convention the map controller uses); a `filter`
329-
* selects every feature whose properties equal all of the filter's pairs.
327+
* Ids are resolved against the layer, not taken on trust: an explicit id is kept
328+
* only when a feature carries it under the same `String(feature.id ?? index)`
329+
* convention the map controller uses, and a `filter` selects every feature whose
330+
* properties equal all of the filter's pairs. An id that names nothing is
331+
* dropped here so the caller can tell a real match from a typo (or from a layer
332+
* whose features are not readable at all) rather than selecting a phantom.
330333
*
331-
* @param features - The layer's features, in map order.
334+
* @param features - The layer's features, in map order. Only ids and properties
335+
* are read, so a null-geometry feature (an attribute-only table) is accepted.
332336
* @param target - The parsed highlight target.
333337
* @returns Feature ids to highlight, in feature order for filter matches.
334338
*/
335-
export function resolveHighlightIds(features: Feature[], target: EmbedHighlightTarget): string[] {
336-
const ids = [...target.featureIds];
339+
export function resolveHighlightIds(
340+
features: Feature<Geometry | null>[],
341+
target: EmbedHighlightTarget,
342+
): string[] {
343+
const known = new Set(features.map((feature, index) => String(feature.id ?? index)));
344+
const ids = target.featureIds.filter((id) => known.has(id));
337345
if (!target.filter) return ids;
338346
const pairs = Object.entries(target.filter);
339347
features.forEach((feature, index) => {

docs/user-guide/embedding.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ For a static build, bake it in instead:
102102
Entries are origins (`scheme://host[:port]`); a trailing path is ignored. `*`
103103
allows any origin and is only appropriate on a private network. The allowlist is
104104
enforced in both directions: a message from an unlisted origin is ignored, and
105-
every message the app sends is addressed to a listed origin, never `*`.
105+
every message the app sends is addressed to a listed origin. (With `*`
106+
configured, outbound messages are addressed to `*` until the host's first
107+
message identifies it, which is one more reason to name your origins.)
106108

107109
Setting the allowlist also narrows the `?embed=1` project/scripting bridges (used
108110
by the [Python package](../python.md)) to the same origins. As extra hardening
@@ -129,9 +131,15 @@ them out of the other `postMessage` traffic on your page.
129131
| `highlightFeature` | `{ layerId, featureId \| featureIds \| filter, fit }` | Selects and highlights features; `filter` matches properties. `fit` zooms to them. |
130132
| `openTool` | `{ id, params }` | Opens the Processing dialog on a tool, pre-filling `params`. Runtime twin of `?tool=`. |
131133

132-
Send `{ layerId }` alone to `highlightFeature` to clear the highlight. Add a
133-
`requestId` to any message and the app answers with an `ack` (below) reporting
134-
whether it worked.
134+
Send `{ layerId }` alone to `highlightFeature` to clear the highlight. A request
135+
that names features (or a filter) but matches none is rejected rather than
136+
treated as a clear, so a mistyped id does not silently wipe the user's selection.
137+
Highlighting reads the layer's features from the project, so it applies to vector
138+
layers GeoLibre holds as GeoJSON, not to ones whose features live only in a tile
139+
source.
140+
141+
Add a `requestId` to any message and the app answers with an `ack` (below)
142+
reporting whether it worked.
135143

136144
### GeoLibre to host
137145

tests/embed-api.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,23 +267,42 @@ describe("parseEmbedRequest: openTool", () => {
267267
});
268268

269269
it("requires an id", () => {
270-
assert.ok(parseEmbedRequest(message("openTool", {}))?.hasOwnProperty("error"));
270+
const parsed = parseEmbedRequest(message("openTool", {}));
271+
assert.ok(parsed && "error" in parsed);
271272
});
272273
});
273274

274275
describe("resolveHighlightIds", () => {
275-
const features: Feature[] = [
276+
// Geometry is irrelevant here (only ids and properties are read), so these
277+
// fixtures declare it as null and type accordingly.
278+
const features: Feature<null>[] = [
276279
{ type: "Feature", id: "f1", properties: { parcel: "A-1", area: 12 }, geometry: null },
277280
{ type: "Feature", properties: { parcel: "A-2", area: 34 }, geometry: null },
278281
{ type: "Feature", id: "f3", properties: { parcel: "A-1", area: 56 }, geometry: null },
279282
];
280283

281-
it("returns explicit ids untouched when no filter is given", () => {
284+
it("keeps an explicit id that the layer actually carries", () => {
282285
assert.deepEqual(resolveHighlightIds(features, highlightTarget({ featureIds: ["f3"] })), [
283286
"f3",
284287
]);
285288
});
286289

290+
it("resolves an explicit id against the index convention for an id-less feature", () => {
291+
assert.deepEqual(resolveHighlightIds(features, highlightTarget({ featureIds: ["1"] })), ["1"]);
292+
});
293+
294+
it("drops an explicit id no feature carries, rather than selecting a phantom", () => {
295+
assert.deepEqual(resolveHighlightIds(features, highlightTarget({ featureIds: ["NOPE"] })), []);
296+
assert.deepEqual(
297+
resolveHighlightIds(features, highlightTarget({ featureIds: ["f1", "NOPE"] })),
298+
["f1"],
299+
);
300+
});
301+
302+
it("resolves nothing when the layer has no readable features", () => {
303+
assert.deepEqual(resolveHighlightIds([], highlightTarget({ featureIds: ["f1"] })), []);
304+
});
305+
287306
it("matches every feature satisfying the filter, in feature order", () => {
288307
const ids = resolveHighlightIds(features, highlightTarget({ filter: { parcel: "A-1" } }));
289308
assert.deepEqual(ids, ["f1", "f3"]);

0 commit comments

Comments
 (0)