Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,20 @@ ARG VITE_GEOLIBRE_SHARE_URL=
# Self-hosted collaboration relay (wss://…). Unset leaves collaboration dark.
# Also settable at RUN time (-e GEOLIBRE_COLLAB_URL=…).
ARG VITE_GEOLIBRE_COLLAB_URL=
# Set to 1 to strip every external CDN reference (unpkg.com, cdn.jsdelivr.net,
# …) from the build output, for deployments that may not load third-party
# hosts. Features that depend on CDN-hosted assets are disabled or degraded —
# see docs/self-hosting.md. Build-time only: the flag is baked into the bundle,
# so it cannot be flipped at RUN time the way the embed/share/collab URLs can.
ARG GEOLIBRE_NO_EXTERNAL_CDN=
ENV GEOLIBRE_APP_BASE=${GEOLIBRE_APP_BASE}
ENV VITE_GEE_OAUTH_CLIENT_ID=${VITE_GEE_OAUTH_CLIENT_ID}
ENV VITE_MAPILLARY_ACCESS_TOKEN=${VITE_MAPILLARY_ACCESS_TOKEN}
ENV VITE_WELCOME_DISABLED=${VITE_WELCOME_DISABLED}
ENV VITE_GEOLIBRE_EMBED_ORIGINS=${VITE_GEOLIBRE_EMBED_ORIGINS}
ENV VITE_GEOLIBRE_SHARE_URL=${VITE_GEOLIBRE_SHARE_URL}
ENV VITE_GEOLIBRE_COLLAB_URL=${VITE_GEOLIBRE_COLLAB_URL}
ENV GEOLIBRE_NO_EXTERNAL_CDN=${GEOLIBRE_NO_EXTERNAL_CDN}

