Skip to content
Draft
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
449 changes: 372 additions & 77 deletions apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,33 @@
"visibilityUnlisted": "Unlisted (anyone with the link)",
"visibilityPublic": "Public (listed in the gallery)",
"visibilityPrivate": "Private (only you)",
"visibilityUnlistedShort": "Unlisted",
"visibilityPublicShort": "Public",
"visibilityPrivateShort": "Private",
"role": "Access role",
"roleView": "View (read-only)",
"roleComment": "Comment (view & comments)",
"roleEdit": "Edit (full app)",
"roleViewShort": "View",
"roleCommentShort": "Comment",
"roleEditShort": "Edit",
"expiry": "Link expiry",
"expiryNever": "Never",
"expiry24h": "24 hours",
"expiry7d": "7 days",
"expiry30d": "30 days",
"password": "Password protection (optional)",
"passwordPlaceholder": "Optional password",
"activeShares": "Active Shares",
"createShare": "New Share",
"noActiveShares": "No active share links found.",
"sharesErrorFallback": "Could not load your active share links.",
"revoke": "Revoke",
"revoking": "Revoking…",
"revokeConfirm": "Revoke this share link? Anyone you sent it to will lose access immediately, and this cannot be undone.",
"revokeErrorFallback": "Could not revoke the share link.",
"passwordProtected": "Password protected",
"expires": "Expires",
"shareButton": "Share",
"sharing": "Sharing…",
"errorFallback": "Could not share the project.",
Expand Down
20 changes: 20 additions & 0 deletions apps/geolibre-desktop/src/lib/project-url.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { parseProject, type GeoLibreProject } from "@geolibre/core";
import type { ShareRole } from "./share-geolibre";
import { normalizeProjectUrl } from "./urls";
import { WHITEBOX_TOOL_PARAM } from "./whitebox-tool-url";

// Query parameters that carry a `.geolibre.json` project URL deep link. A bare
// `?https://...` query (no key) is also accepted by `projectUrlFromLocation`.
export const PROJECT_URL_PARAMS = ["url", "project", "projectUrl", "project_url"];

/**
* Parses a share role string ("view", "comment", "edit") into a valid ShareRole or null.
*/
export function parseShareRole(value: unknown): ShareRole | null {
if (value === "view" || value === "comment" || value === "edit") {
return value;
}
return null;
}

/**
* Reads a share access role from the current `window.location` query string if present (?role=view, ?role=comment, ?role=edit).
*/
export function shareRoleFromLocation(): ShareRole | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
return parseShareRole(params.get("role") || params.get("shareRole"));
}

/**
* Reads a `.geolibre.json` project URL from the current `window.location` query
* string, if one is present.
Expand Down
199 changes: 199 additions & 0 deletions apps/geolibre-desktop/src/lib/share-geolibre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,42 @@ export class ShareUploadError extends Error {
// point to the server's error vocabulary is obvious and easy to update.
const USERNAME_REQUIRED_PATTERN = /username required/i;

export type ShareRole = "view" | "comment" | "edit";
export type ShareExpiry = "24h" | "7d" | "30d" | "never";

export interface ActiveShare {
id: string;
projectSlug: string;
title?: string;
visibility: ShareVisibility;
role: ShareRole;
expiresAt: string | null;
hasPassword: boolean;
createdAt: string;
projectUrl: string;
viewerUrl: string;
}

export interface ShareUploadResult {
id?: string;
username: string;
slug: string;
projectUrl: string;
viewerUrl: string;
rawJsonUrl: string;
role?: ShareRole;
expiresAt?: string | null;
hasPassword?: boolean;
}

export interface ShareUploadOptions {
token: string;
filename: string;
content: string;
visibility: ShareVisibility;
role?: ShareRole;
expiresIn?: ShareExpiry;
password?: string;
/** Override the share host; defaults to the configured/production URL. */
baseUrl?: string;
signal?: AbortSignal;
Expand Down Expand Up @@ -121,11 +144,15 @@ export function resolveShareBaseUrl(

interface ShareProjectResponse {
project?: {
id?: string;
username?: string;
slug?: string;
projectUrl?: string;
viewerUrl?: string;
rawJsonUrl?: string;
role?: ShareRole;
expiresAt?: string | null;
hasPassword?: boolean;
};
}

Expand Down Expand Up @@ -159,6 +186,9 @@ export async function uploadProjectToShare(
filename: options.filename,
content: options.content,
visibility: options.visibility,
...(options.role ? { role: options.role } : {}),
...(options.expiresIn ? { expiresIn: options.expiresIn } : {}),
...(options.password ? { password: options.password } : {}),
}),
signal,
});
Expand All @@ -184,11 +214,180 @@ export async function uploadProjectToShare(
throw new Error("share.geolibre.app returned an unexpected response.");
}
return {
id: project.id,
username: project.username ?? "",
slug: project.slug ?? "",
projectUrl: project.projectUrl,
viewerUrl: project.viewerUrl ?? "",
rawJsonUrl: project.rawJsonUrl,
role: project.role,
expiresAt: project.expiresAt,
hasPassword: project.hasPassword,
};
}

export function normalizeShareRole(value: unknown): ShareRole {
return value === "view" || value === "comment" || value === "edit" ? value : "view";
}

