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
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

ARG GEOLIBRE_APP_BASE=/
ARG VITE_GEE_OAUTH_CLIENT_ID=
ARG VITE_MAPILLARY_ACCESS_TOKEN=

Check warning on line 26 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build and publish container image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "VITE_MAPILLARY_ACCESS_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
# Set to 1 (or true) to disable the first-launch welcome wizard for the whole
# deployment; visitors land straight on the map.
ARG VITE_WELCOME_DISABLED=
Expand All @@ -31,11 +31,21 @@
# postMessage API. Usually set at RUN time instead (-e GEOLIBRE_EMBED_ORIGINS=…),
# which the entrypoint writes into the runtime config without a rebuild.
ARG VITE_GEOLIBRE_EMBED_ORIGINS=
# Self-hosted project sharing server (https://…, or "off" to remove Share and the
# Project Gallery). Unset uses the public hosted service. Like the embed origins,
# normally set at RUN time instead (-e GEOLIBRE_SHARE_URL=…) so a prebuilt image
# can be repointed without a rebuild.
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=
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}

Check warning on line 44 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build and publish container image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "VITE_MAPILLARY_ACCESS_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
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}

RUN npm run build

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
projectOpenToken,
type SharedProject,
} from "../../lib/share-gallery";
import { shareHostLabel } from "../../lib/share-geolibre";
import type { TFunction } from "i18next";

type GalleryScope = "featured" | "all" | "mine";
Expand Down Expand Up @@ -77,13 +78,15 @@ function galleryErrorMessage(error: unknown, t: TFunction): string {
case "timeout":
return t("gallery.errorTimeout");
case "network":
return t("gallery.errorNetwork");
return t("gallery.errorNetwork", { shareHost: shareHostLabel() });
case "invalid-response":
return t("gallery.errorInvalidResponse");
case "unauthorized":
return t("gallery.errorUnauthorized");
return t("gallery.errorUnauthorized", { shareHost: shareHostLabel() });
case "username-required":
return t("gallery.errorUsernameRequired");
return t("gallery.errorUsernameRequired", { shareHost: shareHostLabel() });
case "not-configured":
return t("gallery.errorNotConfigured");
case "http":
return t("gallery.errorHttp", { status: error.status ?? 0 });
}
Expand All @@ -92,7 +95,8 @@ function galleryErrorMessage(error: unknown, t: TFunction): string {
}

