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
14 changes: 12 additions & 2 deletions apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ interface ShareProjectDialogProps {
* Lazily serialize the current project (under the given title) when the user
* confirms the upload.
*/
getProject: (title: string) => Promise<{ content: string; filename: string }>;
getProject: (
title: string,
) => Promise<{ content: string; filename: string; redactedCount?: number }>;
}

/**
Expand Down Expand Up @@ -72,6 +74,7 @@ export function ShareProjectDialog({
const [errorCode, setErrorCode] = useState<ShareUploadErrorCode | null>(null);
const [result, setResult] = useState<ShareUploadResult | null>(null);
const [copied, setCopied] = useState(false);
const [redactedCount, setRedactedCount] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const copyTimeoutRef = useRef<number | null>(null);

Expand All @@ -88,6 +91,7 @@ export function ShareProjectDialog({
setErrorCode(null);
setResult(null);
setCopied(false);
setRedactedCount(0);
} else {
abortRef.current?.abort();
abortRef.current = null;
Expand Down Expand Up @@ -117,14 +121,15 @@ export function ShareProjectDialog({
const controller = new AbortController();
abortRef.current = controller;
try {
const { content, filename } = await getProject(title.trim());
const { content, filename, redactedCount: removed = 0 } = await getProject(title.trim());
const uploaded = await uploadProjectToShare({
token: shareToken,
filename,
content,
visibility,
signal: controller.signal,
});
setRedactedCount(removed);
setResult(uploaded);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
Expand Down Expand Up @@ -234,6 +239,11 @@ export function ShareProjectDialog({
</div>
) : result ? (
<div className="space-y-3">
{redactedCount > 0 ? (
<p className="rounded-md bg-muted p-2 text-sm text-muted-foreground">
{t("share.credentialsRemoved", { count: redactedCount })}
</p>
) : null}
<p className="text-sm text-muted-foreground">{t("share.liveAt")}</p>
<div className="flex gap-2">
<Input readOnly value={result.projectUrl} className="text-xs" />
Expand Down
16 changes: 13 additions & 3 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { DEFAULT_PROJECT_NAME, useAppStore } from "@geolibre/core";
import {
DEFAULT_PROJECT_NAME,
redactProjectCredentials,
serializeProject,
useAppStore,
} from "@geolibre/core";
import { DEFAULT_BUILT_IN_CONTROL_VISIBILITY, type MapController } from "@geolibre/map";
import {
closeDuckDBLayerPanel,
Expand Down Expand Up @@ -1996,7 +2001,8 @@ export function TopToolbar({
getProject={async (title) => {
// Shared projects are opened on another machine where the local files
// don't exist, so always embed the vector data (never file references).
const { content, defaultProjectName } = await projectFiles.buildEmbeddedProject(title);
const { project, defaultProjectName } = await projectFiles.buildEmbeddedProject(title);
const redacted = redactProjectCredentials(project);
// Strip path separators, control chars, and other characters that are
// illegal in filenames so the server gets a predictable name.
const safeName = defaultProjectName.replace(
Expand All @@ -2006,7 +2012,11 @@ export function TopToolbar({
/[\u0000-\u001f\u007f/\\:*?"<>|]/g,
"_",
);
return { content, filename: `${safeName}.geolibre.json` };
return {
content: serializeProject(redacted.project),
filename: `${safeName}.geolibre.json`,
redactedCount: redacted.redactedCount,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}}
/>
<ProjectGalleryDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,28 +196,34 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) {
</DialogContent>
</Dialog>
<Dialog
open={projectFiles.envStripPrompt !== null}
open={projectFiles.credentialStripPrompt !== null}
onOpenChange={(open: boolean) => {
if (!open) projectFiles.resolveEnvStripPrompt("cancel");
if (!open) projectFiles.resolveCredentialStripPrompt("cancel");
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("settings.env.stripPromptTitle")}</DialogTitle>
<DialogDescription>
{t("settings.env.stripPromptDesc", {
count: projectFiles.envStripPrompt?.count ?? 0,
count: projectFiles.credentialStripPrompt?.count ?? 0,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => projectFiles.resolveEnvStripPrompt("cancel")}>
<Button
variant="outline"
onClick={() => projectFiles.resolveCredentialStripPrompt("cancel")}
>
{t("common.cancel")}
</Button>
<Button variant="outline" onClick={() => projectFiles.resolveEnvStripPrompt("keep")}>
<Button
variant="outline"
onClick={() => projectFiles.resolveCredentialStripPrompt("keep")}
>
{t("settings.env.keepButton")}
</Button>
<Button onClick={() => projectFiles.resolveEnvStripPrompt("strip")}>
<Button onClick={() => projectFiles.resolveCredentialStripPrompt("strip")}>
{t("settings.env.stripButton")}
</Button>
</div>
Expand Down
6 changes: 4 additions & 2 deletions apps/geolibre-desktop/src/hooks/embedHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import { EMBED_ORIGIN_WILDCARD, isEmbedOriginAllowed, readEmbedOrigins } from ".
* not throw cross-origin, so it can't be used for this). A random cross-origin
* page that iframes a deployed app therefore never auto-activates the bridge.
* The explicit `?embed=1` opt-in, however, trusts whatever the framing parent
* is — the bridge
* broadcasts full project state to it. Because the legitimate hosts (the Jupyter
* is — the bridge broadcasts project state to it, redacted unless that host
* asks for full fidelity with `trustedWidget` (see `useEmbedBridge.ts`, where
* that flag is documented as self-declared and therefore not a boundary of its
* own). Because the legitimate hosts (the Jupyter
* widget, Colab's proxy) have arbitrary, unknowable origins, an origin allowlist
* is not viable here; instead the deployment constraint is: an `?embed=1`
* export must only be served from a trusted context, never a public URL. A
Expand Down
6 changes: 3 additions & 3 deletions apps/geolibre-desktop/src/hooks/useCollaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { RefObject } from "react";
import type { MapController } from "@geolibre/map";
import type { Map as MapLibreMap } from "maplibre-gl";
import i18n from "../i18n";
import { buildProjectSnapshot } from "../lib/build-project-snapshot";
import { buildProjectEgressSnapshot } from "../lib/build-project-snapshot";
import { projectChanged } from "../lib/project-broadcast-changed";
import {
CollabConnection,
Expand Down Expand Up @@ -76,7 +76,7 @@ export function useCollaboration(

const sendSnapshot = (): void => {
if (!canEdit() || syncPausedRef.current) return;
const project = buildProjectSnapshot(mapControllerRef);
const project = buildProjectEgressSnapshot(mapControllerRef);
Comment thread
giswqs marked this conversation as resolved.
const content = serializeProject(project);
if (content === lastContentRef.current) return;
lastContentRef.current = content;
Expand Down Expand Up @@ -105,7 +105,7 @@ export function useCollaboration(
clearHistory();
scheduleRestore();
}
lastContentRef.current = serializeProject(buildProjectSnapshot(mapControllerRef));
lastContentRef.current = serializeProject(buildProjectEgressSnapshot(mapControllerRef));
};

const handleMessage = (message: ServerMessage): void => {
Expand Down
35 changes: 26 additions & 9 deletions apps/geolibre-desktop/src/hooks/useEmbedBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parseProject, serializeProject, useAppStore, type GeoLibreProject } from "@geolibre/core";
import { type RefObject, useEffect } from "react";
import type { MapController } from "@geolibre/map";
import { buildProjectSnapshot } from "../lib/build-project-snapshot";
import { buildProjectEgressSnapshot, buildProjectSnapshot } from "../lib/build-project-snapshot";
import { getEmbedHost, isEmbedded } from "./embedHost";

// How long to wait after the last store change before posting a fresh project
Expand All @@ -13,6 +13,8 @@ interface LoadProjectMessage {
type: "geolibre:load-project";
project: GeoLibreProject | string;
seq?: number;
/** Set only by the co-located anywidget host, which retains local credentials. */
trustedWidget?: boolean;
}