# The `prebuild` hook of apps/geolibre-desktop runs scripts/build-jupyterlite.mjs,
# which generates the site the Notebook panel embeds. That script is best-effort
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAppStore } from "@geolibre/core";
import type { MapController } from "@geolibre/map";
import {
detectObjects,
isOrtAvailable,
readDetectionImage,
readRasterData,
type Detection,
Expand Down Expand Up @@ -184,12 +185,23 @@ export function ObjectDetectionDialog({

const [imageBytes, setImageBytes] = useState<ArrayBuffer | null>(null);
const [imageName, setImageName] = useState("");
// Inference always goes through onnxruntime-web, which cannot load in a
// no-external-CDN build — a user-supplied .onnx does not help.
const ortAvailable = isOrtAvailable();

// Default to a built-in model so detection works out of the box with no file.
const [modelSource, setModelSource] = useState<"builtin" | "local">("builtin");
const [builtinModelId, setBuiltinModelId] = useState(BUILTIN_DETECTION_MODELS[0].id);
// A build with external CDNs disabled ships no built-ins (the weights are
// CDN-hosted), so fall back to a user-supplied model rather than indexing
// into an empty list.
const [modelSource, setModelSource] = useState<"builtin" | "local">(
BUILTIN_DETECTION_MODELS.length > 0 ? "builtin" : "local",
);
const [builtinModelId, setBuiltinModelId] = useState(BUILTIN_DETECTION_MODELS[0]?.id ?? "");
const [modelBytes, setModelBytes] = useState<ArrayBuffer | null>(null);
const [modelName, setModelName] = useState("");
const [classNames, setClassNames] = useState(BUILTIN_DETECTION_MODELS[0].classNames.join(", "));
const [classNames, setClassNames] = useState(
BUILTIN_DETECTION_MODELS[0]?.classNames.join(", ") ?? "",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const [confidence, setConfidence] = useState(0.25);
const [iou, setIou] = useState(0.45);
const [inputSize, setInputSize] = useState(640);
Expand Down Expand Up @@ -495,6 +507,17 @@ export function ObjectDetectionDialog({
{t("objectDetection.hint")}
</p>

{/* Inference needs the ONNX Runtime WASM backend, which a
no-external-CDN build cannot load at all — so say so up front
rather than letting the user pick an image and a local model and
only fail at the end of the run. */}
{!ortAvailable && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{t("objectDetection.unavailableNoExternalCdn")}
</p>
)}

{/* Image source */}
<div className="grid gap-1.5">
<Label htmlFor="det-image" className="text-xs">
Expand Down Expand Up @@ -539,7 +562,9 @@ export function ObjectDetectionDialog({
if (next === "builtin") selectBuiltinModel(builtinModelId);
}}
>
<option value="builtin">{t("objectDetection.modelSourceBuiltin")}</option>
{BUILTIN_DETECTION_MODELS.length > 0 ? (
<option value="builtin">{t("objectDetection.modelSourceBuiltin")}</option>
) : null}
<option value="local">{t("objectDetection.modelSourceLocal")}</option>
</Select>
</div>
Expand Down Expand Up @@ -676,7 +701,9 @@ export function ObjectDetectionDialog({
<div className="flex items-center gap-3">
<Button
onClick={() => void handleRun()}
disabled={running || !imageBytes || (modelSource === "local" && !modelBytes)}
disabled={
!ortAvailable || running || !imageBytes || (modelSource === "local" && !modelBytes)
}
className="gap-2"
>
{running ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useAppStore } from "@geolibre/core";
import type { MapController } from "@geolibre/map";
import {
isOrtAvailable,
readRasterData,
segmentEverything,
type RasterData,
Expand Down Expand Up @@ -112,6 +113,10 @@ export function SegmentEverythingPanel({
const setOpen = useAppStore((s) => s.setSegmentEverythingOpen);
const addGeoJsonLayer = useAppStore((s) => s.addGeoJsonLayer);

// Segmentation always goes through onnxruntime-web, which cannot load in a
// no-external-CDN build.
const ortAvailable = isOrtAvailable();

const [imageBytes, setImageBytes] = useState<ArrayBuffer | null>(null);
const [imageName, setImageName] = useState("");
const [pointsPerSide, setPointsPerSide] = useState(16);
Expand Down Expand Up @@ -303,6 +308,15 @@ export function SegmentEverythingPanel({
{t("segmentEverything.hint")}
</p>

{/* SlimSAM runs through the same ONNX Runtime WASM backend as object
detection, which a no-external-CDN build cannot load. */}
{!ortAvailable && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{t("segmentEverything.unavailableNoExternalCdn")}
</p>
)}

{/* Image source */}
<div className="grid gap-1.5">
<Label htmlFor="seg-image" className="text-xs">
Expand Down Expand Up @@ -396,7 +410,7 @@ export function SegmentEverythingPanel({
<div className="flex items-center gap-3">
<Button
onClick={() => void handleRun()}
disabled={running || !imageBytes}
disabled={!ortAvailable || running || !imageBytes}
className="gap-2"
>
{running ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4183,6 +4183,7 @@
"title": "Object Detection",
"description": "Run a YOLO model exported to ONNX over a GeoTIFF or geotagged photo, fully in the browser. Each detected class is added as its own layer.",
"hint": "Use a built-in COCO model (downloaded and cached once) or bring your own YOLOv5/v8/v11 ONNX. GeoTIFF detections become georeferenced boxes. Photo detections become points at the camera's GPS location because a photo does not define a ground footprint.",
"unavailableNoExternalCdn": "Object detection is unavailable in this build. It needs the ONNX Runtime, which is loaded from an external CDN that this deployment has disabled. Supplying your own model file does not help.",
"imageLabel": "Image (GeoTIFF or geotagged photo)",
"imagePlaceholder": "Choose a GeoTIFF, JPEG, PNG, or WebP…",
"chooseImage": "Choose image",
Expand Down Expand Up @@ -4215,6 +4216,7 @@
"title": "Segment Everything",
"description": "Run SlimSAM automatically over a GeoTIFF, fully in the browser. Every detected object becomes a georeferenced polygon added as one layer.",
"hint": "A grid of points is sampled across the image and segmented with SlimSAM (downloaded and cached once). No clicks or labels — denser grids find more objects but take longer.",
"unavailableNoExternalCdn": "Segment Everything is unavailable in this build. It needs the ONNX Runtime, which is loaded from an external CDN that this deployment has disabled.",
"imageLabel": "Image (GeoTIFF)",
"imagePlaceholder": "Choose a GeoTIFF…",
"chooseImage": "Choose image",
Expand Down
13 changes: 13 additions & 0 deletions apps/geolibre-desktop/src/lib/build-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,16 @@
*/
export const IS_MAS_BUILD: boolean =
typeof __GEOLIBRE_MAS_BUILD__ !== "undefined" ? __GEOLIBRE_MAS_BUILD__ : false;

/**
* True when the build was configured with `GEOLIBRE_NO_EXTERNAL_CDN=1`, which
* strips every reference to an external CDN origin (unpkg.com,
* cdn.jsdelivr.net, …) so the deployment loads nothing from a third-party host.
* Features that depend on CDN-hosted assets (story map HTML export, the
* built-in detection models, ONNX WASM, the 3D Tiles decoders, Pyodide) are
* disabled or degraded. Guarded the same way as IS_MAS_BUILD so pure helpers
* stay importable in a plain Node test, where the define is absent.
* Injected by vite.config.ts.
*/
export const NO_EXTERNAL_CDN: boolean =
typeof __NO_EXTERNAL_CDN__ !== "undefined" ? __NO_EXTERNAL_CDN__ : false;
35 changes: 19 additions & 16 deletions apps/geolibre-desktop/src/lib/detection-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* mirror these files into a GeoLibre-controlled repo so the built-in option does
* not depend on an external account.
*/
import { NO_EXTERNAL_CDN } from "./build-flags";

/** The 80 COCO class names, in model output order. */
export const COCO_CLASSES = [
Expand Down Expand Up @@ -118,22 +119,24 @@ export interface BuiltinDetectionModel {
inputSize: number;
}

export const BUILTIN_DETECTION_MODELS: readonly BuiltinDetectionModel[] = [
{
id: "yolov8n-coco",
label: "YOLOv8n (COCO, 80 classes)",
url: "https://cdn.jsdelivr.net/gh/Hyuto/yolov8-onnxruntime-web@fc4a52c466d15ad4519873a0cef22fbc935b93b6/public/model/yolov8n.onnx",
classNames: COCO_CLASSES,
inputSize: 640,
},
{
id: "yolov5n-coco",
label: "YOLOv5n (COCO, 80 classes)",
url: "https://cdn.jsdelivr.net/gh/Hyuto/yolov5-onnxruntime-web@203637cc45962e40a81b2a7e78f98813f93971db/public/model/yolov5n.onnx",
classNames: COCO_CLASSES,
inputSize: 640,
},
];
export const BUILTIN_DETECTION_MODELS: readonly BuiltinDetectionModel[] = NO_EXTERNAL_CDN
? []
: [
{
id: "yolov8n-coco",
label: "YOLOv8n (COCO, 80 classes)",
url: "https://cdn.jsdelivr.net/gh/Hyuto/yolov8-onnxruntime-web@fc4a52c466d15ad4519873a0cef22fbc935b93b6/public/model/yolov8n.onnx",
classNames: COCO_CLASSES,
inputSize: 640,
},
{
id: "yolov5n-coco",
label: "YOLOv5n (COCO, 80 classes)",
url: "https://cdn.jsdelivr.net/gh/Hyuto/yolov5-onnxruntime-web@203637cc45962e40a81b2a7e78f98813f93971db/public/model/yolov5n.onnx",
classNames: COCO_CLASSES,
inputSize: 640,
},
];

/** Cache bucket holding downloaded model files so a model is fetched once. */
const MODEL_CACHE = "geolibre-detection-models";
Expand Down
23 changes: 21 additions & 2 deletions apps/geolibre-desktop/src/lib/pyodide/pyodide-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
// As of Pyodide 0.27.x, geopandas/shapely/pyproj (with PROJ data) ship in the
// distribution, so a single loadPackage("geopandas") pulls the whole graph.
import { getRuntimeEnvironment } from "@geolibre/core";
import { NO_EXTERNAL_CDN } from "../build-flags";

export const PYODIDE_VERSION = "0.27.7";

const DEFAULT_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
const DEFAULT_INDEX_URL = NO_EXTERNAL_CDN
? ""
: `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;

/**
* Resolve the Pyodide indexURL (where pyodide.js, the wasm runtime, the
Expand All @@ -24,12 +27,25 @@ const DEFAULT_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/
*
* Returns:
* The indexURL string, guaranteed to end with a slash.
*
* Raises:
* Error: When the build disabled external CDNs and no `VITE_PYODIDE_INDEX_URL`
* mirror was configured, so there is nowhere to load Pyodide from.
*/
export function getPyodideIndexUrl(
env: Record<string, string | undefined> = getRuntimeEnvironment(),
): string {
const override = env.VITE_PYODIDE_INDEX_URL?.trim();
const url = override || DEFAULT_INDEX_URL;
// Without an override the default is empty in a no-external-CDN build.
// Falling through would hand callers "/" — a bogus same-origin path that
// fails later with an opaque 404 — so fail here with the reason instead.
if (!url) {
throw new Error(
Comment thread
giswqs marked this conversation as resolved.
"Pyodide is unavailable in this build (external CDN resources are disabled). " +
"Set VITE_PYODIDE_INDEX_URL to a self-hosted Pyodide mirror to enable it.",
);
}
return url.endsWith("/") ? url : `${url}/`;
}

Expand All @@ -48,5 +64,8 @@ export function getPyodideIndexUrl(
* True when `indexURL` is the default CDN URL.
*/
export function isDefaultPyodideIndexUrl(indexURL: string): boolean {
return indexURL === DEFAULT_INDEX_URL;
// There is no default CDN in a no-external-CDN build, so nothing can match
// it — guard the empty default rather than reporting an empty indexURL as
// "the CDN default" and skipping the mirror's CSP workaround.
return DEFAULT_INDEX_URL !== "" && indexURL === DEFAULT_INDEX_URL;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ function workerUrl(): string {
}

function createHandle(): Promise<WorkerHandle> {
// Resolve the indexURL *before* creating the worker: it throws in a build
// with external CDNs disabled and no VITE_PYODIDE_INDEX_URL mirror. Thrown
// from the postMessage call below instead, it would escape synchronously
// after the worker exists — leaking it until the init timer fires ~2 min
// later, and rejecting a `ready` promise that was never returned to anyone
// (an unhandled rejection).
const indexURL = getPyodideIndexUrl();
const worker = new Worker(workerUrl());
const ready = new Promise<void>((resolve, reject) => {
// A fatal worker failure (failed init, or a crash after init): tear down
Expand Down Expand Up @@ -135,7 +142,7 @@ function createHandle(): Promise<WorkerHandle> {

worker.postMessage({
type: "init",
indexURL: getPyodideIndexUrl(),
indexURL,
vectorOpsSource,
});

Expand Down
10 changes: 10 additions & 0 deletions apps/geolibre-desktop/src/lib/storymap-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type MapProjection,
type StoryMap,
} from "@geolibre/core";
import { NO_EXTERNAL_CDN } from "./build-flags";
import { sanitizeStoryHtml } from "./sanitize-html";
import {
STORY_END_STEP_ID,
Expand Down Expand Up @@ -81,6 +82,15 @@ const BLANK_EXPORT_STYLE: Record<string, unknown> = {
* @returns A complete HTML document as a string.
*/
export function buildStoryMapHtml(options: StoryMapExportOptions): string {
// The exported standalone HTML loads maplibre-gl, scrollama, and the RTL text
// plugin from external CDNs (unpkg.com). When external CDN references are
// stripped from the build, the export cannot produce a functioning page.
if (NO_EXTERNAL_CDN) {
throw new Error(
"Story map HTML export is unavailable in this build (external CDN resources are disabled).",
);
}

const {
storymap,
basemapStyleUrl,
Expand Down
8 changes: 8 additions & 0 deletions apps/geolibre-desktop/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ declare const __GEOLIBRE_MAS_BUILD__: boolean;
// vite.config.ts.
declare const __GEOLIBRE_EMBED_BUILD__: boolean;

// True when the build is configured with GEOLIBRE_NO_EXTERNAL_CDN=1 to strip
// all references to external CDN origins (unpkg.com, cdn.jsdelivr.net, etc.).
// Features that depend on externally hosted resources (storymap HTML export,
// built-in object detection models, ONNX WASM, 3D Tiles decoders, Pyodide) are
// disabled or degraded. Intended for deployments that cannot load from untrusted
// CDNs (e.g. Amazon/Harmony). See vite.config.ts.
declare const __NO_EXTERNAL_CDN__: boolean;

// jsDelivr URLs for the PGlite engine and its PostGIS extension, injected by
// vite.config.ts. Only the embed (Jupyter wheel) build reads them, from
// pglite-loader.cdn.ts; web/desktop builds bundle PGlite and never reference
Expand Down
16 changes: 16 additions & 0 deletions apps/geolibre-desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ if (!process.env.VITE_GEE_OAUTH_CLIENT_ID) {
// the service worker from the desktop bundle.
const IS_TAURI_BUILD = !!process.env.TAURI_ENV_PLATFORM;

// Strip ALL external CDN references (unpkg.com, cdn.jsdelivr.net, etc.) from the
// build output. When set, features that depend on external CDN-hosted resources
// (storymap HTML export, object detection models, ONNX WASM, 3D Tiles decoders,
// Pyodide, PGlite, CereusDB, GDAL) are either disabled or degraded. Intended for
// deployments that cannot reference untrusted external CDNs (e.g. Harmony/Amazon).
// This implicitly forces GEOLIBRE_PGLITE_CDN=0, GEOLIBRE_CEREUS_CDN=0,
// GEOLIBRE_GDAL_CDN=0, and GEOLIBRE_DUCKDB_WASM_CDN=0.
const NO_EXTERNAL_CDN = process.env.GEOLIBRE_NO_EXTERNAL_CDN === "1";
if (NO_EXTERNAL_CDN) {
process.env.GEOLIBRE_PGLITE_CDN = "0";
process.env.GEOLIBRE_CEREUS_CDN = "0";
process.env.GEOLIBRE_GDAL_CDN = "0";
process.env.GEOLIBRE_DUCKDB_WASM_CDN = "0";
Comment thread
giswqs marked this conversation as resolved.
}

// PGlite + PostGIS is ~25 MB raw and weighs ~22 MB inside the Tauri binary
// (postgis.tar is pre-gzipped, so brotli can't shrink it — it was the entire
// 42 → 63 MB binary regression). By default it is fetched from jsDelivr at
Expand Down Expand Up @@ -908,6 +923,7 @@ export default defineConfig({
__GEOLIBRE_STORE_BUILD__: JSON.stringify(IS_STORE_BUILD),
__GEOLIBRE_MAS_BUILD__: JSON.stringify(IS_MAS_BUILD),
__GEOLIBRE_EMBED_BUILD__: JSON.stringify(IS_EMBED),
__NO_EXTERNAL_CDN__: JSON.stringify(NO_EXTERNAL_CDN),
__PGLITE_CDN_URL__: JSON.stringify(PGLITE_CDN_URL),
__PGLITE_POSTGIS_CDN_URL__: JSON.stringify(PGLITE_POSTGIS_CDN_URL),
__CEREUS_WASM_CDN_URL__: JSON.stringify(CEREUS_WASM_CDN_URL),
Expand Down
Loading
Loading