/**
* Browse public projects shared on share.geolibre.app and open one in GeoLibre.
* Browse public projects shared on the configured share host and open one in
* GeoLibre.
*
* The listing endpoint only paginates (no server-side search), so this loads
* pages on demand via "Load more" and filters the already-loaded set in the
Expand Down Expand Up @@ -322,7 +326,9 @@ export function ProjectGalleryDialog({
>
<DialogHeader>
<DialogTitle>{t("gallery.title")}</DialogTitle>
<DialogDescription>{t("gallery.description")}</DialogDescription>
<DialogDescription>
{t("gallery.description", { shareHost: shareHostLabel() })}
</DialogDescription>
</DialogHeader>

<div className="flex w-full gap-1 rounded-md bg-muted p-1 sm:w-auto sm:self-start">
Expand Down Expand Up @@ -360,7 +366,9 @@ export function ProjectGalleryDialog({
</div>

{!hasToken ? (
<p className="text-xs text-muted-foreground">{t("gallery.signedOutHint")}</p>
<p className="text-xs text-muted-foreground">
{t("gallery.signedOutHint", { shareHost: shareHostLabel() })}
</p>
) : null}

{openError ? (
Expand Down
85 changes: 58 additions & 27 deletions apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import type { ThemeMode } from "../../hooks/useThemeMode";
import { isTauri } from "../../lib/is-tauri";
import { THEME_SCHEMES, normalizeHexColor, type ThemeScheme } from "../../lib/theme-schemes";
import { IS_MAS_BUILD } from "../../lib/build-flags";
import { resolveShareHost, shareHostLabel } from "../../lib/share-geolibre";
import { IS_STORE_BUILD, type UpdateNotificationLevel } from "../../lib/updates";
import {
DATA_SOURCE_CATALOG,
Expand Down Expand Up @@ -378,6 +379,25 @@ export function SettingsDialog({
onToggleThemeMode,
}: SettingsDialogProps) {
const { t } = useTranslation();
// The share host's settings page, where the API token below is created.
// Derived from the resolved host so a self-hosted deployment links to its own
// page; null when the deployment configured no share host, in which case the
// description renders without a link rather than pointing at a stranger's site.
const shareHostState = resolveShareHost();
const shareBaseUrl = shareHostState.baseUrl;
const shareHost = shareHostLabel();
const shareSettingsUrl = shareBaseUrl ? `${shareBaseUrl}/settings` : null;
Comment thread
giswqs marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// No usable host (sharing turned off, or a configured address that was
// rejected) means the token field is dead: it would authenticate against a
// server this deployment never talks to. Say so instead of rendering guidance
// that names the public hosted service — the whole point of the opt-out. The
// two unusable states get different copy: "not configured" would send an
// operator who typo'd the variable looking for one they never set.
const shareTokenUsable = shareBaseUrl != null;
Comment thread
giswqs marked this conversation as resolved.
const shareTokenUnavailableMessage =
shareHostState.status === "invalid"
? t("settings.env.tokenHostInvalid")
: t("settings.env.tokenUnavailable");
const { language, options: languageOptions, setLanguage } = useLanguage();
const preferences = useAppStore((s) => s.preferences);
const setPreferences = useAppStore((s) => s.setPreferences);
Expand Down Expand Up @@ -2273,33 +2293,44 @@ export function SettingsDialog({
<div className="space-y-5">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t("settings.env.tokenTitle")}</h3>
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="settings.env.tokenDescription"
components={{
tokenLink: (
<a
className="underline"
href="https://share.geolibre.app/settings"
target="_blank"
rel="noreferrer noopener"
/>
),
}}
/>
</p>
<Input
ref={shareTokenInputRef}
aria-label={t("settings.env.tokenTitle")}
type="password"
autoComplete="new-password"
placeholder={t("settings.env.tokenPlaceholder")}
value={draftDesktopSettings.shareToken}
onChange={(event) => updateShareToken(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("settings.env.tokenStorageNote")}
</p>
{shareTokenUsable ? (
<>
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="settings.env.tokenDescription"
values={{ shareHost }}
components={{
// Non-null here: this branch requires shareBaseUrl,
// which is what shareSettingsUrl is derived from.
tokenLink: (
<a
className="underline"
href={shareSettingsUrl ?? undefined}
target="_blank"
rel="noreferrer noopener"
/>
),
}}
/>
</p>
<Input
ref={shareTokenInputRef}
aria-label={t("settings.env.tokenTitle")}
type="password"
autoComplete="new-password"
placeholder={t("settings.env.tokenPlaceholder")}
value={draftDesktopSettings.shareToken}
onChange={(event) => updateShareToken(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("settings.env.tokenStorageNote", { shareHost })}
</p>
</>
) : (
<p className="text-xs text-muted-foreground">
{shareTokenUnavailableMessage}
</p>
)}
</div>
<div className="space-y-2 border-t pt-5">
<h3 className="text-sm font-semibold">{t("settings.env.cesiumTokenTitle")}</h3>
Expand Down
55 changes: 46 additions & 9 deletions apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isShareableTitle,
MAX_PROJECT_TITLE_LENGTH,
resolveShareBaseUrl,
shareHostLabel,
ShareUploadError,
uploadProjectToShare,
type ShareUploadErrorCode,
Expand All @@ -38,9 +39,18 @@ interface ShareProjectDialogProps {
getProject: (title: string) => Promise<{ content: string; filename: string }>;
}

// The website's account settings page, where the user both creates API tokens
// and sets the username required for sharing.
const ACCOUNT_SETTINGS_URL = `${resolveShareBaseUrl()}/settings`;
/**
* The share host's account settings page, where the user both creates API tokens
* and sets the username required for sharing.
*
* Derived from the resolved host rather than hardcoded, so a self-hosted
* deployment sends its users to its own settings page. Null when no share host is
* configured, in which case the dialog does not render the link.
*/
function accountSettingsUrl(): string | null {
const base = resolveShareBaseUrl();
return base ? `${base}/settings` : null;
}