interface RequestStateMessage {
Expand All @@ -36,13 +38,23 @@ type InboundMessage = LoadProjectMessage | RequestStateMessage;
* received from the app back into the iframe. Outside an embedding host the
* hook is an inert no-op.
*
* Trust model: the embedding host is fully trusted and receives the entire
* project state. Project snapshots are not broadcast until the host sends its
* first message (which is also when the bridge learns its origin and scopes
* subsequent posts to it); only the version-only `geolibre:ready` ping precedes
* the handshake and is the single message sent to `"*"`. Any page that frames
* the app (not just the Jupyter widget) therefore becomes that trusted host, so
* `?embed=1` standalone exports should only be served from a trusted context.
* Trust model: snapshots are redacted by default, so a framing page sees project
* structure without credentials. The co-located anywidget host opts back into
* full fidelity with `trustedWidget` on its load message, because Python's
* `self.project` trait is the same object `to_project(keep_credentials=True)`
* returns and would otherwise lose credentials on the next pan.
*
* That flag is self-declared by the host, so it is a fidelity switch, not a
* security boundary: any page that frames the app can set it and get an
* unredacted snapshot back. It narrows accidental exposure to hosts that never
* ask, not deliberate exposure — the boundary remains the framing context
* itself, which is why `?embed=1` standalone exports should only be served from
* a trusted one.
*
* Project snapshots are not broadcast until the host sends its first message
* (which is also when the bridge learns its origin and scopes subsequent posts
* to it); only the version-only `geolibre:ready` ping precedes the handshake and
* is the single message sent to `"*"`.
*
* @param mapControllerRef - Ref to the live map controller, read so the emitted
* snapshot captures the current camera (pan/zoom) rather than only the store.
Expand All @@ -62,8 +74,12 @@ export function useEmbedBridge(mapControllerRef: RefObject<MapController | null>
// correlate a snapshot with the load that triggered it.
let lastLoadedSeq = 0;
let lastPostedContent: string | null = null;
let trustedWidget = false;

const buildProject = (): GeoLibreProject => buildProjectSnapshot(mapControllerRef);
const buildProject = (): GeoLibreProject =>
trustedWidget
? buildProjectSnapshot(mapControllerRef)
: buildProjectEgressSnapshot(mapControllerRef);
Comment thread
giswqs marked this conversation as resolved.

const postState = () => {
if (disposed) return;
Expand Down Expand Up @@ -103,6 +119,7 @@ export function useEmbedBridge(mapControllerRef: RefObject<MapController | null>
};

const applyLoad = (message: LoadProjectMessage) => {
trustedWidget = message.trustedWidget === true;
// Advance the seq before parsing so a later snapshot carries the right
// correlation id even when the load fails. Reset (not retain) when a load
// omits seq, so a snapshot never echoes a stale, unrelated sequence number.
Expand Down
61 changes: 29 additions & 32 deletions apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
DEFAULT_PROJECT_NAME,
detachProjectCopy,
projectFromStore,
redactProjectCredentials,
serializeProject,
useAppStore,
type GeoLibreLayer,
Expand Down Expand Up @@ -50,8 +51,8 @@ import {
import { importArcgisProject, type ArcgisProjectImportWarning } from "../lib/arcgis-project-import";
import type { MapControllerRef } from "../components/layout/toolbar/constants";

/** A pending "strip env vars before saving?" prompt. */
export interface EnvStripPrompt {
/** A pending "strip credentials before saving?" prompt. */
export interface CredentialStripPrompt {
count: number;
resolve: (choice: "strip" | "keep" | "cancel") => void;
}
Expand Down Expand Up @@ -220,7 +221,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const [projectUrl, setProjectUrl] = useState("");
const [projectUrlError, setProjectUrlError] = useState<string | null>(null);
const [projectUrlLoading, setProjectUrlLoading] = useState(false);
const [envStripPrompt, setEnvStripPrompt] = useState<EnvStripPrompt | null>(null);
const [credentialStripPrompt, setCredentialStripPrompt] = useState<CredentialStripPrompt | null>(
null,
);
const [embedVectorDataPrompt, setEmbedVectorDataPrompt] = useState<EmbedVectorDataPrompt | null>(
null,
);
Expand Down Expand Up @@ -659,17 +662,18 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
};
};

// Ask whether to strip environment variables before writing the file. The
// promise resolves when the user picks an option in the dialog.
const askStripEnvVars = (count: number) =>
// Ask whether to strip credentials (environment variables, geocoder keys,
// layer tokens) before writing the file. The promise resolves when the user
// picks an option in the dialog.
const askStripCredentials = (count: number) =>
new Promise<"strip" | "keep" | "cancel">((resolve) => {
setEnvStripPrompt({ count, resolve });
setCredentialStripPrompt({ count, resolve });
});

const resolveEnvStripPrompt = (choice: "strip" | "keep" | "cancel") => {
const resolveCredentialStripPrompt = (choice: "strip" | "keep" | "cancel") => {
// Resolve outside the state updater (updaters must be side-effect free).
envStripPrompt?.resolve(choice);
setEnvStripPrompt(null);
credentialStripPrompt?.resolve(choice);
setCredentialStripPrompt(null);
};

// Ask whether to embed local vector layers' data in the saved file. Resolves
Expand Down Expand Up @@ -835,20 +839,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
undefined,
layersForSave.layers,
);
// Env vars (possibly API keys) are serialized in plain text. If any are set,
// offer to strip them from the saved file before writing.
// Credentials are serialized in plain text for a local project that needs
// them. Make keeping them an explicit choice and use the same central
// redaction pass as every external egress.
let contentToSave = content;
const envVarCount = (project.preferences.environmentVariables ?? []).filter((variable) =>
variable.key.trim(),
).length;
if (envVarCount > 0) {
const choice = await askStripEnvVars(envVarCount);
const redacted = redactProjectCredentials(project);
if (redacted.redactedPaths.length > 0) {
const choice = await askStripCredentials(redacted.redactedCount);
if (choice === "cancel") return false;
if (choice === "strip") {
contentToSave = serializeProject({
...project,
preferences: { ...project.preferences, environmentVariables: [] },
});
contentToSave = serializeProject(redacted.project);
}
}
// Projects opened from a URL have no writable path, so both Save and
Expand Down Expand Up @@ -947,18 +947,15 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
if (chosen === null) return false;
defaultName = ensureHtmlFileName(chosen, slug);
}
// Only now embed local vector data (self-contained, like Share) and strip
// env vars (secrets serve no purpose in a static viewer): this can be
// costly on a project with many local layers, so it runs after the user
// Only now embed local vector data (self-contained, like Share): this can
// be costly on a project with many local layers, so it runs after the user
// has committed to the export rather than before the prompt. Reuse the
// name snapshot so the title matches the slug computed above.
// name snapshot so the title matches the slug computed above. Credentials
// serve no purpose in a static viewer and are removed inside
// buildProjectHtml, which runs the central redaction pass.
const { project, defaultProjectName } = await buildEmbeddedProject(projectName);
const safeProject = {
...project,
preferences: { ...project.preferences, environmentVariables: [] },
};
const html = buildProjectHtml({
project: safeProject,
project,
Comment thread
giswqs marked this conversation as resolved.
title: defaultProjectName,
});
// Returns null when the user cancels the save dialog; report that as a
Expand Down Expand Up @@ -1024,8 +1021,8 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
setSaveTemplateDialogOpen,
handleDuplicate,
handleSaveAsTemplate: () => setSaveTemplateDialogOpen(true),
envStripPrompt,
resolveEnvStripPrompt,
credentialStripPrompt,
resolveCredentialStripPrompt,
embedVectorDataPrompt,
resolveEmbedVectorDataPrompt,
saveNamePrompt,
Expand Down
9 changes: 5 additions & 4 deletions apps/geolibre-desktop/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1312,7 +1312,8 @@
"sharing": "جارٍ المشاركة…",
"errorFallback": "تعذّرت مشاركة المشروع.",
"usernameRequired": "عيّن اسم مستخدم في حسابك على {{shareHost}} قبل المشاركة. افتح إعدادات حسابك لاختيار اسم، ثم حاول مرة أخرى.",
"openAccountSettings": "فتح إعدادات الحساب"
"openAccountSettings": "فتح إعدادات الحساب",
"credentialsRemoved": "لم يتم تضمين {{count}} من حقول بيانات الاعتماد. يجب على المستلمين تقديم بيانات اعتمادهم أو استخدام مرجع عبر وسيط."
},
"gallery": {
"title": "معرض المشاريع",
Expand Down Expand Up @@ -1986,9 +1987,9 @@
"removeAria": "إزالة {{name}}",
"errorNamePattern": "يجب أن تبدأ أسماء متغيرات البيئة بحرف أو شرطة سفلية وأن تحتوي على أحرف وأرقام وشرطات سفلية فقط.",
"errorDuplicate": "متغير البيئة «{{name}}» مكرر.",
"stripPromptTitle": "هل تريد إزالة متغيرات البيئة؟",
"stripPromptDesc": "يحتوي هذا المشروع على {{count}} من متغيرات البيئة (قد تتضمن مفاتيح API). وهي مخزنة كنص عادي في ملف المشروع وقد تنكشف إذا شاركته. هل تريد إزالتها من الملف المحفوظ؟ ستبقيها الإعدادات على هذا الجهاز في كلتا الحالتين.",
"stripButton": "إزالة من الملف",
"stripPromptTitle": "هل تريد إزالة بيانات الاعتماد؟",
"stripPromptDesc": "يحتوي هذا المشروع على {{count}} من الحقول التي قد تحمل بيانات اعتماد، مثل مفاتيح API أو ترويسات الطلب أو إعدادات الملحقات. هل تريد إزالتها من الملف المحفوظ؟ ستظل مساحة العمل الحالية محتفظة بها في كلتا الحالتين.",
"stripButton": "إزالة بيانات الاعتماد",
"keepButton": "الإبقاء في الملف"
},
"geocoding": {
Expand Down
Loading
Loading