diff --git a/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx b/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx index 8508d388e..38bc5966f 100644 --- a/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx @@ -19,6 +19,8 @@ import { Search, Star, User, + UserPlus, + Users, } from "lucide-react"; import { useCallback, @@ -33,15 +35,21 @@ import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings"; import { openExternalLink } from "../../lib/open-external"; import { fetchMyProjects, + fetchMyGroups, + fetchMyOrganizations, + fetchProjectsSharedWithMe, fetchSharedProjects, GalleryError, + loadSharedProjectThumbnail, projectOpenToken, type SharedProject, + type ShareOrganization, + type ShareGroup, } from "../../lib/share-gallery"; -import { shareHostLabel } from "../../lib/share-geolibre"; +import { resolveShareBaseUrl, shareHostLabel } from "../../lib/share-geolibre"; import type { TFunction } from "i18next"; -type GalleryScope = "featured" | "all" | "mine"; +type GalleryScope = "featured" | "all" | "mine" | "organizations" | "groups"; interface ProjectGalleryDialogProps { open: boolean; @@ -55,7 +63,16 @@ interface ProjectGalleryDialogProps { onOpenProject: ( rawJsonUrl: string, authToken?: string, - options?: { asCopy?: boolean }, + options?: { + asCopy?: boolean; + remoteProject?: { + id: string; + versionCount: number; + canEdit: boolean; + token: string; + baseUrl: string; + }; + }, ) => Promise; } @@ -84,7 +101,9 @@ function galleryErrorMessage(error: unknown, t: TFunction): string { case "unauthorized": return t("gallery.errorUnauthorized", { shareHost: shareHostLabel() }); case "username-required": - return t("gallery.errorUsernameRequired", { shareHost: shareHostLabel() }); + return t("gallery.errorUsernameRequired", { + shareHost: shareHostLabel(), + }); case "not-configured": return t("gallery.errorNotConfigured"); case "http": @@ -120,15 +139,22 @@ export function ProjectGalleryDialog({ // otherwise undershoot the offset and re-deliver already-seen entries). const [rawOffset, setRawOffset] = useState(0); const [query, setQuery] = useState(""); - const [openingState, setOpeningState] = useState<{ id: string; action: "open" | "copy" } | null>( - null, - ); + const [openingState, setOpeningState] = useState<{ + id: string; + action: "open" | "copy"; + } | null>(null); const [openError, setOpenError] = useState(null); const abortRef = useRef(null); + const membershipAbortRef = useRef(null); + const defaultScopePendingRef = useRef(true); + + const [organizations, setOrganizations] = useState([]); + const [groups, setGroups] = useState([]); - // Without a token, the "My projects" scope isn't available; fall back to the - // featured tab. - const effectiveScope: GalleryScope = scope === "mine" && !hasToken ? "featured" : scope; + // Authenticated scopes disappear with the token; fall back instead of making + // an empty-token request if Settings changes while the dialog is open. + const authenticatedScope = scope === "mine" || scope === "organizations" || scope === "groups"; + const effectiveScope: GalleryScope = authenticatedScope && !hasToken ? "featured" : scope; // Explicit dialog size once the user drags the corner grip (null = the // default responsive size). `dialogRef` reads the live element size at the @@ -141,6 +167,13 @@ export function ProjectGalleryDialog({ const resizeCleanupRef = useRef<(() => void) | null>(null); useEffect(() => () => resizeCleanupRef.current?.(), []); + useEffect(() => { + if (open) { + defaultScopePendingRef.current = true; + setScope("featured"); + } + }, [open]); + // Resize the whole dialog from its bottom-right grip. The dialog is centred // via a -50% transform, so each edge moves by half the size change; growing // by 2x the pointer delta keeps the grip under the cursor (mirrors the Print @@ -214,6 +247,18 @@ export function ProjectGalleryDialog({ if (controller.signal.aborted) return; setProjects(mine); setHasMore(false); + } else if (effectiveScope === "organizations" || effectiveScope === "groups") { + const result = await fetchProjectsSharedWithMe({ + token: trimmedToken, + source: effectiveScope, + limit: PAGE_SIZE, + offset, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + setProjects((prev) => (offset === 0 ? result.projects : [...prev, ...result.projects])); + setHasMore(result.hasMore); + setRawOffset(offset + result.rawCount); } else { // "featured" and "all" both page through the public listing; featured // adds the ?featured=true filter. @@ -261,16 +306,66 @@ export function ProjectGalleryDialog({ } }, [open, loadPage]); + useEffect(() => { + if (!open || !hasToken) { + setOrganizations([]); + setGroups([]); + return; + } + const controller = new AbortController(); + membershipAbortRef.current = controller; + const fetchOptions = { token: trimmedToken, signal: controller.signal }; + void fetchMyOrganizations(fetchOptions) + .then((orgs) => { + if (controller.signal.aborted) return; + setOrganizations(orgs); + if (defaultScopePendingRef.current) { + setScope(orgs.length > 0 ? "organizations" : "featured"); + defaultScopePendingRef.current = false; + } + }) + .catch(() => { + if (controller.signal.aborted) return; + setOrganizations([]); + }); + void fetchMyGroups(fetchOptions) + .then((grps) => { + if (controller.signal.aborted) return; + setGroups(grps); + }) + .catch(() => { + if (controller.signal.aborted) return; + setGroups([]); + }); + return () => { + controller.abort(); + if (membershipAbortRef.current === controller) membershipAbortRef.current = null; + }; + }, [open, hasToken, trimmedToken]); + + const selectScope = (nextScope: GalleryScope) => { + defaultScopePendingRef.current = false; + setScope(nextScope); + }; + const handleOpen = async (project: SharedProject, options: { asCopy?: boolean } = {}) => { const action = options.asCopy ? "copy" : "open"; setOpeningState({ id: project.id, action }); setOpenError(null); try { - await onOpenProject( - project.rawJsonUrl, - effectiveScope === "mine" ? projectOpenToken(project, trimmedToken) : undefined, - options, - ); + const asCopy = options.asCopy === true; + await onOpenProject(project.rawJsonUrl, projectOpenToken(project, trimmedToken), { + asCopy, + remoteProject: asCopy + ? undefined + : { + id: project.id, + versionCount: project.versionCount, + canEdit: project.canEdit, + token: trimmedToken, + baseUrl: resolveShareBaseUrl() ?? "", + }, + }); onOpenChange(false); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; @@ -334,23 +429,37 @@ export function ProjectGalleryDialog({
setScope("featured")} + onClick={() => selectScope("featured")} icon={} label={t("gallery.scopeFeatured")} /> setScope("all")} + onClick={() => selectScope("all")} icon={} label={t("gallery.scopeAll")} /> {hasToken ? ( - setScope("mine")} - icon={} - label={t("gallery.scopeMine")} - /> + <> + selectScope("organizations")} + icon={} + label={t("gallery.scopeOrganizations")} + /> + selectScope("groups")} + icon={} + label={t("gallery.scopeGroups")} + /> + selectScope("mine")} + icon={} + label={t("gallery.scopeMine")} + /> + ) : null}
@@ -401,31 +510,38 @@ export function ProjectGalleryDialog({ {t("gallery.retry")} - ) : showEmpty ? ( -

- {trimmedQuery - ? t("gallery.noMatches") - : effectiveScope === "mine" - ? t("gallery.emptyMine") - : effectiveScope === "featured" - ? t("gallery.emptyFeatured") - : t("gallery.empty")} -

) : ( <> -
- {visibleProjects.map((project) => ( - void handleOpen(project)} - onOpenCopy={() => void handleOpen(project, { asCopy: true })} - /> - ))} -
- {hasMore && !trimmedQuery ? ( + {showEmpty ? ( +

+ {trimmedQuery + ? t("gallery.noMatches") + : effectiveScope === "mine" + ? t("gallery.emptyMine") + : effectiveScope === "organizations" + ? t("gallery.emptyOrganizations") + : effectiveScope === "groups" + ? t("gallery.emptyGroups") + : effectiveScope === "featured" + ? t("gallery.emptyFeatured") + : t("gallery.empty")} +

+ ) : ( +
+ {visibleProjects.map((project) => ( + void handleOpen(project)} + onOpenCopy={() => void handleOpen(project, { asCopy: true })} + /> + ))} +
+ )} + {hasMore ? (
) : readiness && readiness.items.length > 0 ? (

- {t("share.readinessAllReachable", { count: readiness.items.length })} + {t("share.readinessAllReachable", { + count: readiness.items.length, + })}

) : null} @@ -496,7 +651,9 @@ export function ShareProjectDialog({ + + ))} + + )} +
+ + {!showHistory ? ( + + ) : null} +
+ + + ); +} + /** * Formats a byte count as a short, human-readable size (e.g. "3.4 MB") for the * embed-data prompt's size warning. diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 8d72300e6..ca27abaa0 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -42,7 +42,11 @@ import { ensureHtmlFileName, ensureProjectFileName } from "../lib/file-names"; import { mergeStringLists } from "../lib/string-lists"; import { fetchProjectFromUrl } from "../lib/project-url"; import { getShareFetch } from "../lib/share-fetch"; -import { resolveShareBaseUrl } from "../lib/share-geolibre"; +import { + resolveShareBaseUrl, + sharedProjectContentMatches, + updateSharedProjectContent, +} from "../lib/share-geolibre"; import { shareAuthorizedFetch } from "../lib/share-gallery"; import { normalizeProjectUrl } from "../lib/urls"; import { recordExplicitProjectSave } from "../lib/project-history-session"; @@ -140,6 +144,14 @@ export interface SaveNamePrompt { placeholder: string; } +export interface RemoteSharedProjectTarget { + id: string; + versionCount: number; + canEdit: boolean; + token: string; + baseUrl: string; +} + /** * Detects a plain GeoJSON layer that a desktop drag-drop or Add Data import * embedded from a local file whose absolute path was captured, so its data can @@ -276,6 +288,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { ); const [saveNamePrompt, setSaveNamePrompt] = useState(null); const [saveNameInput, setSaveNameInput] = useState(""); + const [remoteSaveWarning, setRemoteSaveWarning] = useState( + null, + ); const projectUrlAbortRef = useRef(null); const recentAbortRef = useRef(null); // Separate from projectUrlAbortRef so a gallery open and an Open-from-URL @@ -289,6 +304,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // dialog is open would overwrite the pending prompt and strand the first // call's unresolved promise. const isSavingRef = useRef(false); + const remoteProjectRef = useRef< + (RemoteSharedProjectTarget & { projectGeneration: number }) | null + >(null); // Settling a prompt means resolving its promise and clearing the dialog // state. Each pattern lives here once so the dialog handlers further down and @@ -322,6 +340,13 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // useLayoutEffect (not useEffect) so the stale dialog is gone in the same // commit that swapped the project, rather than lingering for one paint. useLayoutEffect(() => { + if ( + remoteProjectRef.current && + remoteProjectRef.current.projectGeneration !== projectGeneration + ) { + remoteProjectRef.current = null; + setRemoteSaveWarning(null); + } if (credentialStripPrompt && credentialStripPrompt.projectGeneration !== projectGeneration) { settleCredentialStripPrompt(credentialStripPrompt, "cancel"); } @@ -469,14 +494,20 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { for (const layer of imported.project.layers) { if (layer.sourcePath && !isHttpUrl(layer.sourcePath)) { unavailableLayerIds.add(layer.id); - imported.warnings.push({ layerName: layer.name, reason: "browser-local-file" }); + imported.warnings.push({ + layerName: layer.name, + reason: "browser-local-file", + }); } } imported.project.layers = imported.project.layers.filter( (layer) => !unavailableLayerIds.has(layer.id), ); for (const raster of imported.rasters) { - imported.warnings.push({ layerName: raster.name, reason: "browser-local-file" }); + imported.warnings.push({ + layerName: raster.name, + reason: "browser-local-file", + }); } // Rasters never load in the browser build, so drop them before the // group prune below rather than letting them keep a group alive that @@ -533,7 +564,10 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { ); } catch (error) { console.error(`Failed to import ArcGIS raster "${raster.name}"`, error); - imported.warnings.push({ layerName: raster.name, reason: "format" }); + imported.warnings.push({ + layerName: raster.name, + reason: "format", + }); } } } @@ -557,7 +591,10 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { } } catch (error) { console.error(`Failed to import ArcGIS service "${service.name}"`, error); - imported.warnings.push({ layerName: service.name, reason: "service" }); + imported.warnings.push({ + layerName: service.name, + reason: "service", + }); } } useAppStore.setState({ isDirty: true }); @@ -624,7 +661,11 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const openProjectFromShareUrl = async ( url: string, - options: { authToken?: string; asCopy?: boolean } = {}, + options: { + authToken?: string; + asCopy?: boolean; + remoteProject?: RemoteSharedProjectTarget; + } = {}, ): Promise => { const normalizedUrl = normalizeProjectUrl(url); if (!normalizedUrl) { @@ -661,10 +702,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { if (options.asCopy) { const detached = detachProjectCopy(project, { nameSuffix: "" }); loadProject(detached, null); + remoteProjectRef.current = null; useAppStore.setState({ isDirty: true }); } else { loadProject(project, shareAuth ? null : normalizedUrl); + const generation = useAppStore.getState().projectGeneration; + remoteProjectRef.current = options.remoteProject + ? { ...options.remoteProject, projectGeneration: generation } + : null; } + setRemoteSaveWarning(null); } finally { if (shareUrlAbortRef.current === controller) { shareUrlAbortRef.current = null; @@ -819,7 +866,11 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // picks an option in the dialog. const askStripCredentials = (count: number, promptProjectGeneration: number) => new Promise<"strip" | "keep" | "cancel">((resolve) => { - setCredentialStripPrompt({ count, projectGeneration: promptProjectGeneration, resolve }); + setCredentialStripPrompt({ + count, + projectGeneration: promptProjectGeneration, + resolve, + }); }); const resolveCredentialStripPrompt = (choice: "strip" | "keep" | "cancel") => @@ -1018,7 +1069,11 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { ) => new Promise((resolve) => { setSaveNameInput(defaultName); - setSaveNamePrompt({ projectGeneration: promptProjectGeneration, resolve, ...labels }); + setSaveNamePrompt({ + projectGeneration: promptProjectGeneration, + resolve, + ...labels, + }); }); const submitSaveNamePrompt = (event?: FormEvent) => { @@ -1079,6 +1134,43 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { contentToSave = serializeForSave(projectToEgress); } if (contentToSave === null) return false; + const remoteProject = remoteProjectRef.current; + if ( + !options?.saveAs && + remoteProject?.canEdit && + remoteProject.projectGeneration === saveProjectGeneration + ) { + try { + const updated = await updateSharedProjectContent({ + token: remoteProject.token, + projectId: remoteProject.id, + content: contentToSave, + expectedVersion: remoteProject.versionCount, + baseUrl: remoteProject.baseUrl, + }); + if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false; + const updatedRemoteProject = { + ...remoteProject, + versionCount: updated.versionCount, + }; + remoteProjectRef.current = updatedRemoteProject; + + const liveProject = excludeHiddenFieldsFromProject(buildCurrentProject().project); + const liveContent = serializeForSave(liveProject); + if (liveContent && sharedProjectContentMatches(updated.savedContent, liveContent)) { + markSaved(); + recordExplicitProjectSave(); + } + setRemoteSaveWarning(updated.warning ? updatedRemoteProject : null); + return true; + } catch (error) { + console.error("Failed to update shared project", error); + setActionError( + error instanceof Error ? error.message : t("toolbar.error.couldNotSaveProject"), + ); + return false; + } + } // Projects opened from a URL have no writable path, so both Save and // Save As fall back to the save dialog for them. const existingLocalPath = projectPath && !isHttpUrl(projectPath) ? projectPath : null; @@ -1270,6 +1362,8 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { setQgisImportWarnings, arcgisImportWarnings, setArcgisImportWarnings, + remoteSaveWarning, + clearRemoteSaveWarning: () => setRemoteSaveWarning(null), projectUrlDialogOpen, setProjectUrlDialogOpen, handleProjectUrlDialogOpenChange, diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index f77480fac..6c86c3d35 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1234,6 +1234,17 @@ "visibilityUnlisted": "Unlisted (anyone with the link)", "visibilityPublic": "Public (listed in the gallery)", "visibilityPrivate": "Private (only you)", + "visibilityOrganization": "Organization (members of your organization)", + "groups": "Groups", + "publicDisabledByOrgPolicy": "Your organization does not allow public sharing", + "publicPublisherRequired": "Only organization publishers and administrators can share publicly.", + "owner": "Owner", + "personalAccount": "Personal account", + "organizationRequired": "Select an organization for organization visibility.", + "loadingOrganizations": "Loading organizations…", + "loadingGroups": "Loading groups…", + "groupsHint": "Select groups to share this project with. Members can view it; if the group has shared updates enabled, they can also edit it.", + "sharedUpdate": "shared updates", "shareButton": "Share", "sharing": "Sharing…", "errorFallback": "Could not share the project.", @@ -1295,11 +1306,16 @@ "scopeFeatured": "Featured", "scopeAll": "All projects", "scopeMine": "My projects", + "scopeOrganizations": "Organizations", + "scopeGroups": "Groups", "signedOutHint": "Add a {{shareHost}} API token in Settings to see your own unlisted and private projects.", "emptyFeatured": "No featured projects yet.", "emptyMine": "You have not shared any projects yet.", "visibilityUnlisted": "Unlisted", - "visibilityPrivate": "Private" + "visibilityPrivate": "Private", + "visibilityOrganization": "Organization", + "emptyOrganizations": "No organization projects to show yet.", + "emptyGroups": "No group projects to show yet." }, "template": { "saveTitle": "Save as template", @@ -2673,6 +2689,16 @@ "open": "Open", "somethingWentWrong": "Something went wrong", "dismiss": "Dismiss", + "sharedSaveWarningTitle": "A newer shared version was already available", + "sharedSaveWarningDescription": "Your changes were saved as a new version, but they replaced work saved since you opened this project. Review Project History before continuing.", + "openServerHistory": "Open server history", + "serverHistoryTitle": "Shared project versions", + "serverHistoryDescription": "These versions are stored by the sharing server. Open any raw version to inspect its project JSON.", + "loadingServerHistory": "Loading server versions…", + "emptyServerHistory": "The server returned no project versions.", + "serverHistoryError": "Could not load server version history.", + "serverVersion": "Version {{version}}", + "serverVersionDateUnknown": "Date unavailable", "saveProjectAsTitle": "Save project as", "saveProjectAsDesc": "Enter a file name. The project will download to your browser's downloads folder when you click Save.", "embedVectorTitle": "Embed local vector data?", diff --git a/apps/geolibre-desktop/src/lib/share-gallery.ts b/apps/geolibre-desktop/src/lib/share-gallery.ts index 781c9edae..9ef5fd7ba 100644 --- a/apps/geolibre-desktop/src/lib/share-gallery.ts +++ b/apps/geolibre-desktop/src/lib/share-gallery.ts @@ -63,6 +63,10 @@ export interface SharedProject { title: string; description: string; visibility: string; + organization: { id: string; slug: string; name: string } | null; + groupIds: string[]; + /** Authoritative edit permission supplied by authenticated listing endpoints. */ + canEdit: boolean; /** Absolute thumbnail URL (the API returns a path; we resolve it here). */ thumbnailUrl: string | null; views: number; @@ -94,6 +98,16 @@ export interface FetchSharedProjectsOptions { fetchImpl?: typeof fetch; } +export interface FetchProjectsSharedWithMeOptions { + token: string; + source?: "organizations" | "groups"; + limit?: number; + offset?: number; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + export interface FetchSharedProjectsResult { projects: SharedProject[]; /** True when the page came back full, so another page likely exists. */ @@ -117,6 +131,9 @@ interface RawSharedProject { title?: unknown; description?: unknown; visibility?: unknown; + organization?: unknown; + groupIds?: unknown; + canEdit?: unknown; thumbnailUrl?: unknown; views?: unknown; forkCount?: unknown; @@ -168,6 +185,21 @@ function normalizeProject(raw: RawSharedProject, base: string): SharedProject | title: asString(raw.title), description: asString(raw.description), visibility: asString(raw.visibility), + organization: + raw.organization && + typeof raw.organization === "object" && + typeof (raw.organization as { id?: unknown }).id === "string" + ? { + id: (raw.organization as { id: string }).id, + slug: asString((raw.organization as { slug?: unknown }).slug), + name: asString((raw.organization as { name?: unknown }).name), + } + : null, + groupIds: Array.isArray(raw.groupIds) + ? raw.groupIds.filter((groupId): groupId is string => typeof groupId === "string") + : [], + // Missing permission metadata must never grant write access. + canEdit: raw.canEdit === true, thumbnailUrl: resolveThumbnailUrl(raw.thumbnailUrl, base), views: asNumber(raw.views), forkCount: asNumber(raw.forkCount), @@ -235,7 +267,9 @@ export async function fetchSharedProjects( // gallery, so let a JSON parse failure throw rather than swallowing it. let payload: { projects?: RawSharedProject[] } | null; try { - payload = (await response.json()) as { projects?: RawSharedProject[] } | null; + payload = (await response.json()) as { + projects?: RawSharedProject[]; + } | null; } catch { throw new GalleryError("invalid-response"); } @@ -251,6 +285,69 @@ export async function fetchSharedProjects( return { projects, hasMore, rawCount: rawProjects.length }; } +async function shareAuthorizedJsonRequest( + path: string, + token: string, + base: string, + options: { signal?: AbortSignal; fetchImpl?: typeof fetch } = {}, +): Promise { + const authFetch = shareAuthorizedFetch(token, base, options.fetchImpl ?? getShareFetch()); + const timeout = AbortSignal.timeout(LISTING_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + let response: Response; + try { + response = await authFetch(`${base}${path}`, { + headers: { Accept: "application/json" }, + signal, + }); + } catch (error) { + if (error instanceof DOMException) { + if (error.name === "AbortError") throw error; + if (error.name === "TimeoutError") throw new GalleryError("timeout"); + } + throw new GalleryError("network"); + } + if (response.status === 401 || response.status === 403) { + throw new GalleryError("unauthorized"); + } + if (!response.ok) { + throw new GalleryError("http", response.status); + } + try { + return (await response.json()) as unknown; + } catch { + throw new GalleryError("invalid-response"); + } +} + +/** Fetch one authenticated page of projects shared through the user's organizations or groups. */ +export async function fetchProjectsSharedWithMe( + options: FetchProjectsSharedWithMeOptions, +): Promise { + const base = requireShareBase(options.baseUrl); + const params = new URLSearchParams({ shared_with_me: "true" }); + if (options.source) params.set("shared_source", options.source); + if (options.limit != null) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + + const payload = (await shareAuthorizedJsonRequest( + `/api/projects?${params}`, + options.token, + base, + { signal: options.signal, fetchImpl: options.fetchImpl }, + )) as { projects?: RawSharedProject[]; total?: unknown } | null; + + const rawProjects = Array.isArray(payload?.projects) ? payload.projects : []; + const projects = rawProjects + .map((raw) => normalizeProject(raw, base)) + .filter((project): project is SharedProject => project !== null); + const hasMore = + typeof payload?.total === "number" && Number.isFinite(payload.total) + ? (options.offset ?? 0) + rawProjects.length < payload.total + : options.limit != null && rawProjects.length >= options.limit; + return { projects, hasMore, rawCount: rawProjects.length }; +} + export interface FetchMyProjectsOptions { /** Personal API token from Settings; authenticates as the owner. */ token: string; @@ -274,7 +371,7 @@ export function projectOpenToken( token: string, ): string | undefined { if (!token) return undefined; - return project.visibility === "private" ? token : undefined; + return project.visibility === "public" || project.visibility === "unlisted" ? undefined : token; } /** @@ -298,7 +395,7 @@ export function shareAuthorizedFetch( } catch { baseOrigin = null; } - return (input: RequestInfo | URL, init: RequestInit = {}) => { + return ((input: RequestInfo | URL, init: RequestInit = {}) => { const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; let sameHost = false; try { @@ -310,6 +407,52 @@ export function shareAuthorizedFetch( const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${token}`); return baseFetch(input, { ...init, headers }); + }) as typeof fetch; +} + +export interface SharedThumbnailResult { + url: string; + /** True when `url` is an object URL that the caller must revoke. */ + objectUrl: boolean; +} + +interface LoadSharedThumbnailOptions { + token: string; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; + createObjectUrl?: (blob: Blob) => string; +} + +/** Resolve a gallery thumbnail, authenticating protected project images. */ +export async function loadSharedProjectThumbnail( + project: Pick, + options: LoadSharedThumbnailOptions, +): Promise { + if (!project.thumbnailUrl) return null; + if (project.visibility === "public" || project.visibility === "unlisted") { + return { url: project.thumbnailUrl, objectUrl: false }; + } + + const base = requireShareBase(options.baseUrl); + const authFetch = shareAuthorizedFetch(options.token, base, options.fetchImpl ?? getShareFetch()); + const timeout = AbortSignal.timeout(LISTING_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + let response: Response; + try { + response = await authFetch(project.thumbnailUrl, { signal }); + } catch (error) { + if (error instanceof DOMException) { + if (error.name === "AbortError") throw error; + if (error.name === "TimeoutError") throw new GalleryError("timeout"); + } + throw new GalleryError("network"); + } + if (!response.ok) throw new GalleryError("http", response.status); + const blob = await response.blob(); + return { + url: (options.createObjectUrl ?? URL.createObjectURL)(blob), + objectUrl: true, }; } @@ -326,54 +469,218 @@ export function shareAuthorizedFetch( */ export async function fetchMyProjects(options: FetchMyProjectsOptions): Promise { const base = requireShareBase(options.baseUrl); - // One auth path for both production and tests: the injected fetch (or the - // share fetch, which the desktop build routes natively to bypass CORS — see - // share-fetch.ts) flows through the same same-origin token gating. - const authFetch = shareAuthorizedFetch(options.token, base, options.fetchImpl ?? getShareFetch()); + const username = await fetchMyShareUsername(options); - const timeout = AbortSignal.timeout(LISTING_TIMEOUT_MS); - const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; - - const request = async (path: string): Promise => { - let response: Response; - try { - response = await authFetch(`${base}${path}`, { - headers: { Accept: "application/json" }, - signal, - }); - } catch (error) { - if (error instanceof DOMException) { - if (error.name === "AbortError") throw error; - if (error.name === "TimeoutError") throw new GalleryError("timeout"); - } - throw new GalleryError("network"); - } - if (response.status === 401 || response.status === 403) { - throw new GalleryError("unauthorized"); + const pageSize = 100; + const maxPages = 1000; + const rawProjects: RawSharedProject[] = []; + let previousPageIds: Set | null = null; + for (let page = 0; page < maxPages; page++) { + const offset = page * pageSize; + const payload = (await shareAuthorizedJsonRequest( + `/api/users/${encodeURIComponent(username)}/projects?limit=${pageSize}&offset=${offset}`, + options.token, + base, + { signal: options.signal, fetchImpl: options.fetchImpl }, + )) as { projects?: RawSharedProject[] } | null; + const items = Array.isArray(payload?.projects) ? payload.projects : []; + const currentIds = new Set( + items.map((p) => (p && typeof p.id === "string" ? p.id : "")).filter(Boolean), + ); + if ( + previousPageIds && + currentIds.size > 0 && + currentIds.size === previousPageIds.size && + [...currentIds].every((id) => previousPageIds?.has(id)) + ) { + break; } - if (!response.ok) { - throw new GalleryError("http", response.status); - } - try { - return (await response.json()) as unknown; - } catch { - throw new GalleryError("invalid-response"); - } - }; + previousPageIds = currentIds; + rawProjects.push(...items); + if (items.length < pageSize) break; + } + return rawProjects + .map((raw) => normalizeProject(raw, base)) + .filter((p): p is SharedProject => p !== null); +} - const me = (await request("/api/users/me")) as { - user?: { username?: string | null }; - } | null; - const username = me?.user?.username; - if (!username) { +/** + * An organization as returned by the share server. + */ +export interface ShareOrganization { + id: string; + slug: string; + name: string; + publicSharingPolicy: "yes" | "publishers" | "no"; + defaultVisibility: "public" | "unlisted" | "private" | "organization"; + categories: string[]; + role: string | null; +} + +/** + * A group as returned by the share server. + */ +export interface ShareGroup { + id: string; + name: string; + description: string; + organizationId: string | null; + joinPolicy: "invite" | "request" | "open"; + sharedUpdate: boolean; + role: string | null; +} + +export type PublicSharingRestriction = "organization-disabled" | "publisher-required" | null; + +/** Explain whether the selected organization permits this member to publish publicly. */ +export function publicSharingRestriction( + organization: ShareOrganization | null, +): PublicSharingRestriction { + if (!organization || organization.publicSharingPolicy === "yes") return null; + if (organization.role === "administrator") return null; + if (organization.publicSharingPolicy === "no") return "organization-disabled"; + return organization.role === "publisher" ? null : "publisher-required"; +} + +/** Public-sharing policy applies only to the public visibility choice. */ +export function isPublicSharingBlocked( + visibility: string, + organization: ShareOrganization | null, +): boolean { + return visibility === "public" && publicSharingRestriction(organization) !== null; +} + +/** Match a shared project to the organizations shown by /api/organizations/mine. */ +export function isProjectInMyOrganizations( + project: Pick, + organizations: readonly ShareOrganization[], +): boolean { + return Boolean( + project.organization && + organizations.some((organization) => organization.id === project.organization?.id), + ); +} + +/** Match a shared project to the accepted memberships shown by /api/groups/mine. */ +export function isProjectInMyGroups( + project: Pick, + groups: readonly ShareGroup[], +): boolean { + const memberships = new Set(groups.map((group) => group.id)); + return project.groupIds.some((groupId) => memberships.has(groupId)); +} + +export interface FetchOrganizationsOptions { + token: string; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export interface FetchGroupsOptions { + token: string; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +/** Resolve the signed-in username so owner permissions hold across every gallery tab. */ +export async function fetchMyShareUsername(options: FetchMyProjectsOptions): Promise { + const base = requireShareBase(options.baseUrl); + const payload = (await shareAuthorizedJsonRequest("/api/users/me", options.token, base, { + signal: options.signal, + fetchImpl: options.fetchImpl, + })) as { user?: { username?: unknown } } | null; + if (typeof payload?.user?.username !== "string" || !payload.user.username) { throw new GalleryError("username-required"); } + return payload.user.username; +} - const payload = (await request(`/api/users/${encodeURIComponent(username)}/projects`)) as { - projects?: RawSharedProject[]; +/** + * Fetch the organizations the signed-in user belongs to. + */ +export async function fetchMyOrganizations( + options: FetchOrganizationsOptions, +): Promise { + const base = requireShareBase(options.baseUrl); + const payload = (await shareAuthorizedJsonRequest( + "/api/organizations/mine", + options.token, + base, + { signal: options.signal, fetchImpl: options.fetchImpl }, + )) as { + organizations?: unknown[]; } | null; - const rawProjects = Array.isArray(payload?.projects) ? payload.projects : []; - return rawProjects - .map((raw) => normalizeProject(raw, base)) - .filter((p): p is SharedProject => p !== null); + const raw = Array.isArray(payload?.organizations) ? payload.organizations : []; + return raw + .map((o): ShareOrganization | null => { + if (!o || typeof o !== "object") return null; + const org = o as Record; + if ( + typeof org.id !== "string" || + typeof org.slug !== "string" || + typeof org.name !== "string" + ) { + return null; + } + return { + id: org.id, + slug: org.slug, + name: org.name, + publicSharingPolicy: + org.publicSharingPolicy === "yes" || + org.publicSharingPolicy === "publishers" || + org.publicSharingPolicy === "no" + ? org.publicSharingPolicy + : "publishers", + defaultVisibility: + org.defaultVisibility === "public" || + org.defaultVisibility === "unlisted" || + org.defaultVisibility === "private" || + org.defaultVisibility === "organization" + ? org.defaultVisibility + : "organization", + categories: Array.isArray(org.categories) + ? org.categories.filter((c): c is string => typeof c === "string") + : [], + role: typeof org.role === "string" ? org.role : null, + }; + }) + .filter((o): o is ShareOrganization => o !== null); +} + +/** + * Fetch the groups the signed-in user belongs to. + */ +export async function fetchMyGroups(options: FetchGroupsOptions): Promise { + const base = requireShareBase(options.baseUrl); + const payload = (await shareAuthorizedJsonRequest("/api/groups/mine", options.token, base, { + signal: options.signal, + fetchImpl: options.fetchImpl, + })) as { groups?: unknown[] } | null; + const raw = Array.isArray(payload?.groups) ? payload.groups : []; + return raw + .map((g): ShareGroup | null => { + if (!g || typeof g !== "object") return null; + const group = g as Record; + if (typeof group.id !== "string" || typeof group.name !== "string") { + return null; + } + return { + id: group.id, + name: group.name, + description: typeof group.description === "string" ? group.description : "", + organizationId: typeof group.organizationId === "string" ? group.organizationId : null, + joinPolicy: + group.joinPolicy === "invite" || + group.joinPolicy === "request" || + group.joinPolicy === "open" + ? group.joinPolicy + : "invite", + sharedUpdate: group.sharedUpdate === true, + role: typeof group.role === "string" ? group.role : null, + }; + }) + .filter((g): g is ShareGroup => g !== null); } diff --git a/apps/geolibre-desktop/src/lib/share-geolibre.ts b/apps/geolibre-desktop/src/lib/share-geolibre.ts index 57db188af..7baf51d78 100644 --- a/apps/geolibre-desktop/src/lib/share-geolibre.ts +++ b/apps/geolibre-desktop/src/lib/share-geolibre.ts @@ -15,7 +15,8 @@ import { import { readDeploymentEnvValue, type EnvRecord } from "./deployment-env"; import { getShareFetch } from "./share-fetch"; -export type ShareVisibility = "public" | "unlisted" | "private"; +/** `organization` is readable by every signed-in member of the owning organization. */ +export type ShareVisibility = "public" | "unlisted" | "private" | "organization"; /** * Machine-readable cause for an upload failure the dialog can react to. Only @@ -62,6 +63,10 @@ export interface ShareUploadOptions { filename: string; content: string; visibility: ShareVisibility; + /** Optional owning organization. Required by the server for `organization` visibility. */ + organizationId?: string; + /** Groups that may read this project even when it is private. */ + groupIds?: string[]; /** Override the share host; defaults to the configured/production URL. */ baseUrl?: string; signal?: AbortSignal; @@ -69,6 +74,38 @@ export interface ShareUploadOptions { fetchImpl?: typeof fetch; } +export interface SharedProjectUpdateOptions { + token: string; + projectId: string; + content: string; + expectedVersion: number; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export interface SharedProjectUpdateResult { + version: number; + versionCount: number; + warning: string | null; + /** Exact sanitized project content sent in the PUT request. */ + savedContent: string; +} + +export interface SharedProjectVersion { + number: number; + createdAt: string; + rawUrl: string; +} + +export interface FetchSharedProjectVersionsOptions { + token: string; + projectId: string; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + export const DEFAULT_SHARE_BASE_URL = "https://share.geolibre.app"; // Upload deadline; a hung connection rejects with a TimeoutError rather than @@ -99,6 +136,20 @@ export function isShareableTitle(title: string): boolean { ); } +/** Canonicalize and redact project content exactly as the share write path does. */ +export function sanitizeSharedProjectContent(content: string): string { + return serializeProject(redactCredentials(parseProject(content))); +} + +/** True only when the live project still equals the exact content sent remotely. */ +export function sharedProjectContentMatches(savedContent: string, liveContent: string): boolean { + try { + return sanitizeSharedProjectContent(savedContent) === sanitizeSharedProjectContent(liveContent); + } catch { + return false; + } +} + /** * Deployment variable naming the share host. Settable at build time or, on a * prebuilt Docker image, with `-e GEOLIBRE_SHARE_URL=…` (the entrypoint copies it @@ -166,7 +217,11 @@ export function resolveShareHost(configured?: unknown, deploymentEnv?: EnvRecord const raw = configured !== undefined ? configured : readDeploymentEnvValue(SHARE_URL_ENV, deploymentEnv); if (typeof raw !== "string" || !raw.trim()) { - return { status: "default", baseUrl: DEFAULT_SHARE_BASE_URL, configured: null }; + return { + status: "default", + baseUrl: DEFAULT_SHARE_BASE_URL, + configured: null, + }; } const trimmed = raw.trim().replace(/\/+$/, ""); if (trimmed.toLowerCase() === SHARE_DISABLED_VALUE) { @@ -268,7 +323,7 @@ export async function uploadProjectToShare( let safeContent: string; try { - safeContent = serializeProject(redactCredentials(parseProject(options.content))); + safeContent = sanitizeSharedProjectContent(options.content); } catch { throw new Error("The project could not be validated before sharing."); } @@ -285,6 +340,8 @@ export async function uploadProjectToShare( filename: options.filename, content: safeContent, visibility: options.visibility, + ...(options.organizationId ? { organizationId: options.organizationId } : {}), + ...(options.groupIds?.length ? { groupIds: options.groupIds } : {}), }), signal, }); @@ -318,6 +375,128 @@ export async function uploadProjectToShare( }; } +/** Save a new version of an editable project already hosted by the share server. */ +export async function updateSharedProjectContent( + options: SharedProjectUpdateOptions, +): Promise { + const token = options.token.trim(); + if (!token) throw new Error("Add a share API token in Settings before saving."); + const resolved = options.baseUrl ?? resolveShareBaseUrl(); + if (!resolved) throw new Error("No share server is configured for this deployment."); + const base = resolved.replace(/\/+$/, ""); + const fetchImpl = options.fetchImpl ?? getShareFetch(); + const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + + let safeContent: string; + try { + safeContent = sanitizeSharedProjectContent(options.content); + } catch { + throw new Error("The project could not be validated before saving."); + } + + let response: Response; + try { + response = await fetchImpl( + `${base}/api/projects/${encodeURIComponent(options.projectId)}/content`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: safeContent, + expectedVersion: options.expectedVersion, + }), + signal, + }, + ); + } catch (error) { + if (error instanceof DOMException) { + if (error.name === "AbortError") throw error; + if (error.name === "TimeoutError") throw new Error("Save timed out. Please try again."); + } + throw new Error(`Could not reach ${hostOf(base)}. Check your internet connection.`); + } + if (!response.ok) { + const { message, code } = await uploadErrorInfo(response); + throw new ShareUploadError(message, code); + } + const payload = (await response.json().catch(() => null)) as { + project?: { versionCount?: unknown }; + version?: unknown; + warning?: unknown; + } | null; + if (typeof payload?.version !== "number" || !Number.isFinite(payload.version)) { + throw new Error(`${hostOf(base)} returned an unexpected response.`); + } + return { + version: payload.version, + versionCount: + typeof payload.project?.versionCount === "number" && + Number.isFinite(payload.project.versionCount) + ? payload.project.versionCount + : payload.version, + warning: typeof payload.warning === "string" && payload.warning ? payload.warning : null, + savedContent: safeContent, + }; +} + +/** Fetch the authoritative version history retained by the share server. */ +export async function fetchSharedProjectVersions( + options: FetchSharedProjectVersionsOptions, +): Promise { + const token = options.token.trim(); + if (!token) throw new Error("Add a share API token in Settings before loading versions."); + const resolved = options.baseUrl ?? resolveShareBaseUrl(); + if (!resolved) throw new Error("No share server is configured for this deployment."); + const base = resolved.replace(/\/+$/, ""); + const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + const url = `${base}/api/projects/${encodeURIComponent(options.projectId)}/versions`; + let response: Response; + try { + response = await (options.fetchImpl ?? getShareFetch())(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + signal, + }); + } catch (error) { + if (error instanceof DOMException) { + if (error.name === "AbortError") throw error; + if (error.name === "TimeoutError") throw new Error("Load timed out. Please try again."); + } + throw new Error(`Could not reach ${hostOf(base)}. Check your internet connection.`); + } + if (!response.ok) { + const { message } = await uploadErrorInfo(response); + throw new Error(message); + } + const payload = (await response.json().catch(() => null)) as { versions?: unknown } | null; + if (!Array.isArray(payload?.versions)) { + throw new Error(`${hostOf(base)} returned an unexpected response.`); + } + return payload.versions + .map((value): SharedProjectVersion | null => { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const number = + typeof raw.number === "number" + ? raw.number + : typeof raw.version === "number" + ? raw.version + : null; + if (number === null || !Number.isFinite(number)) return null; + return { + number, + createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "", + rawUrl: `${url}/${number}`, + }; + }) + .filter((version): version is SharedProjectVersion => version !== null) + .sort((a, b) => b.number - a.number); +} + async function uploadErrorInfo( response: Response, ): Promise<{ message: string; code?: ShareUploadErrorCode }> { @@ -330,7 +509,9 @@ async function uploadErrorInfo( if (response.status === 429) { return { message: "Too many uploads. Please wait a while and try again." }; } - const body = (await response.json().catch(() => null)) as { error?: string } | null; + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; // Cap the server-provided string so a misconfigured host or MITM on a // non-HTTPS share URL cannot render a wall of text in the dialog. Slice by // code point so the cap can't orphan a UTF-16 surrogate pair. diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index 1add5875e..b9b375bbf 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -9,7 +9,7 @@ import secrets import shutil import uuid -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import Annotated, Literal from urllib.parse import quote @@ -22,6 +22,7 @@ from sqlalchemy import ( Boolean, ForeignKey, + Index, Integer, String, Text, @@ -30,7 +31,10 @@ delete, event, func, + inspect, + or_, select, + text, update, ) from sqlalchemy.exc import IntegrityError, OperationalError @@ -44,7 +48,13 @@ sessionmaker, ) -Visibility = Literal["public", "unlisted", "private"] +UTC = getattr(timezone, "utc", timezone.utc) + +Visibility = Literal["public", "unlisted", "private", "organization"] +OrganizationRole = Literal["administrator", "publisher", "member", "viewer"] +GroupRole = Literal["owner", "manager", "member"] +PublicSharingPolicy = Literal["yes", "publishers", "no"] +JoinPolicy = Literal["invite", "request", "open"] # 3-39 chars, starting and ending alphanumeric. The middle group is *not* # optional: making it so would let a single character through, which contradicts # both the error text and the limits table in docs/server-api.md. @@ -63,11 +73,116 @@ class Account(Base): __tablename__ = "accounts" id: Mapped[str] = mapped_column(String(36), primary_key=True) username: Mapped[str | None] = mapped_column(String(39), unique=True, nullable=True) + email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True) password_hash: Mapped[str] = mapped_column(Text) created_at: Mapped[str] = mapped_column(String(32)) projects: Mapped[list[Project]] = relationship( - back_populates="owner", cascade="all, delete-orphan" + back_populates="owner", foreign_keys="Project.owner_id" + ) + + +class Organization(Base): + __tablename__ = "organizations" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + slug: Mapped[str] = mapped_column(String(100), unique=True) + name: Mapped[str] = mapped_column(String(100)) + public_sharing_policy: Mapped[str] = mapped_column(String(16), default="yes") + default_visibility: Mapped[str] = mapped_column(String(16), default="organization") + categories_json: Mapped[str] = mapped_column(Text, default="[]") + created_at: Mapped[str] = mapped_column(String(32)) + members: Mapped[list[OrganizationMember]] = relationship( + back_populates="organization", cascade="all, delete-orphan" + ) + + +class OrganizationMember(Base): + __tablename__ = "organization_members" + organization_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True + ) + account_id: Mapped[str] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), primary_key=True ) + role: Mapped[str] = mapped_column(String(16)) + created_at: Mapped[str] = mapped_column(String(32)) + organization: Mapped[Organization] = relationship(back_populates="members") + account: Mapped[Account] = relationship() + + +class OrganizationInvitation(Base): + __tablename__ = "organization_invitations" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + organization_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), index=True + ) + invited_by_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE")) + username: Mapped[str | None] = mapped_column(String(39), nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True) + role: Mapped[str] = mapped_column(String(16), default="member") + status: Mapped[str] = mapped_column(String(16), default="pending") + token_digest: Mapped[str] = mapped_column(String(64), unique=True) + created_at: Mapped[str] = mapped_column(String(32)) + accepted_at: Mapped[str | None] = mapped_column(String(32), nullable=True) + revoked_at: Mapped[str | None] = mapped_column(String(32), nullable=True) + organization: Mapped[Organization] = relationship() + + +class Group(Base): + __tablename__ = "groups" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True + ) + owner_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(100)) + description: Mapped[str] = mapped_column(Text, default="") + thumbnail_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + join_policy: Mapped[str] = mapped_column(String(16), default="invite") + shared_update: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(String(32)) + members: Mapped[list[GroupMember]] = relationship( + back_populates="group", cascade="all, delete-orphan" + ) + + +class GroupMember(Base): + __tablename__ = "group_members" + __table_args__ = ( + Index( + "uq_group_accepted_owner", + "group_id", + unique=True, + sqlite_where=text("role = 'owner' AND status = 'accepted'"), + postgresql_where=text("role = 'owner' AND status = 'accepted'"), + ), + ) + group_id: Mapped[str] = mapped_column( + ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True + ) + account_id: Mapped[str] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), primary_key=True + ) + role: Mapped[str] = mapped_column(String(16)) + status: Mapped[str] = mapped_column(String(16), default="accepted") + created_at: Mapped[str] = mapped_column(String(32)) + group: Mapped[Group] = relationship(back_populates="members") + account: Mapped[Account] = relationship() + + +class GroupInvitation(Base): + __tablename__ = "group_invitations" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), index=True) + invited_by_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE")) + username: Mapped[str | None] = mapped_column(String(39), nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True) + role: Mapped[str] = mapped_column(String(16), default="member") + status: Mapped[str] = mapped_column(String(16), default="pending") + token_digest: Mapped[str] = mapped_column(String(64), unique=True) + created_at: Mapped[str] = mapped_column(String(32)) + accepted_at: Mapped[str | None] = mapped_column(String(32), nullable=True) + revoked_at: Mapped[str | None] = mapped_column(String(32), nullable=True) + group: Mapped[Group] = relationship() class Token(Base): @@ -81,13 +196,24 @@ class Token(Base): class Project(Base): __tablename__ = "projects" - __table_args__ = (UniqueConstraint("owner_id", "slug", name="uq_project_owner_slug"),) + __table_args__ = ( + UniqueConstraint("owner_id", "slug", name="uq_project_owner_slug"), + UniqueConstraint("organization_id", "slug", name="uq_project_org_slug"), + ) id: Mapped[str] = mapped_column(String(36), primary_key=True) - owner_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), index=True) + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True + ) + created_by_id: Mapped[str | None] = mapped_column( + ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True + ) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="SET NULL"), nullable=True, index=True + ) slug: Mapped[str] = mapped_column(String(100)) title: Mapped[str] = mapped_column(String(100)) description: Mapped[str] = mapped_column(Text, default="") - visibility: Mapped[str] = mapped_column(String(10)) + visibility: Mapped[str] = mapped_column(String(16)) tags_json: Mapped[str] = mapped_column(Text, default="[]") thumbnail_type: Mapped[str | None] = mapped_column(String(20), nullable=True) views: Mapped[int] = mapped_column(Integer, default=0) @@ -95,10 +221,28 @@ class Project(Base): featured: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[str] = mapped_column(String(32)) updated_at: Mapped[str] = mapped_column(String(32), index=True) - owner: Mapped[Account] = relationship(back_populates="projects") + owner: Mapped[Account | None] = relationship(back_populates="projects", foreign_keys=[owner_id]) + organization: Mapped[Organization | None] = relationship() versions: Mapped[list[Version]] = relationship( - back_populates="project", cascade="all, delete-orphan", order_by="Version.number" + back_populates="project", + cascade="all, delete-orphan", + order_by="Version.number", + ) + group_shares: Mapped[list[ProjectGroup]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + + +class ProjectGroup(Base): + __tablename__ = "project_groups" + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True ) + group_id: Mapped[str] = mapped_column( + ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True + ) + project: Mapped[Project] = relationship(back_populates="group_shares") + group: Mapped[Group] = relationship() class Version(Base): @@ -115,7 +259,12 @@ class Version(Base): # project_json reads project.owner.username and len(project.versions), both lazy. # Without these a single listing page (up to 100 rows) fires ~201 queries instead # of three. -LISTING_EAGER_LOADS = (selectinload(Project.owner), selectinload(Project.versions)) +LISTING_EAGER_LOADS = ( + selectinload(Project.owner), + selectinload(Project.organization), + selectinload(Project.versions), + selectinload(Project.group_shares).selectinload(ProjectGroup.group), +) class Credentials(BaseModel): @@ -126,10 +275,22 @@ class Credentials(BaseModel): password: str = Field(max_length=1024) +class AccountCreate(Credentials): + email: str | None = Field(default=None, max_length=320) + + +class AccountPatch(BaseModel): + email: str | None = Field(max_length=320) + + model_config = {"extra": "forbid"} + + class ProjectCreate(BaseModel): filename: str = Field(max_length=255) content: str visibility: Visibility + organization_id: str | None = Field(default=None, alias="organizationId") + group_ids: list[str] = Field(default_factory=list, alias="groupIds", max_length=20) class ProjectPatch(BaseModel): @@ -137,10 +298,78 @@ class ProjectPatch(BaseModel): description: str | None = Field(default=None, max_length=2000) visibility: Visibility | None = None tags: list[str] | None = None + organization_id: str | None = Field(default=None, alias="organizationId") + group_ids: list[str] | None = Field(default=None, alias="groupIds", max_length=20) + + +class OrganizationCreate(BaseModel): + slug: str = Field(min_length=3, max_length=100) + name: str = Field(min_length=1, max_length=100) + public_sharing_policy: PublicSharingPolicy = Field(default="yes", alias="publicSharingPolicy") + default_visibility: Visibility = Field(default="organization", alias="defaultVisibility") + categories: list[str] = Field(default_factory=list, max_length=50) + + +class OrganizationMemberChange(BaseModel): + username: str + role: OrganizationRole + + +class OrganizationInvitationCreate(BaseModel): + username: str | None = None + email: str | None = Field(default=None, max_length=320) + role: OrganizationRole = "member" + + model_config = {"extra": "forbid"} + + +class OrganizationSettingsPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + public_sharing_policy: PublicSharingPolicy | None = Field( + default=None, alias="publicSharingPolicy" + ) + default_visibility: Visibility | None = Field(default=None, alias="defaultVisibility") + categories: list[str] | None = Field(default=None, max_length=50) + + model_config = {"extra": "forbid"} + + +class GroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: str = Field(default="", max_length=2000) + organization_id: str | None = Field(default=None, alias="organizationId") + join_policy: JoinPolicy = Field(default="invite", alias="joinPolicy") + shared_update: bool = Field(default=False, alias="sharedUpdate") + + +class GroupMemberChange(BaseModel): + username: str + role: GroupRole = "member" + + +class GroupInvitationCreate(BaseModel): + username: str | None = None + email: str | None = Field(default=None, max_length=320) + role: GroupRole = "member" + + model_config = {"extra": "forbid"} + + +class GroupSettingsPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=2000) + join_policy: JoinPolicy | None = Field(default=None, alias="joinPolicy") + + model_config = {"extra": "forbid"} + + +class JoinRequestDecision(BaseModel): + decision: Literal["accept", "reject"] class ContentUpdate(BaseModel): content: str + expected_version: int | None = Field(default=None, alias="expectedVersion", ge=1) class ForkRequest(BaseModel): @@ -262,6 +491,281 @@ def title_from(document: dict, filename: str) -> str: return candidate or "Untitled" +def normalize_email(value: str | None) -> str | None: + if value is None: + return None + email = value.strip().lower() + if ( + len(email) > 320 + or not re.fullmatch( + r"[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@" + r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?\.[a-z]{2,63}", + email, + ) + or ".." in email + ): + raise HTTPException(422, "email must be a valid email address") + return email + + +def postgresql_upgrade_statements() -> list[str]: + """Return idempotent DDL for databases created by pre-organization releases.""" + return [ + "ALTER TABLE accounts ADD COLUMN IF NOT EXISTS email VARCHAR(320)", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_email ON accounts (email)", + "ALTER TABLE projects ADD COLUMN IF NOT EXISTS organization_id VARCHAR(36)", + "ALTER TABLE projects ADD COLUMN IF NOT EXISTS created_by_id VARCHAR(36)", + "ALTER TABLE projects ALTER COLUMN visibility TYPE VARCHAR(16)", + "ALTER TABLE projects ALTER COLUMN owner_id DROP NOT NULL", + "UPDATE projects SET created_by_id = owner_id WHERE created_by_id IS NULL", + "CREATE INDEX IF NOT EXISTS ix_projects_owner_id ON projects (owner_id)", + "CREATE INDEX IF NOT EXISTS ix_projects_organization_id ON projects (organization_id)", + "CREATE INDEX IF NOT EXISTS ix_projects_created_by_id ON projects (created_by_id)", + "CREATE INDEX IF NOT EXISTS ix_projects_updated_at ON projects (updated_at)", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_project_org_slug_idx " + "ON projects (organization_id, slug)", + """ + DO $$ + DECLARE constraint_name text; + BEGIN + FOR constraint_name IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'projects'::regclass + AND c.contype = 'f' + AND a.attname = 'owner_id' + AND c.confdeltype <> 'n' + LOOP + EXECUTE format('ALTER TABLE projects DROP CONSTRAINT %I', constraint_name); + END LOOP; + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'projects'::regclass + AND c.contype = 'f' + AND a.attname = 'owner_id' + ) THEN + ALTER TABLE projects ADD CONSTRAINT fk_projects_owner_id + FOREIGN KEY (owner_id) REFERENCES accounts(id) ON DELETE SET NULL; + END IF; + END $$ + """, + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'projects'::regclass + AND c.contype = 'f' + AND a.attname = 'created_by_id' + ) THEN + ALTER TABLE projects ADD CONSTRAINT fk_projects_created_by_id + FOREIGN KEY (created_by_id) REFERENCES accounts(id) ON DELETE SET NULL; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'projects'::regclass + AND c.contype = 'f' + AND a.attname = 'organization_id' + ) THEN + ALTER TABLE projects ADD CONSTRAINT fk_projects_organization_id + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL; + END IF; + END $$ + """, + """ + UPDATE group_members AS member + SET role = 'manager' + FROM groups AS owning_group + WHERE member.group_id = owning_group.id + AND member.role = 'owner' + AND member.account_id <> owning_group.owner_id + """, + """ + UPDATE group_members AS member + SET role = 'owner', status = 'accepted' + FROM groups AS owning_group + WHERE member.group_id = owning_group.id + AND member.account_id = owning_group.owner_id + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_group_accepted_owner + ON group_members (group_id) + WHERE role = 'owner' AND status = 'accepted' + """, + ] + + +def upgrade_postgresql_schema(engine) -> None: + with engine.begin() as connection: + for statement in postgresql_upgrade_statements(): + connection.execute(text(statement)) + + +def upgrade_sqlite_schema(engine) -> None: + """Add nullable columns introduced after the initial SQLite release. + + SQLAlchemy's create_all creates new tables but deliberately does not alter + existing ones. These additive changes keep old installations bootable; a + fresh database still receives the complete constraints from the models. + """ + inspector = inspect(engine) + tables = set(inspector.get_table_names()) + additions = { + "accounts": [("email", "VARCHAR(320)")], + "projects": [ + ("organization_id", "VARCHAR(36)"), + ("created_by_id", "VARCHAR(36)"), + ], + } + with engine.begin() as connection: + for table_name, columns in additions.items(): + if table_name not in tables: + continue + existing = {column["name"] for column in inspector.get_columns(table_name)} + for column_name, column_type in columns: + if column_name not in existing: + connection.execute( + text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}") + ) + if table_name == "projects" and "organization_id" not in existing: + connection.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_project_org_slug_idx " + "ON projects (organization_id, slug)" + ) + ) + if table_name == "projects" and "created_by_id" not in existing: + connection.execute(text("UPDATE projects SET created_by_id = owner_id")) + if "projects" in tables: + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_projects_organization_id " + "ON projects (organization_id)" + ) + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_projects_created_by_id " + "ON projects (created_by_id)" + ) + ) + if "accounts" in tables: + connection.execute( + text("CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_email ON accounts (email)") + ) + if "group_members" in tables: + connection.execute( + text( + "UPDATE group_members SET role = 'manager' " + "WHERE role = 'owner' AND EXISTS (" + "SELECT 1 FROM groups WHERE groups.id = group_members.group_id " + "AND groups.owner_id <> group_members.account_id)" + ) + ) + connection.execute( + text( + "UPDATE group_members SET role = 'owner', status = 'accepted' " + "WHERE EXISTS (SELECT 1 FROM groups " + "WHERE groups.id = group_members.group_id " + "AND groups.owner_id = group_members.account_id)" + ) + ) + connection.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_group_accepted_owner " + "ON group_members (group_id) " + "WHERE role = 'owner' AND status = 'accepted'" + ) + ) + + inspector = inspect(engine) + if "projects" not in tables: + return + owner_column = next( + column for column in inspector.get_columns("projects") if column["name"] == "owner_id" + ) + owner_foreign_key = next( + ( + key + for key in inspector.get_foreign_keys("projects") + if key["constrained_columns"] == ["owner_id"] + ), + None, + ) + if ( + owner_column["nullable"] + and owner_foreign_key is not None + and (owner_foreign_key.get("options", {}).get("ondelete", "").upper() == "SET NULL") + ): + return + + # SQLite cannot alter nullability or an FK action. Keep child tables in + # place and rebuild only projects with legacy rename behavior enabled so + # versions/project_groups continue to reference the new `projects` table. + raw = engine.raw_connection() + try: + cursor = raw.cursor() + cursor.execute("PRAGMA foreign_keys=OFF") + cursor.execute("PRAGMA legacy_alter_table=ON") + cursor.execute("BEGIN") + cursor.execute("ALTER TABLE projects RENAME TO projects_legacy") + cursor.execute(""" + CREATE TABLE projects ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + owner_id VARCHAR(36) REFERENCES accounts(id) ON DELETE SET NULL, + created_by_id VARCHAR(36) REFERENCES accounts(id) ON DELETE SET NULL, + organization_id VARCHAR(36) REFERENCES organizations(id) ON DELETE SET NULL, + slug VARCHAR(100) NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + visibility VARCHAR(16) NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + thumbnail_type VARCHAR(20), + views INTEGER NOT NULL DEFAULT 0, + fork_count INTEGER NOT NULL DEFAULT 0, + featured BOOLEAN NOT NULL DEFAULT 0, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + CONSTRAINT uq_project_owner_slug UNIQUE (owner_id, slug), + CONSTRAINT uq_project_org_slug UNIQUE (organization_id, slug) + ) + """) + cursor.execute(""" + INSERT INTO projects ( + id, owner_id, created_by_id, organization_id, slug, title, + description, visibility, tags_json, thumbnail_type, views, + fork_count, featured, created_at, updated_at + ) + SELECT id, owner_id, COALESCE(created_by_id, owner_id), organization_id, + slug, title, description, visibility, tags_json, thumbnail_type, + views, fork_count, featured, created_at, updated_at + FROM projects_legacy + """) + cursor.execute("DROP TABLE projects_legacy") + cursor.execute("CREATE INDEX ix_projects_owner_id ON projects (owner_id)") + cursor.execute("CREATE INDEX ix_projects_created_by_id ON projects (created_by_id)") + cursor.execute("CREATE INDEX ix_projects_organization_id ON projects (organization_id)") + cursor.execute("CREATE INDEX ix_projects_updated_at ON projects (updated_at)") + raw.commit() + cursor.execute("PRAGMA foreign_keys=ON") + except Exception: + raw.rollback() + raise + finally: + raw.close() + + def create_app( database_url: str | None = None, storage=None, @@ -282,6 +786,15 @@ def _enable_foreign_keys(dbapi_connection, _record): # pragma: no cover - drive dbapi_connection.execute("PRAGMA foreign_keys=ON") Base.metadata.create_all(engine) + if database_url.startswith("sqlite"): + upgrade_sqlite_schema(engine) + elif engine.dialect.name == "postgresql": + upgrade_postgresql_schema(engine) + legacy_project_owner_required = database_url.startswith("sqlite") and not next( + column["nullable"] + for column in inspect(engine).get_columns("projects") + if column["name"] == "owner_id" + ) sessions = sessionmaker(engine, expire_on_commit=False) object_storage = storage or make_storage() base_url = (public_url or os.getenv("GEOLIBRE_PUBLIC_URL", "http://localhost:8000")).rstrip("/") @@ -374,13 +887,20 @@ def optional_account( raise HTTPException(401, "invalid or expired token") return session.get(Account, row.account_id) - def required_account(account: Account | None = Depends(optional_account)) -> Account: + def required_account( + account: Account | None = Depends(optional_account), + ) -> Account: if account is None: raise HTTPException(401, "authentication required") return account def account_json(account: Account) -> dict: - return {"id": account.id, "username": account.username, "createdAt": account.created_at} + return { + "id": account.id, + "username": account.username, + "email": account.email, + "createdAt": account.created_at, + } def issue_token(session: Session, account: Account) -> str: value = secrets.token_urlsafe(32) @@ -388,32 +908,272 @@ def issue_token(session: Session, account: Account) -> str: session.commit() return value - def unique_slug(session: Session, owner_id: str, desired: str) -> str: + def unique_slug( + session: Session, + owner_id: str | None, + desired: str, + organization_id: str | None = None, + ) -> str: base = slugify(desired) candidate = base suffix = 2 - while session.scalar( - select(Project.id).where(Project.owner_id == owner_id, Project.slug == candidate) - ): - tail = f"-{suffix}" - candidate = base[: 100 - len(tail)].rstrip("-") + tail - suffix += 1 + if organization_id: + while session.scalar( + select(Project.id).where( + Project.slug == candidate, + or_( + Project.organization_id == organization_id, + *([Project.owner_id == owner_id] if legacy_project_owner_required else []), + ), + ) + ): + tail = f"-{suffix}" + candidate = base[: 100 - len(tail)].rstrip("-") + tail + suffix += 1 + else: + while session.scalar( + select(Project.id).where(Project.owner_id == owner_id, Project.slug == candidate) + ): + tail = f"-{suffix}" + candidate = base[: 100 - len(tail)].rstrip("-") + tail + suffix += 1 return candidate - def project_json(project: Project) -> dict: - username = project.owner.username or "" - raw = f"{base_url}/{quote(username)}/{quote(project.slug)}.geolibre.json" - page = f"{base_url}/{quote(username)}/{quote(project.slug)}" + def organization_role(session: Session, organization_id: str, account_id: str) -> str | None: + return session.scalar( + select(OrganizationMember.role).where( + OrganizationMember.organization_id == organization_id, + OrganizationMember.account_id == account_id, + ) + ) + + def require_organization_admin( + session: Session, organization_id: str, account: Account + ) -> Organization: + organization = session.get(Organization, organization_id) + if organization is None: + raise HTTPException(404, "organization not found") + if organization_role(session, organization_id, account.id) != "administrator": + raise HTTPException(403, "organization administrator permission required") + return organization + + def group_membership(session: Session, group_id: str, account_id: str) -> GroupMember | None: + member = session.get(GroupMember, (group_id, account_id)) + return member if member is not None and member.status == "accepted" else None + + def require_group(session: Session, group_id: str) -> Group: + group = session.get(Group, group_id) + if group is None: + raise HTTPException(404, "group not found") + return group + + def require_group_manager(session: Session, group_id: str, account: Account) -> Group: + group = require_group(session, group_id) + membership = group_membership(session, group_id, account.id) + if membership is None or membership.role not in {"owner", "manager"}: + raise HTTPException(403, "group manager permission required") + return group + + def require_group_owner(session: Session, group_id: str, account: Account) -> Group: + group = require_group(session, group_id) + membership = group_membership(session, group_id, account.id) + if membership is None or membership.role != "owner": + raise HTTPException(403, "group owner permission required") + return group + + def shared_group_ids(session: Session, project_id: str) -> list[str]: + return list( + session.scalars( + select(ProjectGroup.group_id).where(ProjectGroup.project_id == project_id) + ) + ) + + def can_publish_public( + session: Session, organization: Organization | None, account: Account + ) -> bool: + if organization is None: + return True + role = organization_role(session, organization.id, account.id) + if role == "administrator": + return True + return organization.public_sharing_policy == "yes" or ( + organization.public_sharing_policy == "publishers" and role == "publisher" + ) + + def validate_access_targets( + session: Session, + account: Account, + visibility: str, + organization_id: str | None, + group_ids: list[str], + ) -> Organization | None: + organization = session.get(Organization, organization_id) if organization_id else None + if organization_id and organization is None: + raise HTTPException(404, "organization not found") + role = organization_role(session, organization_id, account.id) if organization_id else None + if organization is not None and role not in { + "administrator", + "publisher", + "member", + }: + raise HTTPException(403, "organization publishing permission required") + if visibility == "organization" and organization is None: + raise HTTPException(422, "organization visibility requires an organization") + if visibility == "public" and not can_publish_public(session, organization, account): + raise HTTPException(403, "your organization does not allow public sharing") + if len(set(group_ids)) != len(group_ids): + raise HTTPException(422, "groupIds must not contain duplicates") + for group_id in group_ids: + if session.get(Group, group_id) is None: + raise HTTPException(404, "group not found") + if group_membership(session, group_id, account.id) is None: + raise HTTPException(403, "group membership required") + return organization + + def organization_json(organization: Organization, role: str | None = None) -> dict: + value = { + "id": organization.id, + "slug": organization.slug, + "name": organization.name, + "publicSharingPolicy": organization.public_sharing_policy, + "defaultVisibility": organization.default_visibility, + "categories": json.loads(organization.categories_json), + } + if role is not None: + value["role"] = role + return value + + def group_json(group: Group, membership: GroupMember | None = None) -> dict: return { + "id": group.id, + "name": group.name, + "description": group.description, + "organizationId": group.organization_id, + "ownerId": group.owner_id, + "joinPolicy": group.join_policy, + "sharedUpdate": group.shared_update, + "thumbnailUrl": (f"/api/groups/{group.id}/thumbnail" if group.thumbnail_type else None), + "role": membership.role if membership else None, + "membershipStatus": membership.status if membership else None, + "createdAt": group.created_at, + } + + def member_json(member: OrganizationMember | GroupMember) -> dict: + return { + "id": member.account.id, + "username": member.account.username, + "role": member.role, + "status": getattr(member, "status", "accepted"), + "createdAt": member.created_at, + } + + def invitation_target(username: str | None, email: str | None) -> tuple[str | None, str | None]: + username = username.strip().lower() if username else None + email = email.strip().lower() if email else None + if bool(username) == bool(email): + raise HTTPException(422, "provide exactly one of username or email") + return username, email + + def invitation_account( + session: Session, username: str | None, email: str | None + ) -> Account | None: + if username: + return session.scalar(select(Account).where(Account.username == username)) + return session.scalar(select(Account).where(func.lower(Account.email) == email)) + + def invitation_belongs_to( + invitation: OrganizationInvitation | GroupInvitation, account: Account + ) -> bool: + return bool( + (invitation.username and invitation.username == account.username) + or ( + invitation.email + and account.email + and invitation.email == account.email.strip().lower() + ) + ) + + def invitation_json( + invitation: OrganizationInvitation | GroupInvitation, + raw_token: str | None = None, + ) -> dict: + value = { + "id": invitation.id, + "username": invitation.username, + "email": invitation.email, + "role": invitation.role, + "status": invitation.status, + "createdAt": invitation.created_at, + "acceptedAt": invitation.accepted_at, + "revokedAt": invitation.revoked_at, + } + if isinstance(invitation, OrganizationInvitation): + value["organizationId"] = invitation.organization_id + else: + value["groupId"] = invitation.group_id + if raw_token is not None: + value["token"] = raw_token + return value + + def replace_group_shares(session: Session, project: Project, group_ids: list[str]) -> None: + session.execute(delete(ProjectGroup).where(ProjectGroup.project_id == project.id)) + session.add_all( + ProjectGroup(project_id=project.id, group_id=group_id) for group_id in group_ids + ) + + def can_edit_project(session: Session, project: Project, account: Account | None) -> bool: + if account is None: + return False + if project.organization_id is None and project.owner_id == account.id: + return True + if project.organization_id: + role = organization_role(session, project.organization_id, account.id) + if role == "administrator" or ( + project.created_by_id == account.id and role in {"publisher", "member"} + ): + return True + for share in project.group_shares: + if share.group.shared_update and group_membership(session, share.group_id, account.id): + return True + return False + + def project_json( + project: Project, + session: Session | None = None, + account: Account | None = None, + ) -> dict: + if project.organization is not None: + org_slug = project.organization.slug + raw = f"{base_url}/org/{quote(org_slug)}/{quote(project.slug)}.geolibre.json" + page = f"{base_url}/org/{quote(org_slug)}/{quote(project.slug)}" + else: + username = project.owner.username if project.owner and project.owner.username else "" + raw = f"{base_url}/{quote(username)}/{quote(project.slug)}.geolibre.json" + page = f"{base_url}/{quote(username)}/{quote(project.slug)}" + value = { "id": project.id, - "username": username, + "username": ( + project.owner.username + if project.organization_id is None and project.owner + else None + ), "slug": project.slug, "title": project.title, "description": project.description, "visibility": project.visibility, - "thumbnailUrl": f"/api/projects/{project.id}/thumbnail" - if project.thumbnail_type - else None, + "organization": ( + { + "id": project.organization.id, + "slug": project.organization.slug, + "name": project.organization.name, + } + if project.organization + else None + ), + "groupIds": [share.group_id for share in project.group_shares], + "thumbnailUrl": ( + f"/api/projects/{project.id}/thumbnail" if project.thumbnail_type else None + ), "views": project.views, "forkCount": project.fork_count, "versionCount": len(project.versions), @@ -425,20 +1185,66 @@ def project_json(project: Project) -> dict: "projectUrl": page, "viewerUrl": viewer_url + "?project=" + quote(raw, safe=""), } + if account is not None: + assert session is not None + value["canEdit"] = can_edit_project(session, project, account) + return value - def visible(project: Project | None, account: Account | None) -> Project: - if project is None or ( - project.visibility == "private" and (account is None or project.owner_id != account.id) + def visible(session: Session, project: Project | None, account: Account | None) -> Project: + if project is None: + raise HTTPException(404, "project not found") + if project.visibility in {"public", "unlisted"}: + return project + if ( + account is not None + and project.organization_id is None + and project.owner_id == account.id + ): + return project + if ( + account is not None + and project.visibility == "organization" + and project.organization_id + and organization_role(session, project.organization_id, account.id) ): + return project + if account is not None and project.organization_id: + role = organization_role(session, project.organization_id, account.id) + if role == "administrator" or ( + project.created_by_id == account.id and role in {"publisher", "member"} + ): + return project + if account is not None and any( + group_membership(session, group_id, account.id) + for group_id in shared_group_ids(session, project.id) + ): + return project + if project.visibility == "organization" or project.visibility == "private": raise HTTPException(404, "project not found") return project - def owned(project: Project | None, account: Account) -> Project: + def owned(session: Session, project: Project | None, account: Account) -> Project: if project is None: raise HTTPException(404, "project not found") - if project.owner_id != account.id: - raise HTTPException(403, "project ownership required") - return project + if project.organization_id is None and project.owner_id == account.id: + return project + if project.organization_id: + role = organization_role(session, project.organization_id, account.id) + if role == "administrator" or ( + project.created_by_id == account.id and role in {"publisher", "member"} + ): + return project + raise HTTPException(403, "project ownership required") + + def editable(project: Project | None, account: Account, session: Session) -> Project: + if project is None: + raise HTTPException(404, "project not found") + if can_edit_project(session, project, account): + return project + raise HTTPException(403, "project edit permission required") + + def protected(project: Project) -> bool: + return project.visibility in {"private", "organization"} def create_project( session: Session, @@ -446,23 +1252,34 @@ def create_project( content: str, filename: str, visibility: Visibility, + organization_id: str | None = None, + group_ids: list[str] | None = None, *, commit: bool = True, ) -> Project: - if not account.username: - raise HTTPException(400, "username required") + group_ids = group_ids or [] + validate_access_targets(session, account, visibility, organization_id, group_ids) document = parse_content(content, max_project_bytes) title = title_from(document, filename) timestamp = now() + + if not account.username: + raise HTTPException(400, "username required") + # unique_slug SELECTs and this INSERTs, so two concurrent creates with # the same title from one account can pick the same slug and the loser - # hits uq_project_owner_slug. Retry the allocation instead of surfacing - # that as a 500, matching how version numbers are allocated below. + # hits uq_project_owner_slug or uq_project_org_slug. Retry the allocation + # instead of surfacing that as a 500, matching how version numbers are + # allocated below. for _ in range(5): project = Project( id=str(uuid.uuid4()), - owner_id=account.id, - slug=unique_slug(session, account.id, title or filename), + owner_id=( + account.id if organization_id is None or legacy_project_owner_required else None + ), + created_by_id=account.id, + organization_id=organization_id, + slug=unique_slug(session, account.id, title or filename, organization_id), title=title, description="", visibility=visibility, @@ -487,23 +1304,30 @@ def create_project( key = f"projects/{project.id}/versions/1.json" object_storage.put(key, content.encode(), "application/json") session.add(Version(project_id=project.id, number=1, object_key=key, created_at=timestamp)) + session.add_all( + ProjectGroup(project_id=project.id, group_id=group_id) for group_id in group_ids + ) if commit: session.commit() session.refresh(project) return project @app.post("/api/accounts", status_code=201) - def create_account(body: Credentials, session: Session = Depends(db)): + def create_account(body: AccountCreate, session: Session = Depends(db)): username = body.username.strip() + email = normalize_email(body.email) if not USERNAME_RE.fullmatch(username): raise HTTPException(422, "username must be 3-39 lowercase letters, digits, or hyphens") if len(body.password) < 8: raise HTTPException(422, "password must be at least 8 characters") if session.scalar(select(Account.id).where(Account.username == username)): raise HTTPException(409, "username already exists") + if email and session.scalar(select(Account.id).where(Account.email == email)): + raise HTTPException(409, "email already exists") account = Account( id=str(uuid.uuid4()), username=username, + email=email, password_hash=password_hash(body.password), created_at=now(), ) @@ -517,8 +1341,11 @@ def create_account(body: Credentials, session: Session = Depends(db)): # registered), contradicting the documented 409 for a uniqueness # conflict. session.rollback() - raise HTTPException(409, "username already exists") from None - return {"account": account_json(account), "token": issue_token(session, account)} + raise HTTPException(409, "username or email already exists") from None + return { + "account": account_json(account), + "token": issue_token(session, account), + } @app.post("/api/auth/token") def login(body: Credentials, session: Session = Depends(db)): @@ -532,7 +1359,10 @@ def login(body: Credentials, session: Session = Depends(db)): raise HTTPException(401, "invalid username or password") if not password_matches(body.password, account.password_hash): raise HTTPException(401, "invalid username or password") - return {"account": account_json(account), "token": issue_token(session, account)} + return { + "account": account_json(account), + "token": issue_token(session, account), + } @app.delete("/api/auth/token", status_code=204) def revoke( @@ -545,18 +1375,842 @@ def revoke( session.commit() @app.get("/api/account") - def get_account(account: Account = Depends(required_account)): + def get_account(response: Response, account: Account = Depends(required_account)): + response.headers["Cache-Control"] = "private, no-store" + return {"account": account_json(account)} + + @app.patch("/api/account") + def patch_account( + body: AccountPatch, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + email = normalize_email(body.email) + if email and session.scalar( + select(Account.id).where(Account.email == email, Account.id != account.id) + ): + raise HTTPException(409, "email already exists") + account.email = email + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(409, "email already exists") from None + response.headers["Cache-Control"] = "private, no-store" return {"account": account_json(account)} @app.get("/api/users/me") - def get_current_user(account: Account = Depends(required_account)): + def get_current_user(response: Response, account: Account = Depends(required_account)): # The full account shape, matching what docs/server-api.md publishes and # what /api/account returns. The gallery client reads only `username`. + response.headers["Cache-Control"] = "private, no-store" return {"user": account_json(account)} + @app.post("/api/organizations", status_code=201) + def create_organization( + body: OrganizationCreate, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + slug = body.slug.strip().lower() + if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{1,98}[a-z0-9])", slug): + raise HTTPException( + 422, + "organization slug must be 3-100 lowercase letters, digits, or hyphens", + ) + if session.scalar(select(Organization.id).where(Organization.slug == slug)): + raise HTTPException(409, "organization slug already exists") + organization = Organization( + id=str(uuid.uuid4()), + slug=slug, + name=body.name.strip(), + public_sharing_policy=body.public_sharing_policy, + default_visibility=body.default_visibility, + categories_json=json.dumps(body.categories), + created_at=now(), + ) + session.add(organization) + session.add( + OrganizationMember( + organization_id=organization.id, + account_id=account.id, + role="administrator", + created_at=now(), + ) + ) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(409, "organization slug already exists") from None + return {"organization": organization_json(organization, "administrator")} + + @app.get("/api/organizations/mine") + def list_my_organizations( + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + rows = session.execute( + select(Organization, OrganizationMember.role) + .join(OrganizationMember) + .where(OrganizationMember.account_id == account.id) + .order_by(Organization.name) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"organizations": [organization_json(org, role) for org, role in rows]} + + @app.get("/api/organizations/{organization_id}") + def get_organization( + organization_id: str, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + organization = session.get(Organization, organization_id) + if organization is None: + raise HTTPException(404, "organization not found") + role = organization_role(session, organization_id, account.id) + if role is None: + raise HTTPException(404, "organization not found") + response.headers["Cache-Control"] = "private, no-store" + return {"organization": organization_json(organization, role)} + + @app.patch("/api/organizations/{organization_id}") + def patch_organization( + organization_id: str, + body: OrganizationSettingsPatch, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + organization = require_organization_admin(session, organization_id, account) + updates = body.model_dump(exclude_unset=True) + if updates.get("name") is not None: + organization.name = updates["name"].strip() + if updates.get("public_sharing_policy") is not None: + organization.public_sharing_policy = updates["public_sharing_policy"] + if updates.get("default_visibility") is not None: + organization.default_visibility = updates["default_visibility"] + if updates.get("categories") is not None: + organization.categories_json = json.dumps(updates["categories"]) + session.commit() + return {"organization": organization_json(organization, "administrator")} + + @app.get("/api/organizations/{organization_id}/members") + def list_organization_members( + organization_id: str, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + if organization_role(session, organization_id, account.id) is None: + if session.get(Organization, organization_id) is None: + raise HTTPException(404, "organization not found") + raise HTTPException(403, "organization membership required") + members = session.scalars( + select(OrganizationMember) + .where(OrganizationMember.organization_id == organization_id) + .options(selectinload(OrganizationMember.account)) + .order_by(OrganizationMember.created_at) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"members": [member_json(member) for member in members]} + + @app.put("/api/organizations/{organization_id}/members") + def put_organization_member( + organization_id: str, + body: OrganizationMemberChange, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_organization_admin(session, organization_id, account) + target = session.scalar(select(Account).where(Account.username == body.username)) + if target is None: + raise HTTPException(404, "user not found") + member = session.get(OrganizationMember, (organization_id, target.id)) + if member is None: + member = OrganizationMember( + organization_id=organization_id, + account_id=target.id, + role=body.role, + created_at=now(), + ) + session.add(member) + else: + if member.role == "administrator" and body.role != "administrator": + admin_count = session.scalar( + select(func.count()) + .select_from(OrganizationMember) + .where( + OrganizationMember.organization_id == organization_id, + OrganizationMember.role == "administrator", + ) + ) + if admin_count == 1: + raise HTTPException(409, "organization must have an administrator") + member.role = body.role + invitation_target_predicate = OrganizationInvitation.username == target.username + if target.email: + invitation_target_predicate = or_( + invitation_target_predicate, + OrganizationInvitation.email == target.email.strip().lower(), + ) + session.execute( + update(OrganizationInvitation) + .where( + OrganizationInvitation.organization_id == organization_id, + OrganizationInvitation.status == "pending", + invitation_target_predicate, + ) + .values(status="revoked", revoked_at=now()) + ) + session.commit() + member.account = target + return {"member": member_json(member)} + + @app.delete("/api/organizations/{organization_id}/members/{username}", status_code=204) + def delete_organization_member( + organization_id: str, + username: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_organization_admin(session, organization_id, account) + target = session.scalar(select(Account).where(Account.username == username)) + member = session.get(OrganizationMember, (organization_id, target.id)) if target else None + if member is None: + raise HTTPException(404, "organization member not found") + if member.role == "administrator": + admin_count = session.scalar( + select(func.count()) + .select_from(OrganizationMember) + .where( + OrganizationMember.organization_id == organization_id, + OrganizationMember.role == "administrator", + ) + ) + if admin_count == 1: + raise HTTPException(409, "organization must have an administrator") + session.delete(member) + session.commit() + + @app.post("/api/organizations/{organization_id}/invitations", status_code=201) + def create_organization_invitation( + organization_id: str, + body: OrganizationInvitationCreate, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_organization_admin(session, organization_id, account) + username, email = invitation_target(body.username, body.email) + target = invitation_account(session, username, email) + if username and target is None: + raise HTTPException(404, "user not found") + if target and session.get(OrganizationMember, (organization_id, target.id)): + raise HTTPException(409, "user is already an organization member") + target_predicate = ( + OrganizationInvitation.username == username + if username + else OrganizationInvitation.email == email + ) + if session.scalar( + select(OrganizationInvitation.id).where( + OrganizationInvitation.organization_id == organization_id, + OrganizationInvitation.status == "pending", + target_predicate, + ) + ): + raise HTTPException(409, "invitation already pending") + raw_token = secrets.token_urlsafe(32) + invitation = OrganizationInvitation( + id=str(uuid.uuid4()), + organization_id=organization_id, + invited_by_id=account.id, + username=username, + email=email, + role=body.role, + status="pending", + token_digest=token_digest(raw_token), + created_at=now(), + ) + session.add(invitation) + session.commit() + return {"invitation": invitation_json(invitation, raw_token)} + + @app.get("/api/organizations/{organization_id}/invitations") + def list_organization_invitations( + organization_id: str, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_organization_admin(session, organization_id, account) + invitations = session.scalars( + select(OrganizationInvitation) + .where(OrganizationInvitation.organization_id == organization_id) + .order_by(OrganizationInvitation.created_at) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"invitations": [invitation_json(item) for item in invitations]} + + @app.delete( + "/api/organizations/{organization_id}/invitations/{invitation_id}", + status_code=204, + ) + def revoke_organization_invitation( + organization_id: str, + invitation_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_organization_admin(session, organization_id, account) + invitation = session.get(OrganizationInvitation, invitation_id) + if ( + invitation is None + or invitation.organization_id != organization_id + or invitation.status != "pending" + ): + raise HTTPException(404, "invitation not found") + invitation.status = "revoked" + invitation.revoked_at = now() + session.commit() + + @app.post("/api/organizations/invitations/{token}/accept", status_code=204) + def accept_organization_invitation( + token: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + invitation = session.scalar( + select(OrganizationInvitation).where( + OrganizationInvitation.token_digest == token_digest(token), + OrganizationInvitation.status == "pending", + ) + ) + if invitation is None: + raise HTTPException(404, "invitation not found") + if not invitation_belongs_to(invitation, account): + raise HTTPException(403, "invitation belongs to another user") + if session.get(OrganizationMember, (invitation.organization_id, account.id)): + raise HTTPException(409, "user is already an organization member") + session.add( + OrganizationMember( + organization_id=invitation.organization_id, + account_id=account.id, + role=invitation.role, + created_at=now(), + ) + ) + invitation.status = "accepted" + invitation.accepted_at = now() + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(409, "user is already an organization member") from None + + @app.get("/api/organizations/{organization_id}/projects") + def list_organization_projects( + organization_id: str, + response: Response, + limit: Annotated[int, Query(ge=1, le=100)] = 24, + offset: Annotated[int, Query(ge=0)] = 0, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + role = organization_role(session, organization_id, account.id) + if role is None: + if session.get(Organization, organization_id) is None: + raise HTTPException(404, "organization not found") + raise HTTPException(403, "organization membership required") + query = select(Project).where(Project.organization_id == organization_id) + if role != "administrator": + group_ids = ( + select(ProjectGroup.project_id) + .join(GroupMember, GroupMember.group_id == ProjectGroup.group_id) + .where( + GroupMember.account_id == account.id, + GroupMember.status == "accepted", + ) + ) + query = query.where( + or_( + Project.visibility.in_(["public", "organization"]), + Project.id.in_(group_ids), + ( + (Project.created_by_id == account.id) + & (role in {"publisher", "member"} if role is not None else False) + ), + ) + ) + projects = session.scalars( + query.options(*LISTING_EAGER_LOADS) + .order_by(Project.updated_at.desc()) + .offset(offset) + .limit(limit) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"projects": [project_json(project, session, account) for project in projects]} + + @app.post("/api/groups", status_code=201) + def create_group( + body: GroupCreate, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + if body.organization_id: + role = organization_role(session, body.organization_id, account.id) + if session.get(Organization, body.organization_id) is None: + raise HTTPException(404, "organization not found") + if role not in {"administrator", "publisher", "member"}: + raise HTTPException(403, "organization membership required") + group = Group( + id=str(uuid.uuid4()), + organization_id=body.organization_id, + owner_id=account.id, + name=body.name.strip(), + description=body.description, + join_policy=body.join_policy, + shared_update=body.shared_update, + created_at=now(), + ) + session.add(group) + member = GroupMember( + group_id=group.id, + account_id=account.id, + role="owner", + status="accepted", + created_at=now(), + ) + session.add(member) + session.commit() + return {"group": group_json(group, member)} + + @app.get("/api/groups/mine") + def list_my_groups( + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + rows = session.execute( + select(Group, GroupMember) + .join(GroupMember) + .where(GroupMember.account_id == account.id, GroupMember.status == "accepted") + .order_by(Group.name) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"groups": [group_json(group, member) for group, member in rows]} + + @app.get("/api/groups/{group_id}") + def get_group( + group_id: str, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group(session, group_id) + response.headers["Cache-Control"] = "private, no-store" + return {"group": group_json(group, group_membership(session, group_id, account.id))} + + @app.patch("/api/groups/{group_id}") + def patch_group( + group_id: str, + body: GroupSettingsPatch, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group_manager(session, group_id, account) + updates = body.model_dump(exclude_unset=True) + if updates.get("name") is not None: + group.name = updates["name"].strip() + if updates.get("description") is not None: + group.description = updates["description"] + if updates.get("join_policy") is not None: + group.join_policy = updates["join_policy"] + session.commit() + return {"group": group_json(group, group_membership(session, group_id, account.id))} + + @app.get("/api/groups/{group_id}/members") + def list_group_members( + group_id: str, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + if group_membership(session, group_id, account.id) is None: + require_group(session, group_id) + raise HTTPException(403, "group membership required") + membership = group_membership(session, group_id, account.id) + query = select(GroupMember).where(GroupMember.group_id == group_id) + if membership is None or membership.role not in {"owner", "manager"}: + query = query.where(GroupMember.status == "accepted") + members = session.scalars( + query.options(selectinload(GroupMember.account)).order_by(GroupMember.created_at) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"members": [member_json(member) for member in members]} + + @app.put("/api/groups/{group_id}/members") + def put_group_member( + group_id: str, + body: GroupMemberChange, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group_manager(session, group_id, account) + actor = group_membership(session, group_id, account.id) + target = session.scalar(select(Account).where(Account.username == body.username)) + if target is None: + raise HTTPException(404, "user not found") + member = session.get(GroupMember, (group_id, target.id)) + if target.id == group.owner_id and body.role != "owner": + raise HTTPException(409, "transfer group ownership before changing the owner role") + if body.role == "owner": + if actor is None or actor.role != "owner": + raise HTTPException(403, "only the group owner can transfer ownership") + # PostgreSQL serializes competing transfers on the group row. SQLite + # ignores FOR UPDATE but serializes writers and the partial unique + # index below still enforces the final invariant. + group = session.scalar(select(Group).where(Group.id == group_id).with_for_update()) + assert group is not None + session.execute( + update(GroupMember) + .where( + GroupMember.group_id == group_id, + GroupMember.role == "owner", + GroupMember.account_id != target.id, + ) + .values(role="manager") + ) + group.owner_id = target.id + elif actor is None or ( + actor.role != "owner" + and (body.role == "manager" or (member and member.role in {"owner", "manager"})) + ): + raise HTTPException(403, "only the group owner can manage managers") + if member is None: + member = GroupMember( + group_id=group_id, + account_id=target.id, + role=body.role, + status="accepted", + created_at=now(), + ) + session.add(member) + else: + member.role = body.role + member.status = "accepted" + invitation_target_predicate = GroupInvitation.username == target.username + if target.email: + invitation_target_predicate = or_( + invitation_target_predicate, + GroupInvitation.email == target.email.strip().lower(), + ) + session.execute( + update(GroupInvitation) + .where( + GroupInvitation.group_id == group_id, + GroupInvitation.status == "pending", + invitation_target_predicate, + ) + .values(status="revoked", revoked_at=now()) + ) + session.commit() + member.account = target + return {"member": member_json(member)} + + @app.delete("/api/groups/{group_id}/members/{username}", status_code=204) + def delete_group_member( + group_id: str, + username: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_group(session, group_id) + actor = group_membership(session, group_id, account.id) + leaving = username == "me" + if leaving: + target = account + else: + if actor is None or actor.role not in {"owner", "manager"}: + raise HTTPException(403, "group manager permission required") + target = session.scalar(select(Account).where(Account.username == username)) + member = session.get(GroupMember, (group_id, target.id)) if target else None + if member is None: + raise HTTPException(404, "group member not found") + if member.role == "owner": + raise HTTPException(409, "transfer group ownership before removing the owner") + if not leaving and (actor is None or (actor.role != "owner" and member.role == "manager")): + raise HTTPException(403, "only the group owner can remove a manager") + session.delete(member) + session.commit() + + @app.post("/api/groups/{group_id}/invitations", status_code=201) + def create_group_invitation( + group_id: str, + body: GroupInvitationCreate, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group_manager(session, group_id, account) + actor = group_membership(session, group_id, account.id) + username, email = invitation_target(body.username, body.email) + if body.role == "owner": + raise HTTPException(422, "ownership must be transferred through the member route") + if body.role == "manager" and actor and actor.role != "owner": + raise HTTPException(403, "only the group owner can invite a manager") + target = invitation_account(session, username, email) + if username and target is None: + raise HTTPException(404, "user not found") + if target and group_membership(session, group_id, target.id): + raise HTTPException(409, "user is already a group member") + target_predicate = ( + GroupInvitation.username == username if username else GroupInvitation.email == email + ) + existing = session.scalar( + select(GroupInvitation.id).where( + GroupInvitation.group_id == group_id, + GroupInvitation.status == "pending", + target_predicate, + ) + ) + if existing: + raise HTTPException(409, "invitation already pending") + raw_token = secrets.token_urlsafe(32) + invitation = GroupInvitation( + id=str(uuid.uuid4()), + group_id=group.id, + invited_by_id=account.id, + username=username, + email=email, + role=body.role, + status="pending", + token_digest=token_digest(raw_token), + created_at=now(), + ) + session.add(invitation) + session.commit() + return {"invitation": invitation_json(invitation, raw_token)} + + @app.get("/api/groups/{group_id}/invitations") + def list_group_invitations( + group_id: str, + response: Response, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_group_manager(session, group_id, account) + invitations = session.scalars( + select(GroupInvitation) + .where(GroupInvitation.group_id == group_id) + .order_by(GroupInvitation.created_at) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"invitations": [invitation_json(item) for item in invitations]} + + @app.delete("/api/groups/{group_id}/invitations/{invitation_id}", status_code=204) + def revoke_group_invitation( + group_id: str, + invitation_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_group_manager(session, group_id, account) + invitation = session.get(GroupInvitation, invitation_id) + if invitation is None or invitation.group_id != group_id or invitation.status != "pending": + raise HTTPException(404, "invitation not found") + invitation.status = "revoked" + invitation.revoked_at = now() + session.commit() + + @app.post("/api/groups/invitations/{token}/accept", status_code=204) + def accept_group_invitation( + token: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + invitation = session.scalar( + select(GroupInvitation).where( + GroupInvitation.token_digest == token_digest(token), + GroupInvitation.status == "pending", + ) + ) + if invitation is None: + raise HTTPException(404, "invitation not found") + if not invitation_belongs_to(invitation, account): + raise HTTPException(403, "invitation belongs to another user") + member = session.get(GroupMember, (invitation.group_id, account.id)) + if member is not None and member.status == "accepted": + raise HTTPException(409, "user is already a group member") + if member is None: + session.add( + GroupMember( + group_id=invitation.group_id, + account_id=account.id, + role=invitation.role, + status="accepted", + created_at=now(), + ) + ) + else: + member.role = invitation.role + member.status = "accepted" + invitation.status = "accepted" + invitation.accepted_at = now() + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(409, "already a group member") from None + + @app.post("/api/groups/{group_id}/join", status_code=204) + def join_group( + group_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group(session, group_id) + existing = session.get(GroupMember, (group_id, account.id)) + if existing and existing.status == "accepted": + raise HTTPException(409, "already a group member") + if group.join_policy == "invite": + raise HTTPException(403, "group is invitation-only") + status = "accepted" if group.join_policy == "open" else "pending" + if existing is None: + session.add( + GroupMember( + group_id=group_id, + account_id=account.id, + role="member", + status=status, + created_at=now(), + ) + ) + elif existing.status == "pending": + raise HTTPException(409, "join request already pending") + else: + existing.status = status + existing.role = "member" + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(409, "already a group member") from None + + @app.post("/api/groups/{group_id}/members/{username}/decide", status_code=204) + def decide_group_join_request( + group_id: str, + username: str, + body: JoinRequestDecision, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_group_manager(session, group_id, account) + target = session.scalar(select(Account).where(Account.username == username)) + member = session.get(GroupMember, (group_id, target.id)) if target else None + if member is None or member.status != "pending": + raise HTTPException(404, "join request not found") + if body.decision == "accept": + member.status = "accepted" + else: + session.delete(member) + session.commit() + + @app.get("/api/groups/{group_id}/projects") + def list_group_projects( + group_id: str, + response: Response, + limit: Annotated[int, Query(ge=1, le=100)] = 24, + offset: Annotated[int, Query(ge=0)] = 0, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + if group_membership(session, group_id, account.id) is None: + require_group(session, group_id) + raise HTTPException(403, "group membership required") + projects = session.scalars( + select(Project) + .join(ProjectGroup) + .where(ProjectGroup.group_id == group_id) + .options(*LISTING_EAGER_LOADS) + .order_by(Project.updated_at.desc()) + .limit(limit) + .offset(offset) + ).all() + response.headers["Cache-Control"] = "private, no-store" + return {"projects": [project_json(project, session, account) for project in projects]} + + @app.delete("/api/groups/{group_id}/projects/{project_id}", status_code=204) + def moderate_group_project( + group_id: str, + project_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + require_group_manager(session, group_id, account) + share = session.get(ProjectGroup, (project_id, group_id)) + if share is None: + raise HTTPException(404, "group project not found") + session.delete(share) + session.commit() + + @app.put("/api/groups/{group_id}/thumbnail", status_code=204) + async def put_group_thumbnail( + group_id: str, + request: Request, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group_manager(session, group_id, account) + content_type = request.headers.get("content-type", "").split(";")[0] + if content_type not in IMAGE_TYPES: + raise HTTPException(422, "thumbnail must be PNG, JPEG, or WebP") + data = bytearray() + async for chunk in request.stream(): + data.extend(chunk) + if len(data) > max_thumbnail_bytes: + raise HTTPException(413, f"thumbnail exceeds the {max_thumbnail_bytes} byte limit") + object_storage.put(f"groups/{group.id}/thumbnail", bytes(data), content_type) + group.thumbnail_type = content_type + session.commit() + + @app.get("/api/groups/{group_id}/thumbnail") + def get_group_thumbnail( + group_id: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + group = require_group(session, group_id) + confined = group.join_policy != "open" + if confined and ( + account is None or group_membership(session, group_id, account.id) is None + ): + raise HTTPException(404, "thumbnail not found") + if not group.thumbnail_type: + raise HTTPException(404, "thumbnail not found") + try: + data = object_storage.get(f"groups/{group.id}/thumbnail") + except KeyError: + raise HTTPException(404, "thumbnail not found") + cache = "private, no-store" if confined else "public, max-age=3600" + return Response(data, media_type=group.thumbnail_type, headers={"Cache-Control": cache}) + + @app.delete("/api/groups/{group_id}/thumbnail", status_code=204) + def delete_group_thumbnail( + group_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + group = require_group_manager(session, group_id, account) + object_storage.delete(f"groups/{group.id}/thumbnail") + group.thumbnail_type = None + session.commit() + @app.get("/api/users/{username}/projects") def get_user_projects( username: str, + response: Response, limit: Annotated[int, Query(ge=1, le=100)] = 24, offset: Annotated[int, Query(ge=0)] = 0, account: Account | None = Depends(optional_account), @@ -566,7 +2220,9 @@ def get_user_projects( if owner is None: raise HTTPException(404, "user not found") own = account is not None and account.id == owner.id - query = select(Project).where(Project.owner_id == owner.id) + query = select(Project).where( + Project.owner_id == owner.id, Project.organization_id.is_(None) + ) if not own: query = query.where(Project.visibility == "public") projects = session.scalars( @@ -575,7 +2231,9 @@ def get_user_projects( .offset(offset) .limit(limit) ).all() - return {"projects": [project_json(project) for project in projects]} + if account is not None: + response.headers["Cache-Control"] = "private, no-store" + return {"projects": [project_json(project, session, account) for project in projects]} @app.post("/api/projects", status_code=201) def post_project( @@ -585,28 +2243,84 @@ def post_project( ): return { "project": project_json( - create_project(session, account, body.content, body.filename, body.visibility) + create_project( + session, + account, + body.content, + body.filename, + body.visibility, + body.organization_id, + body.group_ids, + ), + session, + account, ) } @app.get("/api/projects") def list_projects( + response: Response, limit: Annotated[int, Query(ge=1, le=100)] = 24, offset: Annotated[int, Query(ge=0)] = 0, featured: bool = False, mine: bool = False, + shared_with_me: bool = False, + shared_source: Literal["organizations", "groups"] | None = None, account: Account | None = Depends(optional_account), session: Session = Depends(db), ): query = select(Project) count = select(func.count()).select_from(Project) + if mine and shared_with_me: + raise HTTPException(422, "mine and shared_with_me cannot both be true") + if shared_source and not shared_with_me: + raise HTTPException(422, "shared_source requires shared_with_me=true") if mine: if account is None: raise HTTPException(401, "authentication required") query, count = ( - query.where(Project.owner_id == account.id), - count.where(Project.owner_id == account.id), + query.where(Project.owner_id == account.id, Project.organization_id.is_(None)), + count.where(Project.owner_id == account.id, Project.organization_id.is_(None)), + ) + elif shared_with_me: + if account is None: + raise HTTPException(401, "authentication required") + organization_ids = select(OrganizationMember.organization_id).where( + OrganizationMember.account_id == account.id + ) + admin_organization_ids = select(OrganizationMember.organization_id).where( + OrganizationMember.account_id == account.id, + OrganizationMember.role == "administrator", + ) + active_creator_organization_ids = select(OrganizationMember.organization_id).where( + OrganizationMember.account_id == account.id, + OrganizationMember.role.in_(["administrator", "publisher", "member"]), ) + group_project_ids = ( + select(ProjectGroup.project_id) + .join(GroupMember, GroupMember.group_id == ProjectGroup.group_id) + .where( + GroupMember.account_id == account.id, + GroupMember.status == "accepted", + ) + ) + organization_condition = Project.organization_id.in_(organization_ids) & or_( + Project.visibility.in_(["public", "organization"]), + Project.organization_id.in_(admin_organization_ids), + ( + (Project.created_by_id == account.id) + & Project.organization_id.in_(active_creator_organization_ids) + ), + ) + group_condition = Project.id.in_(group_project_ids) + condition = ( + organization_condition + if shared_source == "organizations" + else group_condition + if shared_source == "groups" + else or_(organization_condition, group_condition) + ) + query, count = query.where(condition), count.where(condition) else: query, count = ( query.where(Project.visibility == "public"), @@ -623,8 +2337,10 @@ def list_projects( .offset(offset) .limit(limit) ).all() + if account is not None: + response.headers["Cache-Control"] = "private, no-store" return { - "projects": [project_json(p) for p in projects], + "projects": [project_json(p, session, account) for p in projects], "limit": limit, "offset": offset, "total": session.scalar(count), @@ -636,7 +2352,11 @@ def get_project( account: Account | None = Depends(optional_account), session: Session = Depends(db), ): - return {"project": project_json(visible(session.get(Project, project_id), account))} + project = visible(session, session.get(Project, project_id), account) + response = JSONResponse({"project": project_json(project, session, account)}) + if protected(project) or account is not None: + response.headers["Cache-Control"] = "private, no-store" + return response @app.patch("/api/projects/{project_id}") def patch_project( @@ -645,8 +2365,24 @@ def patch_project( account: Account = Depends(required_account), session: Session = Depends(db), ): - project = owned(session.get(Project, project_id), account) + project = owned(session, session.get(Project, project_id), account) updates = body.model_dump(exclude_unset=True) + final_visibility = updates.get("visibility", project.visibility) + final_organization_id = updates.get("organization_id", project.organization_id) + final_group_ids = updates.get("group_ids", shared_group_ids(session, project.id)) + if final_visibility is None: + raise HTTPException(422, "visibility must not be null") + # Existing targets may outlive the creator's membership. They must still + # be able to remove a stale target or edit unrelated metadata; only a + # submitted replacement target list requires current membership. + group_ids_to_validate = final_group_ids if "group_ids" in updates else [] + validate_access_targets( + session, + account, + final_visibility, + final_organization_id, + group_ids_to_validate or [], + ) if "title" in updates: if not updates["title"] or not updates["title"].strip(): raise HTTPException(422, "title must not be empty") @@ -661,14 +2397,30 @@ def patch_project( if updates["visibility"] is None: raise HTTPException(422, "visibility must not be null") project.visibility = updates["visibility"] + if "organization_id" in updates: + if updates["organization_id"] != project.organization_id: + project.slug = unique_slug( + session, account.id, project.slug, updates["organization_id"] + ) + project.organization_id = updates["organization_id"] + if not updates["organization_id"] or legacy_project_owner_required: + project.owner_id = account.id + else: + project.owner_id = None + if "group_ids" in updates: + replace_group_shares(session, project, updates["group_ids"] or []) if "tags" in updates: tags = updates["tags"] or [] if len(tags) > 20 or any(not tag or len(tag) > 40 for tag in tags): raise HTTPException(422, "tags must contain at most 20 non-empty 40-character tags") project.tags_json = json.dumps(tags) project.updated_at = now() - session.commit() - return {"project": project_json(project)} + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(409, "project slug already exists") from None + return {"project": project_json(project, session, account)} @app.put("/api/projects/{project_id}/content", status_code=201) def update_content( @@ -677,7 +2429,7 @@ def update_content( account: Account = Depends(required_account), session: Session = Depends(db), ): - project = owned(session.get(Project, project_id), account) + project = editable(session.get(Project, project_id), account, session) parse_content(body.content, max_project_bytes) # Allocated from max(number) and committed *before* the object is # written. Deriving it from len(project.versions) let two concurrent @@ -694,7 +2446,12 @@ def update_content( ) + 1 key = f"projects/{project.id}/versions/{number}.json" session.add( - Version(project_id=project.id, number=number, object_key=key, created_at=now()) + Version( + project_id=project.id, + number=number, + object_key=key, + created_at=now(), + ) ) try: session.flush() @@ -702,14 +2459,25 @@ def update_content( except (IntegrityError, OperationalError): # See create_project: a SQLite lock is transient and retryable. session.rollback() - project = owned(session.get(Project, project_id), account) + project = editable(session.get(Project, project_id), account, session) else: raise HTTPException(409, "could not allocate a version number; retry") + current_number = number - 1 + conflict = body.expected_version is not None and body.expected_version != current_number object_storage.put(key, body.content.encode(), "application/json") project.updated_at = now() session.commit() session.refresh(project) - return {"project": project_json(project), "version": number} + result = { + "project": project_json(project, session, account), + "version": number, + } + if conflict: + result["warning"] = ( + f"version conflict: expected {body.expected_version}, " + f"but version {current_number} was current; update saved as version {number}" + ) + return result @app.delete("/api/projects/{project_id}", status_code=204) def delete_project_route( @@ -717,7 +2485,7 @@ def delete_project_route( account: Account = Depends(required_account), session: Session = Depends(db), ): - project = owned(session.get(Project, project_id), account) + project = owned(session, session.get(Project, project_id), account) session.delete(project) session.commit() object_storage.delete_project(project_id) @@ -734,7 +2502,7 @@ def fork_project( account: Account = Depends(required_account), session: Session = Depends(db), ): - source = visible(session.get(Project, project_id), account) + source = visible(session, session.get(Project, project_id), account) content = object_storage.get(source.versions[-1].object_key).decode() fork = create_project( session, @@ -752,7 +2520,7 @@ def fork_project( ) session.commit() session.refresh(fork) - return {"project": project_json(fork)} + return {"project": project_json(fork, session, account)} def raw_response(project: Project, version: Version, immutable: bool) -> Response: try: @@ -761,13 +2529,35 @@ def raw_response(project: Project, version: Version, immutable: bool) -> Respons raise HTTPException(404, "project content not found") cache = ( "public, max-age=3600" - if immutable and project.visibility != "private" + if immutable and not protected(project) else "private, no-store" - if project.visibility == "private" + if protected(project) else "public, max-age=60" ) return Response(content, media_type="application/json", headers={"Cache-Control": cache}) + @app.get("/api/projects/{project_id}/versions") + def list_versions( + project_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + project = visible(session, session.get(Project, project_id), account) + body = { + "versions": [ + { + "number": version.number, + "createdAt": version.created_at, + "url": f"{base_url}/api/projects/{project.id}/versions/{version.number}", + } + for version in reversed(project.versions) + ] + } + response = JSONResponse(body) + if protected(project): + response.headers["Cache-Control"] = "private, no-store" + return response + @app.get("/api/projects/{project_id}/versions/{number}") def get_version( project_id: str, @@ -775,7 +2565,7 @@ def get_version( account: Account | None = Depends(optional_account), session: Session = Depends(db), ): - project = visible(session.get(Project, project_id), account) + project = visible(session, session.get(Project, project_id), account) version = session.get(Version, (project_id, number)) if version is None: raise HTTPException(404, "project version not found") @@ -788,7 +2578,7 @@ async def put_thumbnail( account: Account = Depends(required_account), session: Session = Depends(db), ): - project = owned(session.get(Project, project_id), account) + project = owned(session, session.get(Project, project_id), account) content_type = request.headers.get("content-type", "").split(";")[0] if content_type not in IMAGE_TYPES: raise HTTPException(422, "thumbnail must be PNG, JPEG, or WebP") @@ -815,14 +2605,14 @@ def get_thumbnail( account: Account | None = Depends(optional_account), session: Session = Depends(db), ): - project = visible(session.get(Project, project_id), account) + project = visible(session, session.get(Project, project_id), account) if not project.thumbnail_type: raise HTTPException(404, "thumbnail not found") try: data = object_storage.get(f"projects/{project.id}/thumbnail") except KeyError: raise HTTPException(404, "thumbnail not found") - cache = "private, no-store" if project.visibility == "private" else "public, max-age=3600" + cache = "private, no-store" if protected(project) else "public, max-age=3600" return Response(data, media_type=project.thumbnail_type, headers={"Cache-Control": cache}) @app.delete("/api/projects/{project_id}/thumbnail", status_code=204) @@ -831,12 +2621,48 @@ def delete_thumbnail( account: Account = Depends(required_account), session: Session = Depends(db), ): - project = owned(session.get(Project, project_id), account) + project = owned(session, session.get(Project, project_id), account) object_storage.delete(f"projects/{project.id}/thumbnail") project.thumbnail_type = None project.updated_at = now() session.commit() + @app.get("/org/{organization_slug}/{slug}.geolibre.json") + def latest_organization_raw( + organization_slug: str, + slug: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + project = session.scalar( + select(Project) + .join(Organization) + .where(Organization.slug == organization_slug, Project.slug == slug) + ) + project = visible(session, project, account) + body = raw_response(project, project.versions[-1], False) + session.execute( + update(Project).where(Project.id == project.id).values(views=Project.views + 1) + ) + session.commit() + return body + + @app.get("/org/{organization_slug}/{slug}") + def organization_project_page( + organization_slug: str, + slug: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + project = session.scalar( + select(Project) + .join(Organization) + .where(Organization.slug == organization_slug, Project.slug == slug) + ) + visible(session, project, account) + raw = f"{base_url}/org/{quote(organization_slug)}/{quote(slug)}.geolibre.json" + return RedirectResponse(viewer_url + "?project=" + quote(raw, safe=""), status_code=302) + @app.get("/{username}/{slug}.geolibre.json") def latest_raw( username: str, @@ -845,9 +2671,15 @@ def latest_raw( session: Session = Depends(db), ): project = session.scalar( - select(Project).join(Account).where(Account.username == username, Project.slug == slug) + select(Project) + .join(Account, Project.owner_id == Account.id) + .where( + Account.username == username, + Project.slug == slug, + Project.organization_id.is_(None), + ) ) - project = visible(project, account) + project = visible(session, project, account) # Read the object first: a missing object is a 404 that should not count # as a view. Incremented in SQL so concurrent reads do not lose counts. body = raw_response(project, project.versions[-1], False) @@ -865,9 +2697,15 @@ def project_page( session: Session = Depends(db), ): project = session.scalar( - select(Project).join(Account).where(Account.username == username, Project.slug == slug) + select(Project) + .join(Account, Project.owner_id == Account.id) + .where( + Account.username == username, + Project.slug == slug, + Project.organization_id.is_(None), + ) ) - project = visible(project, account) + project = visible(session, project, account) raw = f"{base_url}/{quote(username)}/{quote(slug)}.geolibre.json" return RedirectResponse(viewer_url + "?project=" + quote(raw, safe=""), status_code=302) diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py index 99e0b368f..732791749 100644 --- a/backend/geolibre_server_api/tests/test_api.py +++ b/backend/geolibre_server_api/tests/test_api.py @@ -1,9 +1,15 @@ import hashlib import json +import sqlite3 import pytest from fastapi.testclient import TestClient -from geolibre_server_api.main import FileStorage, create_app +from geolibre_server_api.main import ( + FileStorage, + create_app, + postgresql_upgrade_statements, +) +from sqlalchemy.exc import IntegrityError @pytest.fixture @@ -21,10 +27,11 @@ def client(tmp_path): yield test_client -def account(client, username="ada"): - response = client.post( - "/api/accounts", json={"username": username, "password": "correct horse"} - ) +def account(client, username="ada", email=None): + body = {"username": username, "password": "correct horse"} + if email is not None: + body["email"] = email + response = client.post("/api/accounts", json=body) assert response.status_code == 201 return response.json()["token"] @@ -48,6 +55,158 @@ def create_project(client, token, visibility="public", title="Wetlands"): return response.json()["project"], content +def create_member_organization_project(client): + admin = account(client, "admin") + member = account(client, "member") + organization = client.post( + "/api/organizations", + headers=auth(admin), + json={"slug": "creator-lab", "name": "Creator Lab"}, + ).json()["organization"] + assert ( + client.put( + f"/api/organizations/{organization['id']}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ).status_code + == 200 + ) + created = client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "member-map.json", + "content": '{"title":"Member map"}', + "visibility": "organization", + "organizationId": organization["id"], + }, + ) + assert created.status_code == 201, created.text + return admin, member, organization, created.json()["project"] + + +def test_member_creator_can_update_organization_project_but_viewer_cannot(client): + admin, member, organization, project = create_member_organization_project(client) + with client.app.state.engine.connect() as connection: + row = connection.exec_driver_sql( + "SELECT owner_id, created_by_id FROM projects WHERE id = ?", + (project["id"],), + ).one() + member_id = connection.exec_driver_sql( + "SELECT id FROM accounts WHERE username = 'member'" + ).scalar_one() + assert row.owner_id is None + assert row.created_by_id == member_id + assert project["username"] is None + individual, _ = create_project(client, member, "private", "Member map") + assert individual["slug"] == project["slug"] + organization_collision = client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "member-map.json", + "content": '{"title":"Member map"}', + "visibility": "organization", + "organizationId": organization["id"], + }, + ) + assert organization_collision.status_code == 201, organization_collision.text + assert organization_collision.json()["project"]["slug"] == "member-map-2" + assert ( + client.patch( + f"/api/projects/{project['id']}", + headers=auth(member), + json={"description": "Creator update"}, + ).status_code + == 200 + ) + + assert ( + client.put( + f"/api/organizations/{organization['id']}/members", + headers=auth(admin), + json={"username": "member", "role": "viewer"}, + ).status_code + == 200 + ) + assert ( + client.patch( + f"/api/projects/{project['id']}", + headers=auth(member), + json={"description": "Viewer update"}, + ).status_code + == 403 + ) + assert ( + client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "viewer-map.json", + "content": '{"title":"Viewer map"}', + "visibility": "organization", + "organizationId": organization["id"], + }, + ).status_code + == 403 + ) + + +def test_removed_member_creator_loses_organization_project_access_and_edit(client): + admin, member, organization, project = create_member_organization_project(client) + assert ( + client.delete( + f"/api/organizations/{organization['id']}/members/member", + headers=auth(admin), + ).status_code + == 204 + ) + assert client.get(f"/api/projects/{project['id']}", headers=auth(member)).status_code == 404 + assert ( + client.patch( + f"/api/projects/{project['id']}", + headers=auth(member), + json={"description": "After removal"}, + ).status_code + == 403 + ) + assert ( + client.put( + f"/api/projects/{project['id']}/content", + headers=auth(member), + json={"content": '{"title":"After removal"}'}, + ).status_code + == 403 + ) + + +def test_organization_admin_retains_control_after_creator_removal(client): + admin, _, organization, project = create_member_organization_project(client) + assert ( + client.delete( + f"/api/organizations/{organization['id']}/members/member", + headers=auth(admin), + ).status_code + == 204 + ) + assert ( + client.patch( + f"/api/projects/{project['id']}", + headers=auth(admin), + json={"description": "Administrator update"}, + ).status_code + == 200 + ) + assert ( + client.put( + f"/api/projects/{project['id']}/content", + headers=auth(admin), + json={"content": '{"title":"Administrator update"}'}, + ).status_code + == 201 + ) + + def test_accounts_login_current_user_and_hashed_secrets(client): assert client.get("/health").json() == {"ok": True, "service": "geolibre-server"} @@ -133,6 +292,144 @@ def test_unlisted_is_hidden_from_listings_but_readable_by_url(client): assert anonymous.status_code == 200 and anonymous.json() == json.loads(content) +def test_organization_policy_and_group_access_are_enforced(client): + """Organization and group access is checked on listings, raw reads, and writes. + + This specifically guards the two boundaries that cannot be left to the UI: + a member cannot bypass a no-public policy with a direct POST, and a removed + group member loses access to the already-known raw URL immediately. + """ + admin = account(client, "admin") + member = account(client, "member") + outsider = account(client, "outsider") + organization = client.post( + "/api/organizations", + headers=auth(admin), + json={ + "slug": "watershed-lab", + "name": "Watershed Lab", + "publicSharingPolicy": "no", + "defaultVisibility": "organization", + }, + ) + assert organization.status_code == 201, organization.text + organization_id = organization.json()["organization"]["id"] + assert ( + client.put( + f"/api/organizations/{organization_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ).status_code + == 200 + ) + + content = json.dumps({"version": "1.0", "title": "Team map", "layers": []}) + blocked = client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "team.geolibre.json", + "content": content, + "visibility": "public", + "organizationId": organization_id, + }, + ) + assert blocked.status_code == 403 + + organization_project = client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "internal.geolibre.json", + "content": content, + "visibility": "organization", + "organizationId": organization_id, + }, + ) + assert organization_project.status_code == 201, organization_project.text + organization_project_body = organization_project.json()["project"] + assert organization_project_body["username"] is None + assert ( + client.patch( + f"/api/projects/{organization_project_body['id']}", + headers=auth(member), + json={"description": "Creator update"}, + ).status_code + == 200 + ) + assert ( + client.delete( + f"/api/organizations/{organization_id}/members/member", + headers=auth(admin), + ).status_code + == 204 + ) + assert ( + client.patch( + f"/api/projects/{organization_project_body['id']}", + headers=auth(member), + json={"description": "After removal"}, + ).status_code + == 403 + ) + assert ( + client.patch( + f"/api/projects/{organization_project_body['id']}", + headers=auth(admin), + json={"description": "Administrator update"}, + ).status_code + == 200 + ) + + group = client.post( + "/api/groups", + headers=auth(admin), + json={ + "name": "Field team", + "sharedUpdate": True, + }, + ) + assert group.status_code == 201, group.text + group_id = group.json()["group"]["id"] + assert ( + client.put( + f"/api/groups/{group_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ).status_code + == 200 + ) + + created = client.post( + "/api/projects", + headers=auth(admin), + json={ + "filename": "team.geolibre.json", + "content": content, + "visibility": "private", + "groupIds": [group_id], + }, + ) + assert created.status_code == 201, created.text + project = created.json()["project"] + raw_path = project["rawJsonUrl"].removeprefix("https://share.example") + assert client.get(raw_path, headers=auth(member)).status_code == 200 + assert ( + client.put( + f"/api/projects/{project['id']}/content", + headers=auth(member), + json={"content": json.dumps({"version": "1.0", "title": "Updated", "layers": []})}, + ).status_code + == 201 + ) + assert client.get(raw_path, headers=auth(outsider)).status_code == 404 + assert ( + client.delete(f"/api/groups/{group_id}/members/member", headers=auth(admin)).status_code + == 204 + ) + assert client.get(raw_path, headers=auth(member)).status_code == 404 + + def test_patch_rejects_explicit_nulls(client): """An explicit JSON null survives `exclude_unset`, so these must be 422s rather than a non-nullable column error or a len(None) crash at 500.""" @@ -231,7 +528,9 @@ def test_thumbnail_fork_and_slug_collision(client): path = f"/api/projects/{project['id']}/thumbnail" assert ( client.put( - path, headers={**auth(owner), "Content-Type": "image/png"}, content=thumbnail + path, + headers={**auth(owner), "Content-Type": "image/png"}, + content=thumbnail, ).status_code == 204 ) @@ -275,3 +574,1213 @@ def test_validation_and_errors_use_contract_shape(client): ) assert bad.status_code == 422 and set(bad.json()) == {"error"} assert client.get("/api/projects?limit=101").status_code == 422 + + +def test_organization_settings_patch_and_default_visibility(client): + """Org admin can PATCH settings; defaultVisibility is exposed but not enforced + by the server (client uses it to seed the share dialog).""" + admin = account(client, "admin") + org = client.post( + "/api/organizations", + headers=auth(admin), + json={ + "slug": "test-org", + "name": "Test Org", + "publicSharingPolicy": "publishers", + "defaultVisibility": "organization", + }, + ) + assert org.status_code == 201 + org_id = org.json()["organization"]["id"] + + # Only administrator can PATCH + member = account(client, "member") + client.put( + f"/api/organizations/{org_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ) + blocked = client.patch( + f"/api/organizations/{org_id}", headers=auth(member), json={"name": "Hacked"} + ) + assert blocked.status_code == 403 + + # Admin can change name, policy, defaultVisibility, categories + patched = client.patch( + f"/api/organizations/{org_id}", + headers=auth(admin), + json={ + "name": "Renamed", + "publicSharingPolicy": "no", + "defaultVisibility": "private", + "categories": ["water", "land"], + }, + ) + assert patched.status_code == 200 + assert patched.json()["organization"]["name"] == "Renamed" + assert patched.json()["organization"]["publicSharingPolicy"] == "no" + assert patched.json()["organization"]["defaultVisibility"] == "private" + assert patched.json()["organization"]["categories"] == ["water", "land"] + + # Verify new policy blocks member from publishing public + content = json.dumps({"version": "1.0", "title": "X", "layers": []}) + assert ( + client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "x.json", + "content": content, + "visibility": "public", + "organizationId": org_id, + }, + ).status_code + == 403 + ) + + # But admin still can + ok = client.post( + "/api/projects", + headers=auth(admin), + json={ + "filename": "x.json", + "content": content, + "visibility": "public", + "organizationId": org_id, + }, + ) + assert ok.status_code == 201 + + +def test_group_invitation_accept_revoke_and_join_policies(client): + """Group invitations and join policies work end-to-end.""" + owner = account(client, "owner") + alice = account(client, "alice") + bob = account(client, "bob") + carol = account(client, "carol") + + # Invite-only group: owner invites alice + g1 = client.post( + "/api/groups", + headers=auth(owner), + json={"name": "Invite-only", "joinPolicy": "invite"}, + ) + assert g1.status_code == 201 + g1_id = g1.json()["group"]["id"] + + invite = client.post( + f"/api/groups/{g1_id}/invitations", + headers=auth(owner), + json={"username": "alice", "role": "manager"}, + ) + assert invite.status_code == 201 + token = invite.json()["invitation"]["token"] + with client.app.state.engine.connect() as connection: + stored_digest = connection.exec_driver_sql( + "SELECT token_digest FROM group_invitations WHERE id = ?", + (invite.json()["invitation"]["id"],), + ).scalar_one() + stored_row = connection.exec_driver_sql( + "SELECT * FROM group_invitations WHERE id = ?", + (invite.json()["invitation"]["id"],), + ).one() + assert stored_digest == hashlib.sha256(token.encode()).hexdigest() + assert token not in {str(value) for value in stored_row} + + # Alice accepts + assert ( + client.post(f"/api/groups/invitations/{token}/accept", headers=auth(alice)).status_code + == 204 + ) + members = client.get(f"/api/groups/{g1_id}/members", headers=auth(owner)).json()["members"] + assert next(member for member in members if member["username"] == "alice")["role"] == "manager" + assert client.get(f"/api/groups/{g1_id}/projects", headers=auth(alice)).json() == { + "projects": [] + } + + # Bob cannot accept (wrong token owner) + invite2 = client.post( + f"/api/groups/{g1_id}/invitations", + headers=auth(owner), + json={"username": "bob"}, + ) + assert invite2.status_code == 201 + token2 = invite2.json()["invitation"]["token"] + assert ( + client.post(f"/api/groups/invitations/{token2}/accept", headers=auth(alice)).status_code + == 403 + ) + + # Owner revokes Carol's invite + invite3 = client.post( + f"/api/groups/{g1_id}/invitations", + headers=auth(owner), + json={"username": "carol"}, + ) + inv_id = invite3.json()["invitation"]["id"] + assert ( + client.delete(f"/api/groups/{g1_id}/invitations/{inv_id}", headers=auth(owner)).status_code + == 204 + ) + assert ( + client.post( + f"/api/groups/invitations/{invite3.json()['invitation']['token']}/accept", + headers=auth(carol), + ).status_code + == 404 + ) + revoked = client.get(f"/api/groups/{g1_id}/invitations", headers=auth(owner)).json()[ + "invitations" + ] + assert next(item for item in revoked if item["id"] == inv_id)["status"] == "revoked" + assert all("token" not in item for item in revoked) + + # Request-to-join group: bob requests, owner accepts + g2 = client.post( + "/api/groups", + headers=auth(owner), + json={"name": "Request-join", "joinPolicy": "request"}, + ) + g2_id = g2.json()["group"]["id"] + assert client.post(f"/api/groups/{g2_id}/join", headers=auth(bob)).status_code == 204 + # Request appears in invitations table with invited_by=bob + # Owner accepts + assert ( + client.post( + f"/api/groups/{g2_id}/members/bob/decide", + headers=auth(owner), + json={"decision": "accept"}, + ).status_code + == 204 + ) + assert client.get(f"/api/groups/{g2_id}/projects", headers=auth(bob)).json() == {"projects": []} + + # Open group: carol joins directly + g3 = client.post( + "/api/groups", headers=auth(owner), json={"name": "Open", "joinPolicy": "open"} + ) + g3_id = g3.json()["group"]["id"] + assert client.post(f"/api/groups/{g3_id}/join", headers=auth(carol)).status_code == 204 + assert client.get(f"/api/groups/{g3_id}/projects", headers=auth(carol)).json() == { + "projects": [] + } + + # Owner cannot leave without transfer + assert client.delete(f"/api/groups/{g1_id}/members/me", headers=auth(owner)).status_code == 409 + # Transferring to Alice demotes the prior owner to manager. + client.put( + f"/api/groups/{g1_id}/members", + headers=auth(owner), + json={"username": "alice", "role": "owner"}, + ) + # Now owner can leave + assert client.delete(f"/api/groups/{g1_id}/members/me", headers=auth(owner)).status_code == 204 + + +def test_organization_invitations_by_username_and_email(client): + admin = account(client, "admin") + alice = account(client, "alice") + bob = account(client, "bob") + outsider = account(client, "outsider") + carol = account(client, "carol") + assert ( + client.patch("/api/account", headers=auth(bob), json={"email": "BOB@example.org"}).json()[ + "account" + ]["email"] + == "bob@example.org" + ) + + organization = client.post( + "/api/organizations", + headers=auth(admin), + json={"slug": "invitation-lab", "name": "Invitation Lab"}, + ).json()["organization"] + path = f"/api/organizations/{organization['id']}/invitations" + + username_invite = client.post( + path, + headers=auth(admin), + json={"username": "alice", "role": "publisher"}, + ) + assert username_invite.status_code == 201, username_invite.text + username_body = username_invite.json()["invitation"] + username_token = username_body["token"] + with client.app.state.engine.connect() as connection: + stored_digest = connection.exec_driver_sql( + "SELECT token_digest FROM organization_invitations WHERE id = ?", + (username_body["id"],), + ).scalar_one() + stored_row = connection.exec_driver_sql( + "SELECT * FROM organization_invitations WHERE id = ?", + (username_body["id"],), + ).one() + assert stored_digest == hashlib.sha256(username_token.encode()).hexdigest() + assert username_token not in {str(value) for value in stored_row} + + assert client.get(path, headers=auth(alice)).status_code == 403 + assert client.post(path, headers=auth(alice), json={"username": "carol"}).status_code == 403 + pending = client.get(path, headers=auth(admin)) + assert pending.headers["cache-control"] == "private, no-store" + assert pending.json()["invitations"][0]["status"] == "pending" + assert "token" not in pending.json()["invitations"][0] + assert ( + client.post( + f"/api/organizations/invitations/{username_token}/accept", + headers=auth(outsider), + ).status_code + == 403 + ) + assert ( + client.post( + f"/api/organizations/invitations/{username_token}/accept", + headers=auth(alice), + ).status_code + == 204 + ) + assert ( + client.post( + f"/api/organizations/invitations/{username_token}/accept", + headers=auth(alice), + ).status_code + == 404 + ) + members = client.get( + f"/api/organizations/{organization['id']}/members", headers=auth(admin) + ).json()["members"] + assert next(item for item in members if item["username"] == "alice")["role"] == "publisher" + + email_invite = client.post( + path, + headers=auth(admin), + json={"email": "BOB@example.org", "role": "viewer"}, + ).json()["invitation"] + email_token = email_invite["token"] + assert ( + client.post( + f"/api/organizations/invitations/{email_token}/accept", + headers=auth(outsider), + ).status_code + == 403 + ) + assert ( + client.post( + f"/api/organizations/invitations/{email_token}/accept", + headers=auth(bob), + ).status_code + == 204 + ) + + revoked_invite = client.post(path, headers=auth(admin), json={"username": "carol"}).json()[ + "invitation" + ] + assert client.delete(f"{path}/{revoked_invite['id']}", headers=auth(alice)).status_code == 403 + assert client.delete(f"{path}/{revoked_invite['id']}", headers=auth(admin)).status_code == 204 + assert ( + client.post( + f"/api/organizations/invitations/{revoked_invite['token']}/accept", + headers=auth(carol), + ).status_code + == 404 + ) + statuses = { + item["id"]: item["status"] + for item in client.get(path, headers=auth(admin)).json()["invitations"] + } + assert statuses[username_body["id"]] == "accepted" + assert statuses[email_invite["id"]] == "accepted" + assert statuses[revoked_invite["id"]] == "revoked" + + +def test_group_thumbnail_access_and_caching_follow_join_policy(client): + owner = account(client, "owner") + outsider = account(client, "outsider") + thumbnail = b"\x89PNG\r\n\x1a\nteam" + + confined = client.post( + "/api/groups", + headers=auth(owner), + json={"name": "Confined", "joinPolicy": "invite"}, + ).json()["group"] + confined_path = f"/api/groups/{confined['id']}/thumbnail" + assert ( + client.put( + confined_path, + headers={**auth(owner), "Content-Type": "image/png"}, + content=thumbnail, + ).status_code + == 204 + ) + assert client.get(confined_path).status_code == 404 + assert client.get(confined_path, headers=auth(outsider)).status_code == 404 + member_response = client.get(confined_path, headers=auth(owner)) + assert member_response.content == thumbnail + assert member_response.headers["cache-control"] == "private, no-store" + + open_group = client.post( + "/api/groups", + headers=auth(owner), + json={"name": "Open", "joinPolicy": "open"}, + ).json()["group"] + open_path = f"/api/groups/{open_group['id']}/thumbnail" + assert ( + client.put( + open_path, + headers={**auth(owner), "Content-Type": "image/png"}, + content=thumbnail, + ).status_code + == 204 + ) + public_response = client.get(open_path) + assert public_response.content == thumbnail + assert public_response.headers["cache-control"] == "public, max-age=3600" + + +def test_shared_with_me_includes_org_and_group_projects(client): + """/api/projects?shared_with_me=true returns org-shared and group-shared projects.""" + admin = account(client, "admin") + member = account(client, "member") + outsider = account(client, "outsider") + org = client.post( + "/api/organizations", + headers=auth(admin), + json={ + "slug": "lab", + "name": "Lab", + "publicSharingPolicy": "yes", + "defaultVisibility": "organization", + }, + ) + org_id = org.json()["organization"]["id"] + client.put( + f"/api/organizations/{org_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ) + + group = client.post( + "/api/groups", headers=auth(admin), json={"name": "Team", "sharedUpdate": False} + ) + group_id = group.json()["group"]["id"] + client.put( + f"/api/groups/{group_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ) + + content = json.dumps({"version": "1.0", "title": "X", "layers": []}) + + # Admin creates org-visibility project + org_proj = client.post( + "/api/projects", + headers=auth(admin), + json={ + "filename": "org.json", + "content": content, + "visibility": "organization", + "organizationId": org_id, + }, + ) + org_proj_id = org_proj.json()["project"]["id"] + org_private = client.post( + "/api/projects", + headers=auth(admin), + json={ + "filename": "org-private.json", + "content": content, + "visibility": "private", + "organizationId": org_id, + }, + ).json()["project"]["id"] + + # Admin creates private project shared with group + grp_proj = client.post( + "/api/projects", + headers=auth(admin), + json={ + "filename": "grp.json", + "content": content, + "visibility": "private", + "groupIds": [group_id], + }, + ) + grp_proj_id = grp_proj.json()["project"]["id"] + + # Member sees both in shared_with_me + shared = client.get("/api/projects?shared_with_me=true", headers=auth(member)).json()[ + "projects" + ] + shared_ids = {p["id"] for p in shared} + assert org_proj_id in shared_ids + assert grp_proj_id in shared_ids + assert org_private not in shared_ids + + # Outsider sees neither + assert ( + client.get("/api/projects?shared_with_me=true", headers=auth(outsider)).json()["projects"] + == [] + ) + + # Mine does not include org/shared projects (unless member owns them) + mine = client.get("/api/projects?mine=true", headers=auth(member)).json()["projects"] + assert all(p["id"] not in shared_ids for p in mine) + + +def test_expected_version_conflict_on_content_update(client): + """A stale update is saved but carries the required last-write-wins warning.""" + owner = account(client) + proj = client.post( + "/api/projects", + headers=auth(owner), + json={"filename": "x.json", "content": '{"v":"1"}', "visibility": "public"}, + ) + proj_id = proj.json()["project"]["id"] + + # First update: version becomes 2 + u1 = client.put( + f"/api/projects/{proj_id}/content", + headers=auth(owner), + json={"content": '{"v":"2"}', "expectedVersion": 1}, + ) + assert u1.status_code == 201 + assert u1.json()["version"] == 2 + + # Second update with stale expectedVersion=1 succeeds with a warning. + u2 = client.put( + f"/api/projects/{proj_id}/content", + headers=auth(owner), + json={"content": '{"v":"3"}', "expectedVersion": 1}, + ) + assert u2.status_code == 201 + assert u2.json()["version"] == 3 + assert "version conflict" in u2.json()["warning"].lower() + + # Update with current expectedVersion=3 succeeds without a warning. + u3 = client.put( + f"/api/projects/{proj_id}/content", + headers=auth(owner), + json={"content": '{"v":"4"}', "expectedVersion": 3}, + ) + assert u3.status_code == 201 + assert u3.json()["version"] == 4 + assert "warning" not in u3.json() + + +def test_patch_project_with_organization_id_and_group_ids(client): + """PATCH /projects supports changing organizationId and groupIds.""" + admin = account(client, "admin") + member = account(client, "member") + + org = client.post( + "/api/organizations", + headers=auth(admin), + json={ + "slug": "lab2", + "name": "Lab2", + "publicSharingPolicy": "yes", + "defaultVisibility": "organization", + }, + ) + org_id = org.json()["organization"]["id"] + client.put( + f"/api/organizations/{org_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ) + + g1 = client.post("/api/groups", headers=auth(admin), json={"name": "G1", "sharedUpdate": False}) + g1_id = g1.json()["group"]["id"] + client.put( + f"/api/groups/{g1_id}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ) + + g2 = client.post("/api/groups", headers=auth(admin), json={"name": "G2", "sharedUpdate": False}) + g2_id = g2.json()["group"]["id"] + # Note: member is NOT added to g2 + + # Create private project + proj = client.post( + "/api/projects", + headers=auth(admin), + json={"filename": "x.json", "content": '{"v":"1"}', "visibility": "private"}, + ) + proj_id = proj.json()["project"]["id"] + + # Patch to organization visibility with orgId + p1 = client.patch( + f"/api/projects/{proj_id}", + headers=auth(admin), + json={"visibility": "organization", "organizationId": org_id}, + ) + assert p1.status_code == 200 + assert p1.json()["project"]["visibility"] == "organization" + assert p1.json()["project"]["organization"]["id"] == org_id + + # Member can read via org + assert client.get(f"/api/projects/{proj_id}", headers=auth(member)).status_code == 200 + + # Now test groupIds on a separate PRIVATE project (no org) + proj2 = client.post( + "/api/projects", + headers=auth(admin), + json={"filename": "x2.json", "content": '{"v":"1"}', "visibility": "private"}, + ) + proj2_id = proj2.json()["project"]["id"] + + # Patch groupIds to g1 + p2 = client.patch(f"/api/projects/{proj2_id}", headers=auth(admin), json={"groupIds": [g1_id]}) + assert p2.status_code == 200 + assert p2.json()["project"]["groupIds"] == [g1_id] + + # Member can read via group + assert client.get(f"/api/projects/{proj2_id}", headers=auth(member)).status_code == 200 + + # Replace groupIds to g2 (member not in g2) + p3 = client.patch(f"/api/projects/{proj2_id}", headers=auth(admin), json={"groupIds": [g2_id]}) + assert p3.status_code == 200 + assert p3.json()["project"]["groupIds"] == [g2_id] + + # Member can no longer read (not in g2, and project is private with no org) + assert client.get(f"/api/projects/{proj2_id}", headers=auth(member)).status_code == 404 + + +def test_group_settings_patch_does_not_allow_shared_update_change(client): + """PATCH /groups/{id} does not expose sharedUpdate (immutable per issue #1669).""" + owner = account(client, "owner") + g = client.post("/api/groups", headers=auth(owner), json={"name": "Test", "sharedUpdate": True}) + g_id = g.json()["group"]["id"] + assert g.json()["group"]["sharedUpdate"] is True + + # PATCH can change name and description + p = client.patch( + f"/api/groups/{g_id}", + headers=auth(owner), + json={"name": "Renamed", "description": "New desc"}, + ) + assert p.status_code == 200 + assert p.json()["group"]["name"] == "Renamed" + assert p.json()["group"]["description"] == "New desc" + + # sharedUpdate is not in the patch model, so it stays true + assert p.json()["group"]["sharedUpdate"] is True + assert ( + client.patch( + f"/api/groups/{g_id}", + headers=auth(owner), + json={"sharedUpdate": False}, + ).status_code + == 422 + ) + + +def test_organization_owned_raw_routes_roles_and_protected_caching(client): + admin = account(client, "admin") + member = account(client, "member") + publisher = account(client, "publisher") + viewer = account(client, "viewer") + organization = client.post( + "/api/organizations", + headers=auth(admin), + json={ + "slug": "climate-lab", + "name": "Climate Lab", + "publicSharingPolicy": "publishers", + "defaultVisibility": "organization", + }, + ).json()["organization"] + for username, role in ( + ("member", "member"), + ("publisher", "publisher"), + ("viewer", "viewer"), + ): + assert ( + client.put( + f"/api/organizations/{organization['id']}/members", + headers=auth(admin), + json={"username": username, "role": role}, + ).status_code + == 200 + ) + + content = json.dumps({"version": "1.0", "title": "Internal climate", "layers": []}) + created = client.post( + "/api/projects", + headers=auth(member), + json={ + "filename": "climate.json", + "content": content, + "visibility": "organization", + "organizationId": organization["id"], + }, + ) + assert created.status_code == 201, created.text + project = created.json()["project"] + assert project["username"] is None + assert project["rawJsonUrl"].endswith("/org/climate-lab/internal-climate.geolibre.json") + raw_path = project["rawJsonUrl"].removeprefix("https://share.example") + assert client.get(raw_path).status_code == 404 + protected_raw = client.get(raw_path, headers=auth(viewer)) + assert protected_raw.status_code == 200 + assert protected_raw.headers["cache-control"] == "private, no-store" + metadata = client.get(f"/api/projects/{project['id']}", headers=auth(viewer)) + assert metadata.headers["cache-control"] == "private, no-store" + listing = client.get(f"/api/organizations/{organization['id']}/projects", headers=auth(viewer)) + assert listing.headers["cache-control"] == "private, no-store" + + public_body = { + "filename": "public.json", + "content": content, + "visibility": "public", + "organizationId": organization["id"], + } + assert client.post("/api/projects", headers=auth(member), json=public_body).status_code == 403 + assert ( + client.post("/api/projects", headers=auth(publisher), json=public_body).status_code == 201 + ) + assert client.post("/api/projects", headers=auth(viewer), json=public_body).status_code == 403 + + assert ( + client.delete( + f"/api/organizations/{organization['id']}/members/viewer", + headers=auth(admin), + ).status_code + == 204 + ) + assert client.get(raw_path, headers=auth(viewer)).status_code == 404 + + +def test_group_owner_transfer_moderation_and_read_only_shares(client): + owner = account(client, "owner") + manager = account(client, "manager") + member = account(client, "member") + requester = account(client, "requester") + group = client.post( + "/api/groups", + headers=auth(owner), + json={"name": "Review team", "joinPolicy": "request", "sharedUpdate": False}, + ).json()["group"] + for username, role in (("manager", "manager"), ("member", "member")): + assert ( + client.put( + f"/api/groups/{group['id']}/members", + headers=auth(owner), + json={"username": username, "role": role}, + ).status_code + == 200 + ) + assert ( + client.put( + f"/api/groups/{group['id']}/members", + headers=auth(manager), + json={"username": "member", "role": "owner"}, + ).status_code + == 403 + ) + assert ( + client.put( + f"/api/groups/{group['id']}/members", + headers=auth(owner), + json={"username": "owner", "role": "manager"}, + ).status_code + == 409 + ) + + assert ( + client.post(f"/api/groups/{group['id']}/join", headers=auth(requester)).status_code == 204 + ) + ordinary_members = client.get( + f"/api/groups/{group['id']}/members", headers=auth(member) + ).json()["members"] + assert "requester" not in {item["username"] for item in ordinary_members} + manager_members = client.get( + f"/api/groups/{group['id']}/members", headers=auth(manager) + ).json()["members"] + assert ( + next(item for item in manager_members if item["username"] == "requester")["status"] + == "pending" + ) + + content = json.dumps({"version": "1.0", "title": "Review map", "layers": []}) + project = client.post( + "/api/projects", + headers=auth(owner), + json={ + "filename": "review.json", + "content": content, + "visibility": "private", + "groupIds": [group["id"]], + }, + ).json()["project"] + assert ( + client.put( + f"/api/projects/{project['id']}/content", + headers=auth(member), + json={"content": content}, + ).status_code + == 403 + ) + group_projects = client.get(f"/api/groups/{group['id']}/projects", headers=auth(member)) + assert group_projects.headers["cache-control"] == "private, no-store" + assert [item["id"] for item in group_projects.json()["projects"]] == [project["id"]] + assert ( + client.delete( + f"/api/groups/{group['id']}/projects/{project['id']}", headers=auth(manager) + ).status_code + == 204 + ) + assert client.get(f"/api/projects/{project['id']}", headers=auth(member)).status_code == 404 + + transfer = client.put( + f"/api/groups/{group['id']}/members", + headers=auth(owner), + json={"username": "member", "role": "owner"}, + ) + assert transfer.status_code == 200 + roles = { + item["username"]: item["role"] + for item in client.get(f"/api/groups/{group['id']}/members", headers=auth(member)).json()[ + "members" + ] + } + assert roles["member"] == "owner" + assert roles["owner"] == "manager" + assert list(roles.values()).count("owner") == 1 + + owner_id = next( + item["id"] + for item in client.get(f"/api/groups/{group['id']}/members", headers=auth(member)).json()[ + "members" + ] + if item["username"] == "owner" + ) + with pytest.raises(IntegrityError), client.app.state.engine.begin() as connection: + connection.exec_driver_sql( + "UPDATE group_members SET role = 'owner' WHERE group_id = ? AND account_id = ?", + (group["id"], owner_id), + ) + + +def test_private_organization_projects_follow_admin_creator_and_group_access(client): + admin = account(client, "admin") + creator = account(client, "creator") + member = account(client, "member") + viewer = account(client, "viewer") + organization = client.post( + "/api/organizations", + headers=auth(admin), + json={"slug": "private-lab", "name": "Private Lab"}, + ).json()["organization"] + for username, role in ( + ("creator", "publisher"), + ("member", "member"), + ("viewer", "viewer"), + ): + client.put( + f"/api/organizations/{organization['id']}/members", + headers=auth(admin), + json={"username": username, "role": role}, + ) + project = client.post( + "/api/projects", + headers=auth(creator), + json={ + "filename": "private.json", + "content": '{"title":"Private organization map"}', + "visibility": "private", + "organizationId": organization["id"], + }, + ).json()["project"] + + for token in (admin, creator): + response = client.get(f"/api/projects/{project['id']}", headers=auth(token)) + assert response.status_code == 200 + assert response.json()["project"]["canEdit"] is True + assert response.headers["cache-control"] == "private, no-store" + for token in (member, viewer): + assert client.get(f"/api/projects/{project['id']}", headers=auth(token)).status_code == 404 + + group = client.post( + "/api/groups", + headers=auth(creator), + json={"name": "Readers", "sharedUpdate": False}, + ).json()["group"] + client.put( + f"/api/groups/{group['id']}/members", + headers=auth(creator), + json={"username": "member", "role": "member"}, + ) + client.patch( + f"/api/projects/{project['id']}", + headers=auth(creator), + json={"groupIds": [group["id"]]}, + ) + shared = client.get(f"/api/projects/{project['id']}", headers=auth(member)) + assert shared.status_code == 200 + assert shared.json()["project"]["canEdit"] is False + + +def test_shared_sources_filter_before_pagination_and_report_can_edit(client): + admin = account(client, "admin") + creator = account(client, "creator") + member = account(client, "member") + organization = client.post( + "/api/organizations", + headers=auth(admin), + json={"slug": "source-lab", "name": "Source Lab"}, + ).json()["organization"] + for username in ("creator", "member"): + client.put( + f"/api/organizations/{organization['id']}/members", + headers=auth(admin), + json={"username": username, "role": "member"}, + ) + group = client.post( + "/api/groups", + headers=auth(admin), + json={"name": "Editors", "sharedUpdate": True}, + ).json()["group"] + client.put( + f"/api/groups/{group['id']}/members", + headers=auth(admin), + json={"username": "member", "role": "member"}, + ) + + def create(token, title, visibility, organization_id=None, group_ids=None) -> dict[str, object]: + return client.post( + "/api/projects", + headers=auth(token), + json={ + "filename": f"{title}.json", + "content": json.dumps({"title": title}), + "visibility": visibility, + "organizationId": organization_id, + "groupIds": group_ids or [], + }, + ).json()["project"] + + public = create(admin, "Public org", "public", organization["id"]) + organization_map = create(admin, "Organization map", "organization", organization["id"]) + admin_private = create(admin, "Admin private", "private", organization["id"]) + creator_private = create(creator, "Creator private", "private", organization["id"]) + creator_unlisted = create(creator, "Creator unlisted", "unlisted", organization["id"]) + group_projects = [ + create(admin, f"Group {number}", "private", group_ids=[group["id"]]) for number in range(3) + ] + + member_org = client.get( + "/api/projects?shared_with_me=true&shared_source=organizations", + headers=auth(member), + ).json() + assert {item["id"] for item in member_org["projects"]} == { + public["id"], + organization_map["id"], + } + assert all(item["canEdit"] is False for item in member_org["projects"]) + + creator_org = client.get( + "/api/projects?shared_with_me=true&shared_source=organizations", + headers=auth(creator), + ).json()["projects"] + assert {item["id"] for item in creator_org} == { + public["id"], + organization_map["id"], + creator_private["id"], + creator_unlisted["id"], + } + assert admin_private["id"] not in {item["id"] for item in creator_org} + assert ( + next(item for item in creator_org if item["id"] == creator_private["id"])["canEdit"] is True + ) + + admin_org = client.get( + "/api/projects?shared_with_me=true&shared_source=organizations", + headers=auth(admin), + ).json()["projects"] + assert {item["id"] for item in admin_org} == { + public["id"], + organization_map["id"], + admin_private["id"], + creator_private["id"], + creator_unlisted["id"], + } + assert all(item["canEdit"] is True for item in admin_org) + + pages = [ + client.get( + f"/api/projects?shared_with_me=true&shared_source=groups&limit=1&offset={offset}", + headers=auth(member), + ).json() + for offset in range(3) + ] + assert all(page["total"] == 3 and len(page["projects"]) == 1 for page in pages) + assert {page["projects"][0]["id"] for page in pages} == { + project["id"] for project in group_projects + } + assert all(page["projects"][0]["canEdit"] is True for page in pages) + assert client.get("/api/projects?shared_source=groups", headers=auth(member)).status_code == 422 + + +def test_account_email_creation_update_validation_and_uniqueness(client): + first = account(client, "first", " First@Example.org ") + current = client.get("/api/account", headers=auth(first)) + assert current.json()["account"]["email"] == "first@example.org" + assert current.headers["cache-control"] == "private, no-store" + second = account(client, "second") + assert ( + client.patch( + "/api/account", headers=auth(second), json={"email": "not-an-email"} + ).status_code + == 422 + ) + assert ( + client.patch( + "/api/account", + headers=auth(second), + json={"email": "FIRST@example.org"}, + ).status_code + == 409 + ) + assert ( + client.post( + "/api/accounts", + json={ + "username": "third", + "password": "correct horse", + "email": "first@example.org", + }, + ).status_code + == 409 + ) + cleared = client.patch("/api/account", headers=auth(first), json={"email": None}) + assert cleared.json()["account"]["email"] is None + + +def test_authenticated_version_metadata_listing(client): + owner = account(client, "owner") + outsider = account(client, "outsider") + project, _ = create_project(client, owner, "private", "Versioned") + client.put( + f"/api/projects/{project['id']}/content", + headers=auth(owner), + json={"content": '{"title":"Version two"}'}, + ) + path = f"/api/projects/{project['id']}/versions" + assert client.get(path).status_code == 401 + assert client.get(path, headers=auth(outsider)).status_code == 404 + response = client.get(path, headers=auth(owner)) + assert response.headers["cache-control"] == "private, no-store" + assert [item["number"] for item in response.json()["versions"]] == [2, 1] + assert all( + item["createdAt"] and item["url"].startswith("https://") + for item in response.json()["versions"] + ) + assert ( + client.get(f"/api/projects/{project['id']}/versions/1", headers=auth(owner)).status_code + == 200 + ) + + +def test_organization_transfer_allocates_conflict_free_slug(client): + owner = account(client, "owner") + organization = client.post( + "/api/organizations", + headers=auth(owner), + json={"slug": "transfer-lab", "name": "Transfer Lab"}, + ).json()["organization"] + individual, _ = create_project(client, owner, "private", "Collision") + organization_project = client.post( + "/api/projects", + headers=auth(owner), + json={ + "filename": "collision.json", + "content": '{"title":"Collision"}', + "visibility": "private", + "organizationId": organization["id"], + }, + ).json()["project"] + transferred = client.patch( + f"/api/projects/{organization_project['id']}", + headers=auth(owner), + json={"organizationId": None}, + ) + assert transferred.status_code == 200, transferred.text + assert transferred.json()["project"]["slug"] == f"{individual['slug']}-2" + + +def test_postgresql_upgrade_sql_is_idempotent_and_covers_legacy_constraints(): + sql = "\n".join(postgresql_upgrade_statements()).lower() + assert "add column if not exists email" in sql + assert "visibility type varchar(16)" in sql + assert "owner_id drop not null" in sql + assert "on delete set null" in sql + assert "uq_project_org_slug" in sql + assert "uq_group_accepted_owner" in sql + assert "where role = 'owner' and status = 'accepted'" in sql + + +def test_existing_sqlite_schema_is_upgraded_additively(tmp_path): + database = tmp_path / "legacy.db" + connection = sqlite3.connect(database) + connection.executescript(""" + CREATE TABLE accounts ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(39) UNIQUE, + password_hash TEXT NOT NULL, + created_at VARCHAR(32) NOT NULL + ); + CREATE TABLE projects ( + id VARCHAR(36) PRIMARY KEY, + owner_id VARCHAR(36) NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slug VARCHAR(100) NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + visibility VARCHAR(10) NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + thumbnail_type VARCHAR(20), + views INTEGER NOT NULL DEFAULT 0, + fork_count INTEGER NOT NULL DEFAULT 0, + featured BOOLEAN NOT NULL DEFAULT 0, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + CONSTRAINT uq_project_owner_slug UNIQUE (owner_id, slug) + ); + CREATE TABLE groups ( + id VARCHAR(36) PRIMARY KEY, + organization_id VARCHAR(36), + owner_id VARCHAR(36) NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + thumbnail_type VARCHAR(20), + join_policy VARCHAR(16) NOT NULL DEFAULT 'invite', + shared_update BOOLEAN NOT NULL DEFAULT 0, + created_at VARCHAR(32) NOT NULL + ); + CREATE TABLE versions ( + project_id VARCHAR(36) NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + number INTEGER NOT NULL, + object_key TEXT NOT NULL, + created_at VARCHAR(32) NOT NULL, + PRIMARY KEY (project_id, number) + ); + CREATE TABLE project_groups ( + project_id VARCHAR(36) NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + group_id VARCHAR(36) NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + PRIMARY KEY (project_id, group_id) + ); + INSERT INTO accounts (id, username, password_hash, created_at) + VALUES ('old-account', 'old-owner', 'unused', '2026-01-01T00:00:00Z'); + INSERT INTO projects ( + id, owner_id, slug, title, visibility, created_at, updated_at + ) VALUES ( + 'old-project', 'old-account', 'old-map', 'Old map', 'private', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z' + ); + INSERT INTO groups (id, owner_id, name, created_at) + VALUES ('old-group', 'old-account', 'Old group', '2026-01-01T00:00:00Z'); + INSERT INTO versions (project_id, number, object_key, created_at) + VALUES ('old-project', 1, 'old-object', '2026-01-01T00:00:00Z'); + INSERT INTO project_groups (project_id, group_id) + VALUES ('old-project', 'old-group'); + """) + connection.close() + + app = create_app( + f"sqlite:///{database}", + public_url="https://share.example", + storage=FileStorage(str(tmp_path / "objects")), + ) + with TestClient(app) as legacy_client: + token = account(legacy_client, "legacy-admin") + organization = legacy_client.post( + "/api/organizations", + headers=auth(token), + json={"slug": "legacy-org", "name": "Legacy Org"}, + ).json()["organization"] + project = legacy_client.post( + "/api/projects", + headers=auth(token), + json={ + "filename": "legacy.json", + "content": '{"title":"Legacy"}', + "visibility": "organization", + "organizationId": organization["id"], + }, + ) + assert project.status_code == 201, project.text + assert "/org/legacy-org/legacy.geolibre.json" in project.json()["project"]["rawJsonUrl"] + individual = legacy_client.post( + "/api/projects", + headers=auth(token), + json={ + "filename": "legacy.json", + "content": '{"title":"Legacy"}', + "visibility": "private", + }, + ) + assert individual.status_code == 201, individual.text + assert individual.json()["project"]["slug"] == "legacy" + assert ( + legacy_client.get("/legacy-admin/legacy.geolibre.json", headers=auth(token)).status_code + == 200 + ) + with app.state.engine.connect() as upgraded: + account_columns = { + row[1] for row in upgraded.exec_driver_sql("PRAGMA table_info(accounts)") + } + project_columns = { + row[1] for row in upgraded.exec_driver_sql("PRAGMA table_info(projects)") + } + backfilled_creator = upgraded.exec_driver_sql( + "SELECT created_by_id FROM projects WHERE id = 'old-project'" + ).scalar_one() + compatibility_owner, creator = upgraded.exec_driver_sql( + "SELECT owner_id, created_by_id FROM projects WHERE id = ?", + (project.json()["project"]["id"],), + ).one() + owner_info = next( + row + for row in upgraded.exec_driver_sql("PRAGMA table_info(projects)") + if row[1] == "owner_id" + ) + owner_delete_action = next( + row[6] + for row in upgraded.exec_driver_sql("PRAGMA foreign_key_list(projects)") + if row[3] == "owner_id" + ) + assert ( + upgraded.exec_driver_sql( + "SELECT COUNT(*) FROM versions WHERE project_id = 'old-project'" + ).scalar_one() + == 1 + ) + assert ( + upgraded.exec_driver_sql( + "SELECT COUNT(*) FROM project_groups WHERE project_id = 'old-project'" + ).scalar_one() + == 1 + ) + assert "email" in account_columns + assert "organization_id" in project_columns + assert "created_by_id" in project_columns + assert backfilled_creator == "old-account" + assert compatibility_owner is None + assert creator is not None + assert owner_info[3] == 0 + assert owner_delete_action == "SET NULL" + + creator_id = creator + with app.state.engine.begin() as upgraded: + upgraded.exec_driver_sql("DELETE FROM accounts WHERE id = ?", (creator_id,)) + with app.state.engine.connect() as upgraded: + assert upgraded.exec_driver_sql( + "SELECT organization_id, owner_id, created_by_id FROM projects WHERE id = ?", + (project.json()["project"]["id"],), + ).one() == (organization["id"], None, None) + assert ( + upgraded.exec_driver_sql( + "SELECT COUNT(*) FROM versions WHERE project_id = ?", + (project.json()["project"]["id"],), + ).scalar_one() + == 1 + ) diff --git a/docs/server-api.md b/docs/server-api.md index 77eb24663..64737fc8d 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -19,8 +19,9 @@ implementation or storage engine. The reference implementation lives in project the caller may not discover; `409` is a uniqueness conflict; `422` is malformed input; and `429` is rate limiting. - Servers should send `Cache-Control: public, max-age=3600` on immutable raw - project versions and may use `ETag`/conditional requests. Private responses - must use `Cache-Control: private, no-store`. + public/unlisted project versions and may use `ETag`/conditional requests. + Responses containing private, organization, or group-protected content must + use `Cache-Control: private, no-store`, including metadata listings. - CORS deployments must allow `Authorization` and `Content-Type` from the GeoLibre web origin. Native desktop requests do not depend on CORS. @@ -68,8 +69,16 @@ Servers may configure a smaller upload limit, but must return `413` and an - `public`: discoverable in the public listing and readable without auth. - `unlisted`: omitted from public listings, but readable by anyone holding its URL. It appears in the owner's authenticated listing. -- `private`: readable and mutable only by its owner. Raw and thumbnail URLs - require the same Bearer token as the metadata endpoint. +- `private`: an individually owned project is readable only by its owner unless + explicitly shared with a group. An organization-owned private project is + readable by organization administrators and by its creator while that creator + remains an administrator, publisher, or member. Other organization members + and viewers need an explicit group share. Raw and thumbnail URLs require the + same Bearer token as the metadata endpoint. +- `organization`: readable by every signed-in member of the owning organization. + It is omitted from public listings and its raw/thumbnail responses are always + `Cache-Control: private, no-store` so removing a member revokes a known URL + immediately after their client revalidates it. Changing visibility affects every version immediately. A raw URL is therefore not a capability URL for a private project. @@ -84,7 +93,8 @@ an installation delegates identity to an external provider. ```json { "username": "ada", - "password": "correct horse battery staple" + "password": "correct horse battery staple", + "email": "ada@example.org" } ``` @@ -92,7 +102,7 @@ Response `201`: ```json { - "account": {"id": "uuid", "username": "ada", "createdAt": "2026-08-03T12:00:00Z"}, + "account": {"id": "uuid", "username": "ada", "email": "ada@example.org", "createdAt": "2026-08-03T12:00:00Z"}, "token": "secret-token" } ``` @@ -106,7 +116,15 @@ Exchanges account credentials for a personal API token. ``` Response `200` has the same shape as account creation. Tokens are opaque and -must be stored hashed by the server. +must be stored hashed by the server. `email` is optional at account creation, +trimmed and normalized to lowercase, validated, and unique when present. + +### `PATCH /api/account` + +Requires auth. `{"email":"ada@example.org"}` sets the signed-in account's +validated, normalized email; `{"email":null}` clears it. A duplicate email is +`409`. The response is `{"account": }` and uses +`Cache-Control: private, no-store`. ### `DELETE /api/auth/token` @@ -117,7 +135,7 @@ Revokes the presented Bearer token. Response: `204`. Returns the account associated with the token: ```json -{"user": {"id": "uuid", "username": "ada", "createdAt": "2026-08-03T12:00:00Z"}} +{"user": {"id": "uuid", "username": "ada", "email": "ada@example.org", "createdAt": "2026-08-03T12:00:00Z"}} ``` An identity provider may create accounts without a username. Project creation @@ -125,6 +143,127 @@ for such an account must return `400` with an error containing the stable, case-insensitive sentinel text `username required`. Existing clients recognize that phrase and direct the user to account settings. +## Organizations + +`POST /api/organizations` creates an organization and makes the caller its first +`administrator`. The body contains `slug`, `name`, `publicSharingPolicy` +(`yes`, `publishers`, or `no`), `defaultVisibility`, and optional `categories`. +The slug is globally unique. `defaultVisibility` is returned as the safe client +default; requests still state their visibility explicitly. + +Organization roles are: + +- `administrator`: manage settings and membership, and mutate any + organization-owned project. +- `publisher`: create organization content and publish publicly when policy is + `publishers` or `yes`. +- `member`: create organization content and share within the organization; may + publish only when policy is `yes`. +- `viewer`: read organization-visible content only. + +A publisher or member who creates organization content may manage that content +while they retain that organization role. Administrators may manage every +organization project. Demotion to viewer or removal from the organization +immediately removes the creator's management permission; the project remains +owned by the organization rather than becoming orphaned. + +The same rule governs private reads: administrators and active creators can +read private organization projects they can manage. Membership alone does not +grant a publisher, member, or viewer access to somebody else's private project. + +Routes: + +- `GET /api/organizations/mine` lists memberships and each caller's `role`. +- `GET /api/organizations/{id}` returns settings to a member. +- `PATCH /api/organizations/{id}` changes `name`, `publicSharingPolicy`, + `defaultVisibility`, or `categories`; administrator only. +- `GET /api/organizations/{id}/members` lists members. +- `PUT /api/organizations/{id}/members` adds or updates + `{"username":"ada","role":"member"}`; administrator only. +- `DELETE /api/organizations/{id}/members/{username}` removes a member. The + last administrator cannot be removed or demoted. +- `POST /api/organizations/{id}/invitations` creates a pending invitation for + exactly one `username` or `email`; `GET` on the same path lists pending, + accepted, and revoked invitations. Issuance and listing are administrator + only. The creation response alone includes the opaque `token`. +- `DELETE /api/organizations/{id}/invitations/{invitationId}` changes a pending + invitation to `revoked`; administrator only. +- `POST /api/organizations/invitations/{token}/accept` requires sign-in, verifies + the account's username or email, adds the member with the invited role, and + changes the invitation to `accepted`. +- `GET /api/organizations/{id}/projects` returns the organization gallery. A + non-administrator sees public and organization-visible projects plus private + projects separately shared with one of their groups. + +Supplying `organizationId` on project creation or patch transfers the project +to organization ownership. Its `username` is then `null`, every organization +administrator can manage it, and raw routes use +`/org/{organizationSlug}/{projectSlug}[.geolibre.json]`. Clearing +`organizationId` transfers it to the caller's individual account. The public +sharing policy is enforced on create and patch, including direct API requests. +Servers retain a nullable creator identity separately from ownership. New +projects record their creating account whether ownership is individual or +organizational; organization ownership remains authoritative, and the creator +identity does not populate `username` or create an individual project URL. + +## Groups + +`POST /api/groups` creates a standalone or organization-associated group. The +body contains `name`, optional `description` and `organizationId`, `joinPolicy` +(`invite`, `request`, or `open`), and `sharedUpdate`. `sharedUpdate` is fixed at +creation and cannot be patched; `name`, `description`, and `joinPolicy` are +settings. An optional PNG, JPEG, or WebP thumbnail uses +`PUT`/`GET`/`DELETE /api/groups/{id}/thumbnail`. + +Group roles are `owner`, `manager`, and `member`. Exactly one accepted member is +the owner. An owner transfers ownership by assigning `owner` through +`PUT /api/groups/{id}/members`; the prior owner becomes a manager atomically. +Managers can add/remove ordinary members, invite, decide join requests, and +remove projects from the group. Only the owner can manage managers or transfer +ownership, and an owner cannot leave until ownership is transferred. + +Routes: + +- `GET /api/groups/mine` lists accepted memberships; `GET /api/groups/{id}` + returns group detail to a signed-in caller. +- `GET /api/groups/{id}/members` lists accepted members. Owners/managers also + see pending join requests. +- `PUT /api/groups/{id}/members` adds or changes a member using `username` and + `role`; `DELETE /api/groups/{id}/members/{username}` removes one, and + `{username}=me` leaves. +- `POST /api/groups/{id}/invitations` creates a pending invitation for exactly + one `username` or `email`. The creation response includes its opaque token; + manager listings omit the token and retain pending, accepted, and revoked + rows. `DELETE .../invitations/{invitationId}` changes a pending invitation to + `revoked`, and `POST /api/groups/invitations/{token}/accept` changes it to + `accepted` while adding the signed-in target account. +- `POST /api/groups/{id}/join` immediately joins an open group, creates a + pending request for a request group, and rejects an invite-only group. + `POST /api/groups/{id}/members/{username}/decide` with decision `accept` or + `reject` moderates a pending request. +- `GET /api/groups/{id}/projects` lists targeted projects. + `DELETE /api/groups/{id}/projects/{projectId}` removes that target without + deleting the project. + +Project create and patch requests accept `groupIds`. The caller must be an +accepted member of every target. A member can read a private project targeted +to their group and can update its content only if that group's immutable +`sharedUpdate` value is true. Removing the membership or target revokes access +on the next request; protected raw and thumbnail responses are never shared or +persistently cached. + +Invitation tokens are bearer credentials. For both organization and group +invitations, servers must store only a SHA-256 digest, return the raw token only +from the creation call, and hash the path token before acceptance lookup. +Accepted and revoked tokens cannot be reused. + +Group thumbnails follow the group's join policy. An `open` group's thumbnail is +public and may use `Cache-Control: public, max-age=3600`. For `invite` and +`request` groups, only accepted members may fetch the thumbnail and every +successful response uses `Cache-Control: private, no-store`; non-members receive +`404`. This prevents a stable public thumbnail URL from disclosing content from +a membership-confined group. + ## Projects ### Project representation @@ -137,6 +276,9 @@ that phrase and direct the user to account settings. "title": "Wetlands", "description": "", "visibility": "public", + "canEdit": true, + "organization": {"id": "uuid", "slug": "watershed-lab", "name": "Watershed Lab"}, + "groupIds": ["group-uuid"], "thumbnailUrl": "/api/projects/uuid/thumbnail", "views": 12, "forkCount": 0, @@ -151,9 +293,17 @@ that phrase and direct the user to account settings. } ``` -URLs are absolute except that `thumbnailUrl` may be root-relative. Consumers -must resolve a relative thumbnail URL against the server base URL. Unknown -fields must be ignored. +`organization` is non-null whenever the project is organization-owned, +regardless of visibility. +`groupIds` is an array of group identifiers the project is shared with (empty +array when none). Authenticated project, listing, create, and update responses +include `canEdit`, computed by the server for that caller. It is true for an +individual owner, an organization administrator, an active organization +creator, or a member of a targeted group whose `sharedUpdate` setting is true. +Clients must use this value instead of reconstructing authorization from roles. +Anonymous responses omit it. Because authenticated public responses vary by +caller, they use `Cache-Control: private, no-store`. Unknown fields must be +ignored by consumers. ### `POST /api/projects` @@ -163,24 +313,18 @@ Requires auth. Creates a project and its first immutable version. { "filename": "Wetlands.geolibre.json", "content": "{\"version\":\"1.0\", ...}", - "visibility": "public" + "visibility": "public", + "organizationId": "org-uuid", + "groupIds": ["group-uuid-1", "group-uuid-2"] } ``` `content` is a string containing a valid GeoLibre project JSON document. `filename` supplies a fallback title/slug; the project document's non-empty title is authoritative. `visibility` is required and is `public`, `unlisted`, -or `private`. - -Response `201`: - -```json -{"project": {"id": "uuid", "username": "ada", "slug": "wetlands", "projectUrl": "...", "viewerUrl": "...", "rawJsonUrl": "..."}} -``` - -The `project` object is the full project representation. In particular, -`projectUrl` and `rawJsonUrl` are required because the current client treats a -successful response without them as invalid. +`private`, or `organization`. `organizationId` is required when `visibility` +is `organization`. `groupIds` is an optional array of group identifiers; the +caller must be a member of every listed group. ### `GET /api/projects` @@ -197,9 +341,20 @@ Query parameters: - `featured=true`: return featured projects only. - `mine=true`: return the caller's own projects, including unlisted and private ones. Requires auth; without a valid token this is `401`. - -Only public projects are returned unless `mine=true` is set. An Authorization -header does not broaden a public listing by itself. Invalid pagination is `422`. +- `shared_with_me=true`: return organization-visible projects from the caller's + organizations, organization public projects, manageable private/unlisted + organization projects, and projects explicitly targeted to their groups. + Requires auth and cannot be combined with `mine=true`. +- `shared_source=organizations|groups`: with `shared_with_me=true`, restrict the + query before pagination and counting. `organizations` includes public and + organization-visible projects in the caller's organizations plus + private/unlisted projects manageable as an administrator or active creator. + `groups` includes projects explicitly targeted to an accepted group + membership. Using this parameter without `shared_with_me=true` is `422`. + +Only public projects are returned unless `mine=true` or `shared_with_me=true` is +set. An Authorization header does not broaden a public listing by itself. +Invalid pagination or combining both private listing modes is `422`. ### `GET /api/users/{username}/projects` @@ -210,6 +365,8 @@ projects; every other caller, authenticated or not, sees only that user's public projects. The current client first resolves its username through `GET /api/users/me`, then calls this route. +The route accepts `limit` (1-100, default 24) and `offset` (default 0). + A non-owner therefore gets a filtered `200`, not a `403` — the listing narrows rather than refusing, which keeps a user's existence from being probed through the status code. @@ -218,19 +375,37 @@ the status code. Returns `{"project": }` if visible to the caller. +### `GET /api/projects/{id}/versions` + +Requires auth and read access to the project. Returns newest first: + +```json +{"versions":[{"number":3,"createdAt":"2026-08-03T12:00:00Z","url":"https://example.org/api/projects/uuid/versions/3"}]} +``` + +Protected project history responses use `Cache-Control: private, no-store`. +The existing `GET /api/projects/{id}/versions/{version}` route continues to +return the immutable project document itself. + ### `PATCH /api/projects/{id}` -Requires ownership. Accepted fields are `title`, `description`, `visibility`, -and `tags`. Response: `{"project": }`. +Requires ownership, or organization administrator / active organization creator access for organization-owned projects. Accepted fields are `title`, `description`, `visibility`, +`tags`, `organizationId`, and `groupIds`. Response: `{"project": }`. ### `PUT /api/projects/{id}/content` -Requires ownership. Creates a new immutable version. +Requires ownership or write access via a shared-update group. Creates a new +immutable version. ```json -{"content": "{\"version\":\"1.0\", ...}"} +{"content": "{\"version\":\"1.0\", ...}", "expectedVersion": 3} ``` +`expectedVersion` is optional. When provided and it does not match the current +latest version, the write still succeeds under last-write-wins and the `201` +response includes a `warning` string containing the stable phrase +`version conflict`. A matching or omitted version has no `warning` field. + Response `201`: `{"project": , "version": }`. ### `DELETE /api/projects/{id}` @@ -254,6 +429,9 @@ returning `422`. Responds `201` with `{"project": }`. The source document. - `GET /{username}/{slug}` may return an HTML project page or redirect to the configured GeoLibre viewer. It is the `projectUrl` advertised by the API. +- Organization-owned equivalents are + `GET /org/{organizationSlug}/{slug}.geolibre.json` and + `GET /org/{organizationSlug}/{slug}`. Every successful read of the latest raw document may increment `views`; servers must not count failed or unauthorized reads. diff --git a/tests/share-fetch.test.ts b/tests/share-fetch.test.ts index 3831e990b..8e965bdb3 100644 --- a/tests/share-fetch.test.ts +++ b/tests/share-fetch.test.ts @@ -31,7 +31,7 @@ describe("share fetch override", () => { globalThis.fetch = (() => { calledDefault += 1; return Promise.resolve(new Response("ok")); - }) as typeof fetch; + }) as unknown as typeof fetch; // Default share fetch delegates to whatever globalThis.fetch is. await getShareFetch()("https://example.com/"); @@ -42,7 +42,7 @@ describe("share fetch override", () => { setShareFetch((() => { calledOverride += 1; return Promise.resolve(new Response("ok")); - }) as typeof fetch); + }) as unknown as typeof fetch); await getShareFetch()("https://example.com/"); assert.equal(calledOverride, 1); assert.equal(calledDefault, 1); @@ -71,7 +71,7 @@ describe("share fetch override", () => { }, }), ); - }) as typeof fetch); + }) as unknown as typeof fetch); await uploadProjectToShare({ token: "tok", @@ -87,7 +87,7 @@ describe("share fetch override", () => { setShareFetch(((input: RequestInfo | URL) => { seen = typeof input === "string" ? input : input.toString(); return Promise.resolve(jsonResponse({ projects: [] })); - }) as typeof fetch); + }) as unknown as typeof fetch); await fetchSharedProjects(); assert.equal(seen, "https://share.geolibre.app/api/projects"); @@ -100,16 +100,16 @@ describe("share fetch override", () => { const url = typeof input === "string" ? input : input.toString(); seen.push(url); auth = new Headers(init?.headers).get("Authorization"); - if (url.endsWith("/api/users/me")) { + if (url.includes("/api/users/me")) { return Promise.resolve(jsonResponse({ user: { username: "giswqs" } })); } return Promise.resolve(jsonResponse({ projects: [] })); - }) as typeof fetch); + }) as unknown as typeof fetch); await fetchMyProjects({ token: "tok" }); assert.deepEqual(seen, [ "https://share.geolibre.app/api/users/me", - "https://share.geolibre.app/api/users/giswqs/projects", + "https://share.geolibre.app/api/users/giswqs/projects?limit=100&offset=0", ]); // The share-host request carries the bearer token via shareAuthorizedFetch. assert.equal(auth, "Bearer tok"); diff --git a/tests/share-gallery.test.ts b/tests/share-gallery.test.ts index c6c490224..dfc10d8d1 100644 --- a/tests/share-gallery.test.ts +++ b/tests/share-gallery.test.ts @@ -1,10 +1,19 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + fetchMyGroups, + fetchMyOrganizations, fetchMyProjects, + fetchMyShareUsername, + fetchProjectsSharedWithMe, fetchSharedProjects, GalleryError, + isPublicSharingBlocked, + isProjectInMyGroups, + isProjectInMyOrganizations, + loadSharedProjectThumbnail, projectOpenToken, + publicSharingRestriction, resolveThumbnailUrl, shareAuthorizedFetch, } from "../apps/geolibre-desktop/src/lib/share-gallery"; @@ -78,6 +87,18 @@ describe("fetchSharedProjects", () => { assert.equal(projects[0].views, 7); assert.deepEqual(projects[0].tags, ["water", "ocean"]); assert.equal(projects[0].thumbnailUrl, `${BASE}/api/thumbnails/abc-123?v=1`); + assert.equal(projects[0].canEdit, false); + }); + + it("uses authoritative canEdit metadata and defaults missing values to false", async () => { + const { fn } = fakeFetch(200, { + projects: [rawProject({ id: "editable", canEdit: true }), rawProject({ id: "safe" })], + }); + const { projects } = await fetchSharedProjects({ baseUrl: BASE, fetchImpl: fn }); + assert.deepEqual( + projects.map((project) => project.canEdit), + [true, false], + ); }); it("sends limit and offset as query params", async () => { @@ -101,7 +122,11 @@ describe("fetchSharedProjects", () => { it("adds featured=true only when requested", async () => { const plain = fakeFetch(200, { projects: [] }); - await fetchSharedProjects({ baseUrl: BASE, limit: 10, fetchImpl: plain.fn }); + await fetchSharedProjects({ + baseUrl: BASE, + limit: 10, + fetchImpl: plain.fn, + }); assert.ok(!plain.calls[0].includes("featured")); const feat = fakeFetch(200, { projects: [] }); @@ -249,6 +274,28 @@ describe("fetchMyProjects", () => { assert.ok(auth.every((a) => a === "Bearer glb_tok")); }); + it("loads every page instead of silently stopping at the server default", async () => { + const offsets: string[] = []; + const fn = (async (url: string) => { + const parsed = new URL(url); + if (parsed.pathname === "/api/users/me") { + return new Response(JSON.stringify({ user: { username: "giswqs" } })); + } + offsets.push(parsed.searchParams.get("offset") ?? ""); + const offset = Number(parsed.searchParams.get("offset")); + const projects = + offset === 0 + ? Array.from({ length: 100 }, (_, index) => rawProject({ id: `p${index}` })) + : [rawProject({ id: "p100" })]; + return new Response(JSON.stringify({ projects })); + }) as typeof fetch; + + const projects = await fetchMyProjects({ token: "glb_tok", baseUrl: BASE, fetchImpl: fn }); + + assert.equal(projects.length, 101); + assert.deepEqual(offsets, ["0", "100"]); + }); + it("throws a 'username-required' GalleryError when the account has no username", async () => { const { fn } = routedFetch({ "/api/users/me": { status: 200, body: { user: { username: null } } }, @@ -270,6 +317,164 @@ describe("fetchMyProjects", () => { }); }); +describe("authenticated sharing APIs", () => { + it("paginates shared_with_me with the bearer token", async () => { + const seen: { url: string; auth: string | null }[] = []; + const fn = (async (url: string, init: RequestInit = {}) => { + seen.push({ url, auth: new Headers(init.headers).get("Authorization") }); + return new Response( + JSON.stringify({ + projects: [rawProject({ visibility: "organization" })], + total: 3, + }), + { status: 200 }, + ); + }) as typeof fetch; + const result = await fetchProjectsSharedWithMe({ + token: "glb_tok", + source: "organizations", + baseUrl: BASE, + limit: 1, + offset: 1, + fetchImpl: fn, + }); + const url = new URL(seen[0].url); + assert.equal(url.searchParams.get("shared_with_me"), "true"); + assert.equal(url.searchParams.get("shared_source"), "organizations"); + assert.equal(url.searchParams.get("limit"), "1"); + assert.equal(url.searchParams.get("offset"), "1"); + assert.equal(seen[0].auth, "Bearer glb_tok"); + assert.equal(result.hasMore, true); + assert.equal(result.rawCount, 1); + }); + + it("normalizes organization and group memberships", async () => { + const { fn } = routedFetch({ + "/api/organizations/mine": { + status: 200, + body: { + organizations: [ + { + id: "org-1", + slug: "maps", + name: "Maps", + role: "publisher", + publicSharingPolicy: "publishers", + defaultVisibility: "private", + }, + ], + }, + }, + "/api/groups/mine": { + status: 200, + body: { + groups: [{ id: "group-1", name: "Editors", sharedUpdate: true }], + }, + }, + }); + const options = { token: "glb_tok", baseUrl: BASE, fetchImpl: fn }; + const [organizations, groups] = await Promise.all([ + fetchMyOrganizations(options), + fetchMyGroups(options), + ]); + assert.equal(organizations[0].defaultVisibility, "private"); + assert.equal(organizations[0].role, "publisher"); + assert.equal(groups[0].sharedUpdate, true); + }); + + it("resolves the current username for owner permissions in shared tabs", async () => { + const { fn } = routedFetch({ + "/api/users/me": { status: 200, body: { user: { username: "giswqs" } } }, + }); + assert.equal( + await fetchMyShareUsername({ token: "glb_tok", baseUrl: BASE, fetchImpl: fn }), + "giswqs", + ); + }); +}); + +describe("shared project membership logic", () => { + const organization = { + id: "org-1", + slug: "maps", + name: "Maps", + publicSharingPolicy: "publishers" as const, + defaultVisibility: "organization" as const, + categories: [], + role: "member", + }; + const group = { + id: "group-1", + name: "Editors", + description: "", + organizationId: "org-1", + joinPolicy: "invite" as const, + sharedUpdate: true, + role: "member", + }; + const project = { + organization: { id: "org-1", slug: "maps", name: "Maps" }, + groupIds: ["group-1"], + }; + + it("filters organization and group tabs by actual memberships", () => { + assert.equal(isProjectInMyOrganizations(project, [organization]), true); + assert.equal(isProjectInMyGroups(project, [group]), true); + assert.equal(isProjectInMyGroups(project, [{ ...group, id: "other" }]), false); + }); + + it("blocks only public organization sharing when policy denies it", () => { + assert.equal(publicSharingRestriction(null), null); + assert.equal(publicSharingRestriction(organization), "publisher-required"); + assert.equal(publicSharingRestriction({ ...organization, role: "publisher" }), null); + assert.equal( + publicSharingRestriction({ ...organization, publicSharingPolicy: "no" }), + "organization-disabled", + ); + assert.equal( + publicSharingRestriction({ + ...organization, + role: "administrator", + publicSharingPolicy: "no", + }), + null, + ); + assert.equal(isPublicSharingBlocked("public", organization), true); + for (const visibility of ["organization", "private", "unlisted"]) { + assert.equal(isPublicSharingBlocked(visibility, organization), false); + } + }); +}); + +describe("loadSharedProjectThumbnail", () => { + it("keeps public and unlisted thumbnails as direct URLs", async () => { + const result = await loadSharedProjectThumbnail( + { thumbnailUrl: `${BASE}/thumb.png`, visibility: "public" }, + { token: "glb_tok", fetchImpl: () => assert.fail("must not fetch") }, + ); + assert.deepEqual(result, { url: `${BASE}/thumb.png`, objectUrl: false }); + }); + + it("fetches protected thumbnails with authorization and returns an object URL", async () => { + let authorization: string | null = null; + const fetchImpl = (async (_url: string, init: RequestInit = {}) => { + authorization = new Headers(init.headers).get("Authorization"); + return new Response(new Blob(["image"]), { status: 200 }); + }) as typeof fetch; + const result = await loadSharedProjectThumbnail( + { thumbnailUrl: `${BASE}/api/projects/private/thumbnail`, visibility: "private" }, + { + token: "glb_tok", + baseUrl: BASE, + fetchImpl, + createObjectUrl: () => "blob:test-thumbnail", + }, + ); + assert.equal(authorization, "Bearer glb_tok"); + assert.deepEqual(result, { url: "blob:test-thumbnail", objectUrl: true }); + }); +}); + // A deployment that disabled sharing (or named a host that was rejected) must // make the gallery say so rather than silently listing the public hosted // service's projects instead. See GeoLibre#1684. @@ -361,6 +566,10 @@ describe("projectOpenToken", () => { assert.equal(projectOpenToken({ visibility: "private" }, "glb_tok"), "glb_tok"); }); + it("sends the token for organization projects", () => { + assert.equal(projectOpenToken({ visibility: "organization" }, "glb_tok"), "glb_tok"); + }); + it("sends nothing when there is no token to send", () => { assert.equal(projectOpenToken({ visibility: "private" }, ""), undefined); }); diff --git a/tests/share-geolibre.test.ts b/tests/share-geolibre.test.ts index 7cc0243d9..c64468417 100644 --- a/tests/share-geolibre.test.ts +++ b/tests/share-geolibre.test.ts @@ -4,13 +4,16 @@ import { createEmptyProject, serializeProject } from "@geolibre/core"; import { DEFAULT_PROJECT_TITLE, DEFAULT_SHARE_BASE_URL, + fetchSharedProjectVersions, isShareableTitle, MAX_PROJECT_TITLE_LENGTH, resolveShareBaseUrl, resolveShareHost, SHARE_URL_ENV, + sharedProjectContentMatches, shareHostLabel, ShareUploadError, + updateSharedProjectContent, uploadProjectToShare, } from "../apps/geolibre-desktop/src/lib/share-geolibre"; @@ -21,6 +24,7 @@ const PROJECT_DTO = { viewerUrl: "https://web.geolibre.app/?url=https://share.geolibre.app/giswqs/my-map.geolibre.json", rawJsonUrl: "https://share.geolibre.app/giswqs/my-map.geolibre.json", }; +const BASE = "https://share.geolibre.app"; function fakeFetch( status: number, @@ -260,6 +264,23 @@ describe("uploadProjectToShare", () => { assert.equal(result.rawJsonUrl, PROJECT_DTO.rawJsonUrl); }); + it("sends organization ownership and additive group shares", async () => { + const { fn, calls } = fakeFetch(201, { project: PROJECT_DTO }); + await uploadProjectToShare({ + ...baseArgs, + visibility: "organization", + organizationId: "org-1", + groupIds: ["group-1", "group-2"], + fetchImpl: fn, + }); + const body = JSON.parse(String(calls[0].init.body)) as { + organizationId: string; + groupIds: string[]; + }; + assert.equal(body.organizationId, "org-1"); + assert.deepEqual(body.groupIds, ["group-1", "group-2"]); + }); + it("maps 401 to an invalid-token message", async () => { const { fn } = fakeFetch(401, { error: "Unauthorized" }); await assert.rejects( @@ -356,3 +377,142 @@ describe("uploadProjectToShare", () => { assert.equal(result.viewerUrl, ""); }); }); + +describe("updateSharedProjectContent", () => { + it("PUTs content with expectedVersion and returns a stale-version warning", async () => { + const { fn, calls } = fakeFetch(201, { + project: { versionCount: 4 }, + version: 4, + warning: "version conflict", + }); + const result = await updateSharedProjectContent({ + token: "glb_secrettoken", + projectId: "project/id", + content: baseArgs.content, + expectedVersion: 2, + baseUrl: baseArgs.baseUrl, + fetchImpl: fn, + }); + assert.equal(calls[0].url, "https://share.geolibre.app/api/projects/project%2Fid/content"); + assert.equal(calls[0].init.method, "PUT"); + assert.equal( + (calls[0].init.headers as Record).Authorization, + "Bearer glb_secrettoken", + ); + const body = JSON.parse(String(calls[0].init.body)) as { + content: string; + expectedVersion: number; + }; + assert.equal(body.expectedVersion, 2); + assert.equal(result.versionCount, 4); + assert.equal(result.warning, "version conflict"); + assert.equal(result.savedContent, body.content); + }); + + it("returns no warning after an ordinary update", async () => { + const { fn } = fakeFetch(201, { project: { versionCount: 3 }, version: 3 }); + const result = await updateSharedProjectContent({ + token: "glb_secrettoken", + projectId: "project-1", + content: baseArgs.content, + expectedVersion: 2, + baseUrl: baseArgs.baseUrl, + fetchImpl: fn, + }); + assert.equal(result.warning, null); + }); + + it("rejects with ShareUploadError when the server returns a non-ok status", async () => { + const { fn } = fakeFetch(400, { error: "Version conflict detected" }); + await assert.rejects( + () => + updateSharedProjectContent({ + token: "glb_secrettoken", + projectId: "project-1", + content: baseArgs.content, + expectedVersion: 2, + baseUrl: baseArgs.baseUrl, + fetchImpl: fn, + }), + (err: unknown) => + err instanceof ShareUploadError && /Version conflict detected/.test(err.message), + ); + }); +}); + +describe("sharedProjectContentMatches", () => { + it("matches canonical sanitized content but detects edits made during a save", () => { + const sent = createEmptyProject("Remote map"); + sent.preferences.geocoding.apiKeys.mapbox = "secret-a"; + const same = structuredClone(sent); + same.preferences.geocoding.apiKeys.mapbox = "secret-b"; + assert.equal(sharedProjectContentMatches(serializeProject(sent), serializeProject(same)), true); + + same.name = "Edited while saving"; + assert.equal( + sharedProjectContentMatches(serializeProject(sent), serializeProject(same)), + false, + ); + }); + + it("fails safely for invalid live content", () => { + assert.equal(sharedProjectContentMatches(baseArgs.content, "not json"), false); + }); +}); + +describe("fetchSharedProjectVersions", () => { + it("fetches, normalizes, and sorts authoritative server versions", async () => { + const { fn, calls } = fakeFetch(200, { + versions: [ + { number: 1, createdAt: "2026-01-01T00:00:00Z" }, + { version: 3, createdAt: "2026-01-03T00:00:00Z" }, + ], + }); + const versions = await fetchSharedProjectVersions({ + token: "glb_secrettoken", + projectId: "project/id", + baseUrl: BASE, + fetchImpl: fn, + }); + assert.equal(calls[0].url, `${BASE}/api/projects/project%2Fid/versions`); + assert.equal( + (calls[0].init.headers as Record).Authorization, + "Bearer glb_secrettoken", + ); + assert.deepEqual( + versions.map((version) => [version.number, version.rawUrl]), + [ + [3, `${BASE}/api/projects/project%2Fid/versions/3`], + [1, `${BASE}/api/projects/project%2Fid/versions/1`], + ], + ); + }); + + it("rejects when the versions payload is not an array", async () => { + const { fn } = fakeFetch(200, { versions: "invalid" }); + await assert.rejects( + () => + fetchSharedProjectVersions({ + token: "glb_secrettoken", + projectId: "project/id", + baseUrl: BASE, + fetchImpl: fn, + }), + /unexpected response/i, + ); + }); + + it("surfaces the server error message for non-ok responses", async () => { + const { fn } = fakeFetch(404, { error: "Project history not found" }); + await assert.rejects( + () => + fetchSharedProjectVersions({ + token: "glb_secrettoken", + projectId: "project/id", + baseUrl: BASE, + fetchImpl: fn, + }), + /Project history not found/, + ); + }); +});