export function ShareProjectDialog({
open,
Expand All @@ -49,6 +59,11 @@ export function ShareProjectDialog({
getProject,
}: ShareProjectDialogProps) {
const { t } = useTranslation();
// Resolved per render rather than at module load so a deployment env written
// after this module was imported is still honored.
const settingsUrl = accountSettingsUrl();
// Named in the copy below, so a self-hosted deployment reads its own host.
const shareHost = shareHostLabel();
const shareToken = useDesktopSettingsStore((s) => s.desktopSettings.shareToken);
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState<ShareVisibility>("unlisted");
Expand Down Expand Up @@ -158,6 +173,26 @@ export function ShareProjectDialog({
});
};

// Defensive: the Share entry points (menu item and command palette) are gated on
// the same state, so this should be unreachable. Guarding here anyway keeps a
// future caller from rendering setup guidance that names the public hosted
// service on a deployment that configured no share host.
if (!settingsUrl) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-4 w-4" />
{t("share.title")}
</DialogTitle>
<DialogDescription>{t("gallery.errorNotConfigured")}</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
Expand All @@ -166,20 +201,22 @@ export function ShareProjectDialog({
<Share2 className="h-4 w-4" />
{t("share.title")}
</DialogTitle>
<DialogDescription>{t("share.description")}</DialogDescription>
<DialogDescription>{t("share.description", { shareHost })}</DialogDescription>
</DialogHeader>

{!hasToken ? (
<div className="space-y-4 text-sm">
<p className="text-muted-foreground">{t("share.setupIntro")}</p>
<p className="text-muted-foreground">{t("share.setupIntro", { shareHost })}</p>
<ol className="space-y-3">
<li className="space-y-2 rounded-md border p-3">
<p className="font-medium">{t("share.step1Title")}</p>
<p className="text-muted-foreground">{t("share.step1Description")}</p>
<p className="text-muted-foreground">
{t("share.step1Description", { shareHost })}
</p>
<Button
type="button"
variant="outline"
onClick={() => void openExternalLink(ACCOUNT_SETTINGS_URL)}
onClick={() => void openExternalLink(settingsUrl)}
>
<ExternalLink className="me-2 h-3.5 w-3.5" />
{t("share.getToken")}
Expand Down Expand Up @@ -259,12 +296,12 @@ export function ShareProjectDialog({
role="alert"
className="space-y-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive"
>
<p>{t("share.usernameRequired")}</p>
<p>{t("share.usernameRequired", { shareHost })}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void openExternalLink(ACCOUNT_SETTINGS_URL)}
onClick={() => void openExternalLink(settingsUrl)}
>
<ExternalLink className="me-2 h-3.5 w-3.5" />
{t("share.openAccountSettings")}
Expand Down
27 changes: 20 additions & 7 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import { NewProjectDialog } from "./NewProjectDialog";
import { ManagePluginsDialog } from "./ManagePluginsDialog";
import { ProjectGalleryDialog } from "./ProjectGalleryDialog";
import { ShareProjectDialog } from "./ShareProjectDialog";
import { resolveShareHost } from "../../lib/share-geolibre";
import type { CollaborationApi } from "../../hooks/useCollaboration";
import { SettingsDialog } from "./SettingsDialog";
import { SetViewDialog } from "./SetViewDialog";
Expand Down Expand Up @@ -1070,6 +1071,11 @@ export function TopToolbar({
const [managePluginsOpen, setManagePluginsOpen] = useState(false);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [galleryDialogOpen, setGalleryDialogOpen] = useState(false);
// Whether this deployment has a usable share host. Read once per render (the
// deployment env does not change while the app is running) and passed down so
// the menu, the command palette, and the dialogs agree.
const shareHost = resolveShareHost();
const shareAvailable = shareHost.baseUrl != null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor UX inconsistency: when shareHostStatus === "invalid", ProjectMenu keeps the Share/Gallery items visible but disabled with a reason (shareBrokenReason) so the misconfiguration is discoverable. Here, shareAvailable (baseUrl != null) is false for both disabled and invalid, so the command-palette entry is silently omitted in the invalid case too — a user relying on the command palette gets no hint that sharing is broken vs. simply off. Not a functional bug, just a discoverability gap between the two entry points. Confidence: low.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing this one — leaving the thread open for a maintainer call.

The gap is real, but the Command type in lib/commands.ts has no disabled state: it is { id, title, group, keywords?, shortcut?, icon?, run }. Surfacing a broken-config hint in the palette means adding one and teaching both the palette renderer and the global shortcut handler to honour it, which is a lot of surface for a state an operator hits once at deploy time — and one the container now refuses to boot into at all, since the entrypoint exits on a malformed GEOLIBRE_SHARE_URL. The only way to reach invalid in practice is a bad build-time var or a hand-edited runtime config.

The menu keeps the discoverable version (visible reason + aria-describedby, tightened in 8a879a4), so the hint exists in the UI. Happy to add disabled to Command if you'd rather have parity.

const [aboutOpen, setAboutOpen] = useState(false);
const [printLayoutOpen, setPrintLayoutOpen] = useState(false);
const [fieldCollectionOpen, setFieldCollectionOpen] = useState(false);
Expand Down Expand Up @@ -1196,13 +1202,19 @@ export function TopToolbar({
shortcut: { key: "s", mod: true, shift: true },
run: () => void projectFiles.handleSaveAs(),
},
{
id: "project.share",
title: t("toolbar.command.projectShare"),
group: t("toolbar.commandGroup.project"),
icon: Share2,
run: () => setShareDialogOpen(true),
},
// Only when the deployment has a usable share host; a command that always
// failed would be worse than an absent one.
...(shareAvailable
? [
{
id: "project.share",
title: t("toolbar.command.projectShare"),
group: t("toolbar.commandGroup.project"),
icon: Share2,
run: () => setShareDialogOpen(true),
},
]
: []),
// Only surfaced when live collaboration is configured (env flag).
...(collaboration.enabled
? [
Expand Down Expand Up @@ -1771,6 +1783,7 @@ export function TopToolbar({
<ProjectMenu
chrome={chrome}
collaborationEnabled={collaboration.enabled}
shareHostStatus={shareHost.status}
onNewProject={() => setNewProjectDialogOpen(true)}
onOpenFromFile={() => void projectFiles.handleOpenFromFile()}
onOpenFromUrl={() => projectFiles.setProjectUrlDialogOpen(true)}
Expand Down
Loading
Loading