export interface FetchSharesOptions {
token: string;
baseUrl?: string;
signal?: AbortSignal;
fetchImpl?: typeof fetch;
}

export async function fetchProjectShares(options: FetchSharesOptions): Promise<ActiveShare[]> {
const token = options.token.trim();
if (!token) {
throw new Error("Add a share.geolibre.app API token in Settings before managing shares.");
}

const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, "");
const fetchImpl = options.fetchImpl ?? getShareFetch();
const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;

let response: Response;
try {
response = await fetchImpl(`${base}/api/shares`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new Error("Could not reach share.geolibre.app. Check your internet connection.");
}

if (response.status === 401 || response.status === 403) {
throw new Error("Invalid or expired API token.");
}
if (!response.ok) {
throw new Error(`Failed to fetch shares (HTTP ${response.status}).`);
}

const payload = (await response.json().catch(() => ({}))) as { shares?: unknown[] };
const rawShares = Array.isArray(payload.shares) ? payload.shares : [];
return rawShares
.map((raw) => {
const item = (raw ?? {}) as Record<string, unknown>;
// Unparseable access-control metadata fails closed to the least
// privileged role, so a server that adds a role this build doesn't know
// never gets displayed as full edit access.
const role = normalizeShareRole(item.role);
const visibility: ShareVisibility =
item.visibility === "public" || item.visibility === "private"
? item.visibility
: "unlisted";
const projectUrl = String(
item.projectUrl || `${base}/u/${encodeURIComponent(String(item.slug ?? ""))}`,
);
return {
id: String(item.id || ""),
projectSlug: String(item.projectSlug || item.slug || ""),
title: String(item.title || ""),
visibility,
role,
expiresAt: item.expiresAt ? String(item.expiresAt) : null,
hasPassword: Boolean(item.hasPassword || item.passwordProtected),
createdAt: String(item.createdAt || ""),
projectUrl,
// The project URL becomes a query *value* here, so it has to be
// percent-encoded: a raw `&` or `#` in it would otherwise truncate the
// viewer link at that character.
viewerUrl: String(item.viewerUrl || `${base}/viewer?url=${encodeURIComponent(projectUrl)}`),
};
})
.filter((s) => s.id !== "");
}

export interface RevokeShareOptions {
token: string;
shareId: string;
baseUrl?: string;
signal?: AbortSignal;
fetchImpl?: typeof fetch;
}

export async function revokeShare(options: RevokeShareOptions): Promise<void> {
const token = options.token.trim();
if (!token) {
throw new Error("API token required to revoke share.");
}

const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, "");
const fetchImpl = options.fetchImpl ?? getShareFetch();
const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;

let response: Response;
try {
response = await fetchImpl(`${base}/api/shares/${encodeURIComponent(options.shareId)}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new Error("Could not reach share.geolibre.app to revoke share.");
}

if (response.status === 401 || response.status === 403) {
throw new Error("Invalid or expired API token.");
}
if (!response.ok) {
throw new Error(`Failed to revoke share (HTTP ${response.status}).`);
}
}

export interface VerifySharePasswordOptions {
shareUrl: string;
password: string;
signal?: AbortSignal;
fetchImpl?: typeof fetch;
}

export async function verifySharePassword(

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.

Bug/incomplete feature (medium-high confidence): verifySharePassword is exported and unit-tested but is never called from anywhere in the app (ShareProjectDialog.tsx, useProjectUrlLoader.ts, useEmbedApi.ts — none reference it). fetchProjectFromUrl/useProjectUrlLoader fetch the raw project JSON directly and have no branch for a 401/403 "password required" response, so there's currently no UI path where a recipient of a password-protected share link is ever prompted to enter the password — they'd just see the generic "could not fetch the project" error from fetchProjectFromUrl. Password protection appears to be wired up only on the creation side (sending password in uploadProjectToShare), not the consumption side.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed, and left unfixed in 38aca65 on purpose — leaving this thread open for @giswqs.

verifySharePassword really is unreferenced outside its tests, and fetchProjectFromUrl has no 401/403 "password required" branch, so a recipient of a protected link gets the generic fetch error. Wiring the consumption side isn't a minimal fix: the POST <shareUrl>/access contract it assumes has to be confirmed against share.geolibre.app, and useProjectUrlLoader needs a new password-prompt state. Same call as the role helpers (#discussion_r3675757119) — groundwork for a follow-up, or drop it until the server side exists.

options: VerifySharePasswordOptions,
): Promise<{ projectContent: string; role?: ShareRole }> {
const fetchImpl = options.fetchImpl ?? getShareFetch();
const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;

let response: Response;
try {
response = await fetchImpl(`${options.shareUrl.replace(/\/+$/, "")}/access`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
// The password travels in the request body only. Sending it a second time
// as a custom header would widen its exposure for nothing: proxy and
// logging layers routinely capture headers separately from bodies.
body: JSON.stringify({ password: options.password }),
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new Error("Could not reach share server.");
}

if (response.status === 401 || response.status === 403) {
throw new Error("Incorrect password.");
}
if (!response.ok) {
throw new Error(`Password verification failed (HTTP ${response.status}).`);
}

const data = (await response.json()) as { content?: string; role?: unknown };
return {
projectContent: typeof data.content === "string" ? data.content : JSON.stringify(data),
role: data.role === undefined ? undefined : normalizeShareRole(data.role),
};
}

Expand Down
Loading