From 8144e4148f10fb78949036000102450c0205fff3 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 25 Sep 2026 17:46:07 -0700 Subject: [PATCH 1/3] trimmed useless tests, keyboard shortcuts improved --- backend/gamma/cloud_auth.py | 2 +- backend/gamma/routers/cloud_auth.py | 6 +- backend/gamma/workspaces.py | 2 +- cloud/deploy/compose.yml | 2 +- docs/dev/debugging.md | 4 +- docs/dev/hotkeys.md | 4 +- docs/user_guide.md | 3 +- frontend/src/app/App.jsx | 11 +-- frontend/src/app/appCommands.js | 14 ++-- frontend/src/app/prefs.js | 2 +- frontend/src/chat/ChatDock.jsx | 23 +++++- frontend/src/settings/SettingsCloudSignIn.jsx | 2 +- frontend/src/settings/SettingsDialog.jsx | 27 ++++--- frontend/src/settings/SettingsKeyboard.jsx | 2 +- frontend/src/shared/styles/app.css | 3 +- frontend/src/shared/ui/listKeys.js | 20 ++++++ .../tests/e2e/scenarios/chatNavigation.mjs | 35 ++++++++++ .../tests/e2e/scenarios/contextualGuide.mjs | 11 ++- frontend/tests/e2e/scenarios/guide.mjs | 11 +-- frontend/tests/e2e/scenarios/i18n.mjs | 13 +++- frontend/tests/e2e/scenarios/ink.mjs | 12 +--- frontend/tests/e2e/scenarios/inkEditing.mjs | 9 +-- frontend/tests/e2e/scenarios/mentions.mjs | 17 ++--- frontend/tests/e2e/scenarios/notes.mjs | 50 +++++++++++++ frontend/tests/e2e/scenarios/pdf.mjs | 40 +++++------ frontend/tests/e2e/scenarios/pdfTouch.mjs | 6 +- frontend/tests/e2e/scenarios/settings.mjs | 70 +++++++++---------- frontend/tests/e2e/scenarios/transfers.mjs | 12 ++-- 28 files changed, 259 insertions(+), 154 deletions(-) create mode 100644 frontend/src/shared/ui/listKeys.js diff --git a/backend/gamma/cloud_auth.py b/backend/gamma/cloud_auth.py index b239cd75..3eba24a4 100644 --- a/backend/gamma/cloud_auth.py +++ b/backend/gamma/cloud_auth.py @@ -681,7 +681,7 @@ def _resolve(claims: dict, stale: list[str]) -> str: if exists: if not (cfg["policy"] == "claim" or is_admin_seed): raise CloudAuthError(f"\"{local}\" exists on this server but is not linked to your Gamma Cloud " - "account. Sign in with its password and link it from Settings → Account.") + "account. Sign in with its password and link it from Settings → Account & sync.") stale.append(link(conn, local, claims, claims.get("_refresh_token", ""))) if is_admin_seed: conn.execute("UPDATE users SET is_admin = 1 WHERE username = ?", (local,)) diff --git a/backend/gamma/routers/cloud_auth.py b/backend/gamma/routers/cloud_auth.py index dc39f749..ac7bbd2d 100644 --- a/backend/gamma/routers/cloud_auth.py +++ b/backend/gamma/routers/cloud_auth.py @@ -13,11 +13,11 @@ preference profile is pulled before the redirect and this server put on the person's server list (gamma/cloud_sync.py); - ``GET /api/auth/cloud/status`` / ``POST /api/auth/cloud/unlink`` for the - signed-in account's own identity (Settings → Account); an unlink takes + signed-in account's own identity (Settings → Account & sync); an unlink takes this server off the person's server list and revokes the grant. - ``GET /api/auth/cloud/sync-status``: the signed-in account's own preference profile sync state (Settings' section tags), from memory. -- ``POST /api/auth/cloud/sync``: Settings → Account's Sync now, and the +- ``POST /api/auth/cloud/sync``: Settings → Account & sync's Sync now, and the answer to a first sync's choice (its Fetch from cloud / Push to cloud dialog; ``merge`` is API-only). """ @@ -95,7 +95,7 @@ def cloud_callback(request: Request, code: str = "", state: str = "", error: str async def cloud_status(request: Request): user = require_user(request) cfg = cloud_auth.settings() - # the issuer is the portal's address too: Settings → Account opens it from here + # the issuer is the portal's address too: Settings → Account & sync opens it from here return {"identity": cloud_auth.status_of(user), "enabled": cfg["enabled"], "issuer": cfg["issuer"]} diff --git a/backend/gamma/workspaces.py b/backend/gamma/workspaces.py index 039619af..17d82b43 100644 --- a/backend/gamma/workspaces.py +++ b/backend/gamma/workspaces.py @@ -465,7 +465,7 @@ def _cloud_access_token(by: str) -> str: from . import cloud_auth # local: cloud_auth imports this module if not cloud_auth.grant_of(by)[0]: - raise CloudLookupError("Link your own Gamma Cloud account (Settings → Account) to invite by " + raise CloudLookupError("Link your own Gamma Cloud account (Settings → Account & sync) to invite by " "Gamma Cloud username.") token = cloud_auth.access_token_for(by) if not token: diff --git a/cloud/deploy/compose.yml b/cloud/deploy/compose.yml index 59b1c0cd..2e86f2dc 100644 --- a/cloud/deploy/compose.yml +++ b/cloud/deploy/compose.yml @@ -49,7 +49,7 @@ services: demo: # sha- of the build to run. update-demo-server rewrites this line # on the host; the value here is only the first deployment's. - image: ghcr.io/tim4431/gamma:sha-a0d31c6 + image: ghcr.io/tim4431/gamma:sha-30586ef restart: unless-stopped env_file: demo.env volumes: diff --git a/docs/dev/debugging.md b/docs/dev/debugging.md index 329c691f..86b37f04 100644 --- a/docs/dev/debugging.md +++ b/docs/dev/debugging.md @@ -273,14 +273,14 @@ The scenarios live in `tests/e2e/scenarios/`: switcher. Runs in a NON-default workspace on purpose. - `pdf.mjs`: upload + page by attachment, the viewer's text layer, a highlight from a text selection (overlay, quote row, persisted position), - the find bar hitting page 2, the library card, an AI citation link + the find bar hitting page 2, an AI citation link highlighting its quote on the cited page ([pdf_citations.md](pdf_citations.md)). - `transfers.mjs`: the Import and Export dialogs — format/source cards, the review step and its switches, direct export for fixed formats. - `ink.mjs`: handwriting. The tool strip and its presets, mouse strokes becoming an ink block with an `.ink` upload, persistence across a reload, the eraser, stroke undo/redo, the partial eraser, a lasso move + delete, - the notes card's jump + outline, `/Ink` in the exported PDF. Pen input: + the notes card's jump + outline (`/Ink` in the exported PDF is `test_ink.py`). Pen input: coalesced sample timing, pressure and lift endpoints in the uploaded file, prediction, palm suppression and palm-first pen takeover, cleanup after `pointercancel` / lost capture. Chromium's native touch and pen, diff --git a/docs/dev/hotkeys.md b/docs/dev/hotkeys.md index 2b92b605..d50d3c8c 100644 --- a/docs/dev/hotkeys.md +++ b/docs/dev/hotkeys.md @@ -14,7 +14,7 @@ A **command** is declared once, as an object in one of two catalogs: - `id` — stable, `scope.name`; the account's overrides are keyed by it. - `label` / `group` — what the palette and the pane show (`t()` at module level, like any option list). -- `keys` — the default chord, or an array of them (`["Mod-y", "Mod-Shift-z"]`), or `null`: no keys until the account binds some, a palette entry meanwhile. Defaults are deliberately few — the keys Gamma always had (search, Ctrl+P, back, undo/redo, the formatting marks), Ctrl+Shift+P, F2 and Ctrl+Shift+K, plus the ↑/↓ hop; everything else (move, duplicate, new above, indent, fold, to-do, the view toggles, export, share…) starts unbound. +- `keys` — the default chord, or an array of them (`["Mod-y", "Mod-Shift-z"]`), or `null`: no keys until the account binds some, a palette entry meanwhile. Defaults are deliberately few — the keys Gamma always had (search, Ctrl+P, back, undo/redo, the formatting marks), Ctrl+Shift+P, F2, Ctrl+Shift+K and Ctrl+, (settings, the convention everywhere), plus the ↑/↓ hop; everything else (move, duplicate, new above, indent, fold, to-do, the view toggles, export, share…) starts unbound. - `scope` is the catalog the command sits in: - [frontend/src/app/appCommands.js](../../frontend/src/app/appCommands.js) — the app commands, alive wherever focus is: search, the palettes, back, undo / redo, rename, share, metadata, attach, the exports (the Export dialog preset to a format), download, import, new page, show or hide chat / PDF / notes, settings, report a problem. - [frontend/src/editor/blockCommands.js](../../frontend/src/editor/blockCommands.js) — the block commands, for a note whose editor is open: VSCode's line shortcuts with the block as the line (move, duplicate, delete line, new above, indent, fold, to-do, select), the hop to the neighbouring block on ↑ / ↓, and Obsidian's formatting keys. @@ -58,7 +58,7 @@ is the pure core (no DOM, `tests/hotkeys.test.mjs`): **Order.** A block row's `onKeyDown` ([editor/BlockTree.jsx](../../frontend/src/editor/BlockTree.jsx)) first serves its popups (the paste chooser, the `[[` search, the slash menu, the math autocomplete), then dispatches the block catalog, then the outliner's own keys (Tab in math and fences, Enter, Tab, ←/→ folding at the text's edge, Backspace on an empty note). A handled key never reaches the window, so a block chord shadows an app chord while an editor is open. App's window listener dispatches the app catalog and then handles Escape, which is not a command: it always closes popovers and clears selections. -**Dialogs.** Every modal sits in a `.reportOverlay` (Settings and its sub-dialogs, Import, Export, the palette, the confirm box…). While one is open, App's listener dispatches only the app commands whose `inDialog(dialog)` holds (`liveAppCommands` in appCommands.js, `topDialog` the last overlay in the DOM): the rest would act on the page behind it out of sight — Ctrl+Z undoing a note from inside Settings, F2 renaming the page, Alt+← navigating away. Ctrl+F goes to the dialog's own search box, the input marked `data-find` (Settings' search, the move-to-page filter, the chat's page picker), and falls back to the browser's find when there is none; Ctrl+P / Ctrl+Shift+P work only inside the palette itself, switching it between pages and commands or closing it. A dialog with a search box marks it `data-find`. +**Dialogs.** Every modal sits in a `.reportOverlay` (Settings and its sub-dialogs, Import, Export, the palette, the confirm box…). While one is open, App's listener dispatches only the app commands whose `inDialog(dialog)` holds (`liveAppCommands` in appCommands.js, `topDialog` the last overlay in the DOM): the rest would act on the page behind it out of sight — Ctrl+Z undoing a note from inside Settings, F2 renaming the page, Alt+← navigating away. Ctrl+F goes to the dialog's own search box, the input marked `data-find` (Settings' search and the Keyboard pane's filter, the move-to-page filter, the chat's page picker), and falls back to the browser's find when there is none; with several the innermost comes first and pressing again moves to the one before it; Ctrl+P / Ctrl+Shift+P work only inside the palette itself, switching it between pages and commands or closing it. A dialog with a search box marks it `data-find`, and walks its results with `stepList` ([shared/ui/listKeys.js](../../frontend/src/shared/ui/listKeys.js)): ↓ from the box to the first result, ↑/↓ between them, ↑ from the first back to the box; Enter in the box takes the best match (opens the setting, moves the block, ticks the page — with an empty query the page picker's Enter is Done). **CodeMirror.** The block editor installs only `standardKeymap` (caret movement, Home/End, selection by word). Everything above that — the formatting marks, line and block operations — is a command, so nothing arrives from a library keymap by accident. `defaultKeymap` is not used: its Ctrl+M tab-focus mode stops Tab from indenting ([research note](../research/keyboard-shortcuts.md)). diff --git a/docs/user_guide.md b/docs/user_guide.md index 1ebee0ba..4c7a8291 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -264,7 +264,8 @@ Preferences apply immediately; browser-only ones (theme, layout) are marked *Thi | Keys | Does | |---|---| -| Ctrl+F / Ctrl+Shift+F | Search everything (find-in-chat when the chat is focused; on the home page, the listing's box; in Settings or a dialog, its own search box) / always the full panel | +| Ctrl+F / Ctrl+Shift+F | Search everything (find-in-chat when the chat is focused; on the home page, the listing's box; in Settings or a dialog, its own search box — press again for the next one) / always the full panel | +| Ctrl+, | Open settings | | Ctrl+P | Quick open: pick a page by title, folder or label (recent pages first) | | Ctrl+Shift+P | Command palette: every command by name, with its keys (also `>` typed into Ctrl+P) | | F2 | Rename the page | diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 7f73079b..69c73d7e 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -77,6 +77,7 @@ import { } from "../shared/model/blockModel"; import { chordLabel, dispatch as dispatchHotkey, effectiveKeys } from "../shared/lib/hotkeys.js"; import { APP_COMMANDS, liveAppCommands } from "./appCommands.js"; +import { stepList } from "../shared/ui/listKeys.js"; import { BLOCK_COMMANDS } from "../editor/blockCommands.js"; import { loadSession, saveSession, clearSession, setSessionScope } from "./sessionState"; import { ROLE_LABEL, workspaceMeta } from "../settings/SettingsWorkspace"; @@ -2514,7 +2515,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { return null; }); // The first settings sync with Gamma Cloud found two different copies: - // Settings → Account asks which to keep, opened once per page load. + // Settings → Account & sync asks which to keep, opened once per page load. const askedCloudChoice = useRef(false); useEffect(() => { if (!profileSync.cloudChoice || askedCloudChoice.current) return; @@ -9362,7 +9363,8 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ) : null} {moveBlockDialog ? (
setMoveBlockDialog(null)}> -
e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => stepList(e, e.currentTarget.querySelector("[data-find]"), [...e.currentTarget.querySelectorAll(".moveBlockList .ctxMenuItem")])}>
{moveBlockDialog.fragment ? t("Move to page") : t("Move block to page")}
{moveBlockDialog.fragment ? t("It becomes a block of its own at the end of the chosen page.") @@ -9375,7 +9377,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { onChange={(e) => setMoveBlockDialog((s) => ({ ...s, query: e.target.value }))} onKeyDown={(e) => { if (e.key === "Escape") setMoveBlockDialog(null); - else if (e.key === "Enter" && movePageMatches.length) { + else if (e.key === "Enter" && movePageMatches.length && !e.nativeEvent.isComposing) { doMoveBlock(moveBlockDialog.blockId, movePageMatches[0]); } }} @@ -9383,7 +9385,8 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) {
{movePageMatches.map((p) => ( - doMoveBlock(moveBlockDialog.blockId, p)}> + doMoveBlock(moveBlockDialog.blockId, p)} + onKeyDown={(e) => { if (e.key === "Escape") setMoveBlockDialog(null); }}> {p.content || t("Untitled")} ))} diff --git a/frontend/src/app/appCommands.js b/frontend/src/app/appCommands.js index e7001ef6..32c34a0a 100644 --- a/frontend/src/app/appCommands.js +++ b/frontend/src/app/appCommands.js @@ -2,7 +2,7 @@ // App.jsx's one window keydown listener dispatches this catalog // (shared/lib/hotkeys.js) with a ctx of handles it refreshes every render; // the command palette (Ctrl+Shift+P) lists the same entries, and Settings → -// Keyboard rebinds them. Only the long-standing keys and F2 have defaults; +// Keyboard rebinds them. Only the long-standing keys, F2 and Ctrl+, have defaults; // the rest are palette entries until the account gives them a chord. // ctx: { shareMode, homeMode, hasPage, hasPdf, readOnly, search(all), // palette(prefix), back(), undo(redo), renameTitle(), toggleChat(), @@ -34,10 +34,14 @@ export function liveAppCommands() { return dialog ? APP_COMMANDS.filter((cmd) => cmd.inDialog?.(dialog)) : APP_COMMANDS; } // Ctrl+F inside a dialog: the dialog's own search box (marked `data-find`), -// else the browser's find. +// else the browser's find. With several (Settings' search above the Keyboard +// pane's filter) the innermost — the last — comes first, and pressing again +// from one goes to the one before it. function findInDialog(dialog) { - const box = [...dialog.querySelectorAll("[data-find]")].find((el) => el.getClientRects().length && !el.closest("[inert]")); - if (!box) return false; + const boxes = [...dialog.querySelectorAll("[data-find]")].filter((el) => el.getClientRects().length && !el.closest("[inert]")); + if (!boxes.length) return false; + const at = boxes.indexOf(document.activeElement); + const box = boxes[(at < 0 ? boxes.length : at) - 1] || boxes[boxes.length - 1]; box.focus(); box.select?.(); return true; @@ -66,7 +70,7 @@ export const APP_COMMANDS = [ cmd("app.quickOpen", t("Go to page"), GROUP_NAVIGATION, "Mod-p", (c) => { c.palette(""); }, { when: (c) => !c.shareMode, inDialog: inPalette }), cmd("app.commandPalette", t("Command palette"), GROUP_NAVIGATION, "Mod-Shift-p", (c) => { c.palette(">"); }, { palette: false, inDialog: inPalette }), cmd("app.back", t("Back to where you were"), GROUP_NAVIGATION, "Alt-ArrowLeft", (c) => { c.back(); }), - cmd("app.settings", t("Open settings"), GROUP_NAVIGATION, null, (c) => { c.openSettings(); }, { when: (c) => !c.shareMode }), + cmd("app.settings", t("Open settings"), GROUP_NAVIGATION, "Mod-,", (c) => { c.openSettings(); }, { when: (c) => !c.shareMode }), cmd("app.keyboardSettings", t("Keyboard shortcuts…"), GROUP_NAVIGATION, null, (c) => { c.openSettings("keyboard"); }, { when: (c) => !c.shareMode }), cmd("app.workspaces", t("Workspaces…"), GROUP_NAVIGATION, null, (c) => { c.openSettings("workspaces"); }, { when: (c) => !c.shareMode }), diff --git a/frontend/src/app/prefs.js b/frontend/src/app/prefs.js index 9fd571f6..70de3b50 100644 --- a/frontend/src/app/prefs.js +++ b/frontend/src/app/prefs.js @@ -61,7 +61,7 @@ const PULL_MIN_MS = 15000; // - `noteCloud(profile)`: the dialog hands it every sync-status answer; a // "synced" at a time after the last push clears `awaitingCloud`; // - `cloudChoice`: the server's first sync with Gamma Cloud found two -// different copies and waits for the person's choice (Settings → Account); +// different copies and waits for the person's choice (Settings → Account & sync); // - `reload()`: sends a pending change, then pulls — after a sync from // Settings replaced entries on the server. const NONE = new Set(); diff --git a/frontend/src/chat/ChatDock.jsx b/frontend/src/chat/ChatDock.jsx index e72ac33f..8bd70b58 100644 --- a/frontend/src/chat/ChatDock.jsx +++ b/frontend/src/chat/ChatDock.jsx @@ -5,6 +5,7 @@ // prompt preferences it also needs elsewhere. import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { API, apiJson, copyText, isPdfFile, readNdjson } from "../shared/lib/utils"; +import { stepList } from "../shared/ui/listKeys.js"; import { DockWindow, ChatMarkdown, AutoGrowTextarea, useCopied, useTextScale } from "../shared/ui/Widgets"; import PaperMentionInput from "./PaperMentionInput"; import { MAX_CHAT_REFERENCES } from "./paperMentions"; @@ -1544,7 +1545,14 @@ export default function ChatDock({ : null} {docPicker ? (
setDocPicker(false)}> -
e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => { + // ↑/↓ walk the search box and the pages' checkboxes; Enter on a + // page ticks it like Space does. + const list = [...e.currentTarget.querySelectorAll(".docPickerList input[type=checkbox]")]; + if (stepList(e, e.currentTarget.querySelector("[data-find]"), list)) return; + if (e.key === "Enter" && list.includes(e.target)) { e.preventDefault(); e.target.click(); } + }}>
{t("Add pages to the chat")}
{t("Selected pages (their PDF text, and optionally your notes) are sent with every question — pick a few and just ask for a report.")} @@ -1555,7 +1563,18 @@ export default function ChatDock({ placeholder={t("Search your pages…")} value={docPickerQuery} onChange={(e) => setDocPickerQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setDocPicker(false); } }} + onKeyDown={(e) => { + if (e.key === "Escape") { e.preventDefault(); setDocPicker(false); } + // Enter ticks the best match (with a query) and selects the + // query, so the next name typed replaces it; on an empty box + // it is Done. + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + if (!docPickerQuery.trim()) { setDocPicker(false); return; } + const first = e.currentTarget.closest(".docPickerModal").querySelector(".docPickerList input[type=checkbox]"); + if (first && !first.disabled) { first.click(); e.currentTarget.select(); } + } + }} />
{(() => { diff --git a/frontend/src/settings/SettingsCloudSignIn.jsx b/frontend/src/settings/SettingsCloudSignIn.jsx index 371722f2..fc3a4dca 100644 --- a/frontend/src/settings/SettingsCloudSignIn.jsx +++ b/frontend/src/settings/SettingsCloudSignIn.jsx @@ -3,7 +3,7 @@ // server's address, the client this server is, and what happens to a cloud // identity this server has not seen (refuse / claim / provision), and // under provision whether it accepts published pages (the share host). -// - CloudIdentityRow — Settings → Account: the signed-in account's own link +// - CloudIdentityRow — Settings → Account & sync: the signed-in account's own link // to its cloud account (link = a round trip through the account server, // unlink = one call; refused for an account that has no password), and // under it, once linked, CloudSyncRow: the settings sync by hand. diff --git a/frontend/src/settings/SettingsDialog.jsx b/frontend/src/settings/SettingsDialog.jsx index a5184de8..8668c963 100644 --- a/frontend/src/settings/SettingsDialog.jsx +++ b/frontend/src/settings/SettingsDialog.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { stepList } from "../shared/ui/listKeys.js"; import { API, apiJson, fmtBytes, isUnverifiedPaperMeta, metaSourceInfo, getCurrentWorkspace } from "../shared/lib/utils"; import { MenuSelect } from "../shared/ui/Menus"; import { T, t, tn } from "../shared/i18n/i18n.js"; @@ -836,7 +837,7 @@ const SYNC_SOON_MS = 6000; // the server pushes to Gamma Cloud 5 s after a chang // account-wide), polled every 15 s, again as soon as a local change has been // saved, and sooner while a push waits. Every answer also goes to the local // hook's noteCloud, which then knows when its last push reached the cloud. -// `refresh()` polls again at once (after Settings → Account synced by hand). +// `refresh()` polls again at once (after Settings → Account & sync synced by hand). function useCloudSyncStatus(open, local) { const [cloud, setCloud] = React.useState(null); const [tick, setTick] = React.useState(0); @@ -873,6 +874,7 @@ export default function SettingsDialog({ const [pending, setPending] = React.useState(null); const paneRef = React.useRef(null); const modalRef = React.useRef(null); + const searchRef = React.useRef(null); const drafts = React.useRef(new Map()); const available = (id) => { if (id === "integrations") return !!users && !users.isGuest; @@ -923,14 +925,7 @@ export default function SettingsDialog({ }, [!!activePane]); if (!activePane) return null; const results = searchSettings(query, allowed.map(([id]) => id)); - // ↑/↓ walk the search results; ↑ from the first goes back to the box. - const stepResults = (event) => { - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; - event.preventDefault(); - const list = [...paneRef.current.querySelectorAll(".settingsSearchResult")]; - const next = list[list.indexOf(event.currentTarget) + (event.key === "ArrowDown" ? 1 : -1)]; - (next || (event.key === "ArrowUp" ? modalRef.current.querySelector("[data-find]") : null))?.focus(); - }; + const aiValue = { ...ai, aiInfo: prompts.aiInfo }; const paperValue = { ...papers, chatModelName: (ai.aiModels || []).find((m) => m.id === ai.chatModel)?.model }; const navButton = ([id, label, Icon]) => : null}
@@ -994,7 +991,7 @@ export default function SettingsDialog({ {query.trim() ? <> {tn("{n} matching setting", "{n} matching settings", results.length)} {results.length ? results.map(({ pane: id, label }) => ) + onClick={() => navigate(id, label)}>{t(label)}{t(allNav.find(([key]) => key === id)?.[1] || "")}) : {t('No settings found. Try "model", "PDF", or "storage".')}} : <> {pane === "appearance" ? : null} diff --git a/frontend/src/settings/SettingsKeyboard.jsx b/frontend/src/settings/SettingsKeyboard.jsx index 0ce7714c..9b442c3b 100644 --- a/frontend/src/settings/SettingsKeyboard.jsx +++ b/frontend/src/settings/SettingsKeyboard.jsx @@ -35,7 +35,7 @@ export function KeyboardSettings({ value }) {
setFilter(e.target.value)} /> diff --git a/frontend/src/shared/styles/app.css b/frontend/src/shared/styles/app.css index e22ef572..d84cfa52 100644 --- a/frontend/src/shared/styles/app.css +++ b/frontend/src/shared/styles/app.css @@ -3163,7 +3163,7 @@ html.appFocusFullscreen, html.appFocusFullscreen body { overflow: hidden; oversc font-size: calc(12px * var(--ui-font-scale, 1)); color: var(--text-primary); } -.docPickerItem:hover { background: var(--bg-hover); } +.docPickerItem:hover, .docPickerItem:has(input:focus-visible) { background: var(--bg-hover); } .docPickerItem input { flex-shrink: 0; margin: 0; } .docPickerItem .attachName { flex: 1; min-width: 0; } .docPickerNotes { padding-left: 0; } @@ -4898,6 +4898,7 @@ html.appFocusFullscreen, html.appFocusFullscreen body { overflow: hidden; oversc color: var(--text-primary); } .ctxMenuItem:hover { background: var(--bg-hover); } +.moveBlockList .ctxMenuItem:focus-visible { background: var(--bg-hover); outline: none; } /* Menu rows with a leading glyph (e.g. the tab menu's pin/close). */ .ctxMenuItemIconed { display: flex; diff --git a/frontend/src/shared/ui/listKeys.js b/frontend/src/shared/ui/listKeys.js new file mode 100644 index 00000000..59d70c8b --- /dev/null +++ b/frontend/src/shared/ui/listKeys.js @@ -0,0 +1,20 @@ +// ↑/↓ between a search box and the results listed under it (Settings' +// search, the move-to-page filter, the chat's page picker): ↓ from the box +// focuses the first result, ↑/↓ walk the results, ↑ from the first goes back +// to the box. `items` are the focusable results in order; a result that is a +// button or a checkbox already answers Enter / Space itself. Returns true +// when the key was handled. +export function stepList(event, box, items) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return false; + if (event.nativeEvent?.isComposing || event.altKey || event.ctrlKey || event.metaKey) return false; + const down = event.key === "ArrowDown"; + const active = document.activeElement; + const at = items.indexOf(active); + let next = null; + if (active === box) next = down ? items[0] : null; + else if (at >= 0) next = !down && at === 0 ? box : items[at + (down ? 1 : -1)] || active; + if (!next) return false; + event.preventDefault(); + next.focus(); + return true; +} diff --git a/frontend/tests/e2e/scenarios/chatNavigation.mjs b/frontend/tests/e2e/scenarios/chatNavigation.mjs index 97b303ac..a756dde0 100644 --- a/frontend/tests/e2e/scenarios/chatNavigation.mjs +++ b/frontend/tests/e2e/scenarios/chatNavigation.mjs @@ -164,4 +164,39 @@ export async function chatNavigationScenarios(env) { assertNoProblems(page); } finally { await ctx.close(); } }); + + await step("chat navigation: the page picker's keys — Enter ticks the best match, arrows walk, Ctrl+F stays in the picker", async () => { + const ctx = await alice.context(browser); + await ctx.addInitScript(() => localStorage.setItem("gamma-ai-login-check", "off")); + const page = await openPage(ctx, `${server.base}/?ws=${alice.ws}`); + try { + await page.getByRole("button", { name: "Add attachments or chat context" }).click(); + await page.locator(".chatPlusMenuItem", { hasText: "Add pages from library" }).click(); + const picker = page.locator(".docPickerModal"); + const box = picker.getByPlaceholder("Search your pages…"); + await box.waitFor(); + const linked = picker.locator(".docPickerItem", { hasText: "Linked paper" }).locator("input"); + await box.fill("Linked paper"); + await box.press("Enter"); + await until(() => linked.isChecked()); + assertEq(await box.evaluate((el) => el.selectionStart === 0 && el.selectionEnd === el.value.length), true, "the query is selected for the next name"); + // ↓ reaches the page's checkbox, Enter unticks it, ↑ goes back to the box. + await page.keyboard.press("ArrowDown"); + assertEq(await linked.evaluate((el) => el === document.activeElement), true); + await page.keyboard.press("Enter"); + await until(async () => !(await linked.isChecked())); + await page.keyboard.press("ArrowUp"); + await until(() => box.evaluate((el) => el === document.activeElement)); + // Ctrl+F from a checkbox comes back to the picker's box, not find-in-chat. + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Control+f"); + await until(() => box.evaluate((el) => el === document.activeElement)); + assertEq(await page.locator(".chatFindRow").count(), 0); + // Enter on an empty box is Done. + await box.fill(""); + await box.press("Enter"); + await until(async () => !(await picker.count())); + assertNoProblems(page); + } finally { await ctx.close(); } + }); } diff --git a/frontend/tests/e2e/scenarios/contextualGuide.mjs b/frontend/tests/e2e/scenarios/contextualGuide.mjs index cef806ae..45d0fb78 100644 --- a/frontend/tests/e2e/scenarios/contextualGuide.mjs +++ b/frontend/tests/e2e/scenarios/contextualGuide.mjs @@ -1,6 +1,6 @@ // Tours are manual, compact, and never submit a message or activate the mic. export async function contextualGuideScenarios(env) { - const { server, browser, alice, step, until, assert, assertEq, assertNoProblems, openPage, makePdf } = env; + const { server, browser, alice, step, until, assert, assertEq, assertNoProblems, openPage, makePdf, flags } = env; const models = { enabled: true, models: [{ id: "demo:model", provider: "demo", provider_name: "Demo", model: "model" }], default: "demo:model" }; for (const mode of ["library", "pdf", "hidden-pdf", "phone"]) { await step(`guide: manual chat tour ${mode}`, async () => { @@ -16,7 +16,7 @@ export async function contextualGuideScenarios(env) { await ctx.route("**/api/ai/models*", (route) => route.fulfill({ json: models })); let sends = 0; await ctx.route("**/api/ai/chat", (route) => { sends++; return route.fulfill({ body: "" }); }); - const page = await openPage(ctx, `${server.base}/?ws=${alice.ws}${paperId ? `&block=${paperId}` : ""}&guide=ai-chat`); + const page = await openPage(ctx, `${server.base}/?ws=${alice.ws}${paperId ? `&block=${paperId}` : ""}`); try { await page.waitForLoadState("networkidle"); if (mobile) await page.getByRole("button", { name: "AI chat", exact: true }).click(); @@ -24,8 +24,7 @@ export async function contextualGuideScenarios(env) { await input.waitFor(); await input.click(); await input.fill("Keep this draft"); - assertEq(await page.locator('[data-guide-overlay], [data-guide-offer]').count(), 0, "chat focus and URL never start a tour"); - assertEq(await page.getByRole("button", { name: "Chat guide", exact: true }).count(), 0, "chat has no tour button"); + assertEq(await page.locator('[data-guide-overlay], [data-guide-offer]').count(), 0, "chat focus never starts a tour"); if (paperId) await page.waitForSelector('[data-guide="pdf.textLayer"] span'); if (mode === "hidden-pdf") await page.getByRole("button", { name: "Close PDF", exact: true }).click(); // The page's chat mounts after the initial library shell. @@ -43,7 +42,7 @@ export async function contextualGuideScenarios(env) { await page.waitForSelector('[data-guide-overlay="chat-question"]'); assertEq(await page.locator('.guideBody').count(), 0, "no paragraph copy"); await until(async () => await input.inputValue() === "summarize the paper for me", { what: "example is typed into the composer" }); - await page.screenshot({ path: `${server.dir}/chat-${mode}-input.png` }); + if (flags.keep) await page.screenshot({ path: `${server.dir}/chat-${mode}-input.png` }); await page.waitForSelector('[data-guide-overlay="chat-voice"]'); await until(async () => await input.inputValue() === "Keep this draft", { what: "existing draft is restored" }); assertEq(await page.getByRole("button", { name: "Cancel recording", exact: true }).count(), 0, "tour never records"); @@ -53,7 +52,7 @@ export async function contextualGuideScenarios(env) { await page.waitForSelector('[data-guide-overlay="chat-box-context"]'); await page.waitForSelector('[data-guide="chat.imageContext"] img'); assertEq(await page.locator('[data-hl-id]').count(), 0, "context creates no saved annotation"); - await page.screenshot({ path: `${server.dir}/chat-pdf-context.png` }); + if (flags.keep) await page.screenshot({ path: `${server.dir}/chat-pdf-context.png` }); } else { assertEq(await page.locator('.guideCard .primary').textContent(), "Done", "box step is absent without a visible PDF"); } diff --git a/frontend/tests/e2e/scenarios/guide.mjs b/frontend/tests/e2e/scenarios/guide.mjs index b317b33c..32730b50 100644 --- a/frontend/tests/e2e/scenarios/guide.mjs +++ b/frontend/tests/e2e/scenarios/guide.mjs @@ -2,14 +2,15 @@ // registered anchor for the home view is in the DOM, the demo step adds a // paper by itself (click Add, type the link, Enter — against an uploaded PDF // so no network is needed), the user's highlight checks the next step off, -// Esc leaves and records the dismissal, and the account menu restarts it. +// finishing records "done", the account menu restarts it, and Esc leaves a +// replay with its temporary selection cleared. import { ANCHORS, anchorsForView } from "../../../src/guide/anchors.js"; import { selectPdfText, waitForPdf } from "./pdf.mjs"; import { ABSTRACT_PASSAGE } from "../../../src/guide/previewHighlight.js"; import { readFileSync } from "node:fs"; export async function guideScenarios(env) { - const { server, browser, alice, step, until, assert, assertEq, assertNoProblems, openPage, makePdf } = env; + const { server, browser, alice, step, until, assert, assertEq, assertNoProblems, openPage, makePdf, flags } = env; await step("guide: first-run tour — demo adds a paper, the user highlights, menu restarts", async () => { const pdf = process.env.GAMMA_GUIDE_PDF ? readFileSync(process.env.GAMMA_GUIDE_PDF) : makePdf([[ "Attention is all you need, said the transformer.", @@ -21,7 +22,7 @@ export async function guideScenarios(env) { const up = await alice.upload("/api/uploads", pdf, "attention.pdf", "application/pdf"); const ctx = await alice.context(browser); await ctx.addInitScript((url) => localStorage.setItem("gamma-guide-vars", JSON.stringify({ demoUrl: url })), `/api/uploads/${up.doc_id}.pdf`); - const page = await openPage(ctx, `${server.base}/?ws=${alice.ws}&guide=first-run`); + const page = await openPage(ctx, `${server.base}/?ws=${alice.ws}`); try { await page.click('[data-guide="header.account"]'); await page.click('[data-guide="account.tour"]'); @@ -43,7 +44,7 @@ export async function guideScenarios(env) { await until(async () => (await page.locator(".guideCursor").getAttribute("style")) !== firstPointer, { what: "the example pointer moves across the text" }); await page.waitForSelector('[data-guide="pdf.highlightColor"]'); assertEq((await page.evaluate(() => window.getSelection().toString())).replace(/\s/g, ""), ABSTRACT_PASSAGE.replace(/\s/g, ""), "demo selects the requested abstract across lines"); - await page.screenshot({ path: `${server.dir}/highlight-demo.png` }); + if (flags.keep) await page.screenshot({ path: `${server.dir}/highlight-demo.png` }); await page.waitForSelector('[data-guide-overlay="highlight"] .guideCard', { timeout: 30000 }); assertEq(await page.evaluate(() => window.getSelection().toString()), "", "example selection clears before handing over"); assertEq(await page.locator("[data-hl-id]").count(), 0, "the example creates no saved highlight"); @@ -73,7 +74,7 @@ export async function guideScenarios(env) { return Date.now() - stableSince > 150; }, { what: "the formula rectangle finishes growing" }); assertEq(await page.locator('.guideModifier').textContent(), "Ctrl", "box demonstration shows the modifier"); - await page.screenshot({ path: `${server.dir}/area-demo.png` }); + if (flags.keep) await page.screenshot({ path: `${server.dir}/area-demo.png` }); await page.waitForSelector('[data-guide-overlay="area"] .guideCard'); assertEq(await page.locator('.pdfAreaMarquee').count(), 0, "example rectangle is cleaned up"); const pdfBox = await page.locator('[data-guide="pdf.page"]').first().boundingBox(); diff --git a/frontend/tests/e2e/scenarios/i18n.mjs b/frontend/tests/e2e/scenarios/i18n.mjs index 1dd4c0e7..07443328 100644 --- a/frontend/tests/e2e/scenarios/i18n.mjs +++ b/frontend/tests/e2e/scenarios/i18n.mjs @@ -33,6 +33,11 @@ export async function i18nScenarios(env) { await openSettings(page, "Account & settings", "Settings…"); assertEq(await page.evaluate(() => document.documentElement.lang), "en", "an English browser starts in English"); + // The remounted app pulls the profile once (useProfileSync); that pull + // must not bring the old language back. Wait for it, not for a time. + const nextPull = () => page.waitForResponse((r) => r.url().includes("/api/prefs/profile") && r.request().method() === "GET"); + const applied = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 50))))); + let pulled = nextPull(); await pick(page, "Language", "中文"); await until(() => page.evaluate(() => document.documentElement.lang === "zh-CN"), { what: "the document language follows" }); // The app remounted under the new locale, on the same pane. @@ -40,8 +45,8 @@ export async function i18nScenarios(env) { await page.getByRole("navigation", { name: "设置分类" }).getByRole("button", { name: "外观", exact: true }).waitFor(); assertEq(await page.locator('.settingsPane [data-setting="语言"] .settingLabel').textContent(), "语言", "the row itself is translated"); assertEq(await page.evaluate(() => localStorage.getItem("gamma-language")), "zh", "the pick is stored"); - // The fresh app's profile pull must not bring the old value back. - await sleep(2500); + await pulled; + await applied(); assertEq(await page.evaluate(() => document.documentElement.lang), "zh-CN", "the pick survives the profile pull"); assertEq((await user.api("/api/prefs/profile")).value.language, "zh", "the profile holds the pick"); assertNoProblems(page); @@ -50,10 +55,12 @@ export async function i18nScenarios(env) { await page.waitForSelector(".folderNewBtn"); assertEq(await page.evaluate(() => document.documentElement.lang), "zh-CN", "a reload paints Chinese from the stored pick"); await openSettings(page, "账户与设置", "设置…"); + pulled = nextPull(); await pick(page, "语言", "系统"); await until(() => page.evaluate(() => document.documentElement.lang === "en"), { what: "System follows the English browser" }); await settings(page).waitFor(); - await sleep(2500); + await pulled; + await applied(); assertEq(await page.evaluate(() => document.documentElement.lang), "en", "System survives the profile pull"); assertNoProblems(page); } finally { await ctx.close(); } diff --git a/frontend/tests/e2e/scenarios/ink.mjs b/frontend/tests/e2e/scenarios/ink.mjs index 955b0170..1d8b3abf 100644 --- a/frontend/tests/e2e/scenarios/ink.mjs +++ b/frontend/tests/e2e/scenarios/ink.mjs @@ -1,7 +1,7 @@ // Handwriting (docs/dev/handwriting.md): the tool strip and its presets, // mouse strokes becoming an ink block with an .ink upload, persistence -// across a reload, the eraser, the lasso, the notes card's jump + flash, -// and /Ink in the annotated PDF. +// across a reload, the eraser, the lasso, the notes card's jump + flash. +// /Ink in the annotated PDF is backend/tests/test_ink.py. import { waitForPdf } from "./pdf.mjs"; async function drawLine(page, from, to) { @@ -33,7 +33,6 @@ export async function inkScenarios({ server, browser, alice, makePdf, step, unti assertEq((await page.$$(".pdfInkBar .inkToolInk")).length, 7, "the default presets: four pens, three highlighters"); const buttons = await page.locator(".pdfInkRow button").evaluateAll((els) => els.map((el) => el.getAttribute("aria-label"))); assertEq(JSON.stringify(buttons.slice(-2)), JSON.stringify(["Undo ink", "Redo ink"]), "history controls are last"); - assertEq(await page.locator(".pdfInkBar button[title^='Start a new']").count(), 0, "new-note plus button removed"); box = await page.locator('[data-page="1"]').boundingBox(); await drawLine(page, [box.x + 100, box.y + 150], [box.x + 250, box.y + 170]); await drawLine(page, [box.x + 100, box.y + 250], [box.x + 250, box.y + 280]); @@ -187,14 +186,9 @@ export async function inkScenarios({ server, browser, alice, makePdf, step, unti assertNoProblems(page); }); - await step("ink: the notes card jumps to the group and outlines it; the annotated PDF carries /Ink", async () => { + await step("ink: the notes card jumps to the group and outlines it", async () => { await page.click(".blockInkCard"); await page.waitForSelector('[data-page="1"] .inkFlash', { timeout: 5000 }); - const r = await account.api(`/api/pages/${pageId}/export-pdf`, { raw: true }); - assert(r.ok, `export-pdf ${r.status}`); - const bytes = Buffer.from(await r.arrayBuffer()); - assert(bytes.includes("/Ink"), "the exported PDF has an /Ink annotation"); - assert(bytes.includes("/GammaInk"), "…carrying the gamma-ink strokes for a round trip"); if (flags.keep) await page.screenshot({ path: `${server.dir}/ink-notes.png` }); assertNoProblems(page); }); diff --git a/frontend/tests/e2e/scenarios/inkEditing.mjs b/frontend/tests/e2e/scenarios/inkEditing.mjs index 21774124..d74b8811 100644 --- a/frontend/tests/e2e/scenarios/inkEditing.mjs +++ b/frontend/tests/e2e/scenarios/inkEditing.mjs @@ -83,14 +83,9 @@ export async function inkEditingScenarios({ server, browser, alice, bob, makePdf await page.getByText("Nothing to undo in handwriting.", { exact: true }).waitFor(); assertEq(await page.locator(paths).count(), 2, "empty ink history does not fall through to note undo"); for (const name of ["Color", "Width", "Duplicate", "Select note", "Show note", "Delete"]) { - const button = menu().getByRole("button", { name, exact: true }); - assertEq(await button.locator("svg").count(), 1, `${name} has an icon`); - assert(await button.getAttribute("title"), `${name} has a tooltip`); - const box = await button.boundingBox(); + const box = await menu().getByRole("button", { name, exact: true }).boundingBox(); assert(box.width >= 36 && box.height >= 36, `${name} retains a touch target`); } - assertEq(await menu().getByRole("button", { name: "Done", exact: true }).count(), 0); - assertEq(await menu().locator(".inkEditRow button").last().getAttribute("aria-label"), "Delete"); await menu().getByRole("button", { name: "Duplicate", exact: true }).hover(); await page.getByRole("tooltip").filter({ hasText: "Duplicate" }).waitFor(); assertNoProblems(page); @@ -213,7 +208,7 @@ export async function inkEditingScenarios({ server, browser, alice, bob, makePdf await page.getByRole("button", { name: /^Eraser \(E\)/ }).tap(); await page.mouse.move(p.x, p.y); assertEq(await cursor.getAttribute("data-tool"), "eraser"); - assertEq(Math.round((await cursor.boundingBox()).width), 18, "medium eraser diameter in CSS pixels"); + assert((await cursor.boundingBox()).width > penSize, "the eraser shows its own, larger footprint"); await page.getByRole("button", { name: "Hand", exact: true }).tap(); await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...p, pointerType: "pen", buttons: 0 }); await cursor.waitFor({ state: "visible" }); diff --git a/frontend/tests/e2e/scenarios/mentions.mjs b/frontend/tests/e2e/scenarios/mentions.mjs index 1057051f..12cc5943 100644 --- a/frontend/tests/e2e/scenarios/mentions.mjs +++ b/frontend/tests/e2e/scenarios/mentions.mjs @@ -38,20 +38,11 @@ export async function mentionScenarios(env) { await until(() => requests.length === 1); const reference = page.locator(".chatBubble.user .chatMsgPdfs .crumbBtn").last(); await reference.waitFor(); - for (const theme of ["light", "dark"]) { - await page.evaluate((theme) => document.documentElement.setAttribute("data-theme", theme), theme); - for (const selector of [".chatReferenceChip", ".chatReferenceChip .crumbBtn", ".chatBubble.user .chatMsgPdfs .crumbBtn"]) { - const style = await page.locator(selector).last().evaluate((el) => { - const css = getComputedStyle(el); - return { border: css.borderTopWidth, background: css.backgroundColor, - fits: el.scrollWidth <= el.clientWidth + 1 }; - }); - assertEq(style.border, "0px", `${selector} has no frame`); - assertEq(style.background, "rgba(0, 0, 0, 0)", `${selector} has no filled badge`); - assert(style.fits, `${selector} truncates long titles without overflowing`); - } - if (process.env.GAMMA_MENTIONS_SCREENSHOT) await page.locator(".chatPanel").screenshot({ path: `${process.env.GAMMA_MENTIONS_SCREENSHOT}-${theme}.png` }); + for (const selector of [".chatReferenceChip", ".chatReferenceChip .crumbBtn", ".chatBubble.user .chatMsgPdfs .crumbBtn"]) { + const fits = await page.locator(selector).last().evaluate((el) => el.scrollWidth <= el.clientWidth + 1); + assert(fits, `${selector} truncates long titles without overflowing`); } + if (process.env.GAMMA_MENTIONS_SCREENSHOT) await page.locator(".chatPanel").screenshot({ path: `${process.env.GAMMA_MENTIONS_SCREENSHOT}.png` }); await page.getByRole("button", { name: `Remove ${title} from context` }).click(); assertEq(await page.locator(".chatReferenceChip").count(), 0); await reference.focus(); diff --git a/frontend/tests/e2e/scenarios/notes.mjs b/frontend/tests/e2e/scenarios/notes.mjs index d2e5c252..21fa7c05 100644 --- a/frontend/tests/e2e/scenarios/notes.mjs +++ b/frontend/tests/e2e/scenarios/notes.mjs @@ -151,6 +151,56 @@ export async function noteScenarios({ server, browser, alice, step, until, sleep assertNoProblems(page); }); + await step("notes: the move-to-page dialog walks its pages by keys; page shortcuts wait behind it", async () => { + // A page of its own in a second tab, so the tree the other steps check stays put. + const src = await alice2.api("/api/pages", { method: "POST", body: { title: "Move source" } }); + await alice2.api(`/api/pages/${src.id}/ops`, { method: "POST", body: { client: "e2e", ops: [ + { op: "insert", id: "moveblk1", parent: src.id, position: "a0", content: "to be moved" }] } }); + const destA = await alice2.api("/api/pages", { method: "POST", body: { title: "Archive alpha" } }); + const destB = await alice2.api("/api/pages", { method: "POST", body: { title: "Archive beta" } }); + const p2 = await openPage(ctx, `${server.base}/?ws=${second.id}&page=${src.id}`); + try { + await editRow(p2, "to be moved"); + await p2.keyboard.type(" now"); + await until(async () => (await tree(alice2, src.id))[0]?.content === "to be moved now", { what: "the edit saved" }); + await closeEditor(p2); + const wrap = p2.locator(".sortableBlockWrap", { hasText: "to be moved" }).first(); + await wrap.hover(); + await wrap.locator(".dragHandle").click(); + await p2.locator(".ctxMenuItem", { hasText: "Move to page…" }).click(); + const box = p2.getByPlaceholder("Filter pages…"); + await box.waitFor(); + // Ctrl+Z and F2 belong to the page behind the dialog: neither acts. + await p2.keyboard.press("Control+z"); + await p2.keyboard.press("F2"); + await sleep(500); // an undo would have saved by now + assertEq(await p2.locator(".titleEdit").count(), 0, "F2 does not rename behind the dialog"); + assertEq((await tree(alice2, src.id))[0]?.content, "to be moved now", "Ctrl+Z does not undo behind the dialog"); + await box.fill("archive"); + const items = p2.locator(".moveBlockList .ctxMenuItem"); + await until(async () => (await items.count()) === 2); + const focused = (i) => items.nth(i).evaluate((el) => el === document.activeElement); + await p2.keyboard.press("ArrowDown"); + assertEq(await focused(0), true); + await p2.keyboard.press("ArrowDown"); + assertEq(await focused(1), true); + await p2.keyboard.press("ArrowDown"); + assertEq(await focused(1), true, "the last page holds the focus"); + await p2.keyboard.press("ArrowUp"); + await p2.keyboard.press("ArrowUp"); + await until(() => box.evaluate((el) => el === document.activeElement)); + await p2.keyboard.press("ArrowDown"); + await p2.keyboard.press("ArrowDown"); + const pick = (await items.nth(1).innerText()).includes("beta") ? destB : destA; + await p2.keyboard.press("Enter"); + await until(async () => (await tree(alice2, pick.id)).some((b) => b.content === "to be moved now"), { what: "moved to the second match" }); + assertEq((await tree(alice2, src.id)).length, 0); + assertNoProblems(p2); + } finally { + await p2.close(); + } + }); + await step("notes: block commands from the palette (new above, move down) and the keys Ctrl+Shift+K, F2", async () => { // Unbound block commands run from Ctrl+Shift+P on the focused row. await editRow(page, "third"); diff --git a/frontend/tests/e2e/scenarios/pdf.mjs b/frontend/tests/e2e/scenarios/pdf.mjs index 164ed99e..2feec3b8 100644 --- a/frontend/tests/e2e/scenarios/pdf.mjs +++ b/frontend/tests/e2e/scenarios/pdf.mjs @@ -39,7 +39,6 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti const created = await account.api(`/api/blocks/by-doc/${docId}`, { method: "POST", body: { default_title: "Rydberg paper", source_url: up.source_url } }); pageId = created.id; ctx = await account.context(browser); - await ctx.addInitScript(() => localStorage.setItem("gamma-hl-note-badge", "0")); page = await openPage(ctx, `${server.base}/?page=${pageId}&ws=${account.ws}`); await waitForPdf(page, 1); await until(async () => (await page.$$("[data-page]")).length >= 2, { what: "two page wrappers" }); @@ -75,7 +74,7 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti assertNoProblems(page); }); - await step("pdf: note badges stay on even with an old disabled preference", async () => { + await step("pdf: a highlight with a note shows a badge that opens the note", async () => { const data = await account.api(`/api/blocks/${pageId}/subtree`); const highlight = data.block.children.find((block) => block.properties?.highlight_id); await account.api(`/api/blocks/${highlight.id}`, { method: "PUT", body: { content: "A note on this passage" } }); @@ -89,7 +88,9 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti }); await step("pdf: interface scale keeps note badges anchored and Tours consistent", async () => { - for (const scale of [0.7, 1, 1.6]) { + // Sizes are compared with interface size 100% (measured first), not pinned in pixels. + let base; + for (const scale of [1, 0.7, 1.6]) { await page.evaluate((value) => localStorage.setItem("gamma-ui-scale", String(value)), scale); await page.reload(); await waitForPdf(page); @@ -103,13 +104,16 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti const end = marks.sort((a, b) => b.top - a.top || b.right - a.right)[0]; return { width: box.width, dx: box.left - end.right, dy: box.top - end.top }; }); - for (const zoom of [false, true]) { - if (zoom) await page.getByRole("button", { name: "Zoom in", exact: true }).click(); - await until(async () => { - const { width, dx, dy } = await measure(); - return Math.abs(width - 15 * scale) < 1 && Math.abs(dx - 2) < 1 && Math.abs(dy + 8) < 1; - }, { what: `badge stays at passage end with interface scale ${scale}` }); - } + // at the passage's end, just after it; zooming the PDF moves it along, same size and offset + const before = await until(async () => { + const m = await measure(); + return m.dx >= 0 && m.dx < 6 * scale && Math.abs(m.dy) < 16 * scale ? m : null; + }, { what: `badge sits at the passage end with interface scale ${scale}` }); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await until(async () => { + const m = await measure(); + return Math.abs(m.width - before.width) < 1 && Math.abs(m.dx - before.dx) < 1 && Math.abs(m.dy - before.dy) < 1; + }, { what: `badge keeps its place and size when the PDF zooms (interface scale ${scale})` }); await page.getByRole("button", { name: "Account & settings", exact: true }).click(); const settings = page.getByRole("button", { name: "Settings…", exact: true }); const tours = page.locator('summary[data-guide="account.tour"]'); @@ -121,7 +125,9 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti assert(Math.abs((await tour.boundingBox()).height - toursBox.height) < 1, "submenu scales once"); await settings.click(); const font = await page.getByText("Interface size", { exact: true }).evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); - assert(Math.abs(font - 13 * scale) < 0.1, "ordinary settings text follows interface size"); + base ||= { font, badge: before.width }; + assert(Math.abs(font / base.font - scale) < 0.02, `ordinary settings text follows interface size (${font} at ${scale})`); + assert(Math.abs(before.width / base.badge - scale) < 0.08, `the badge follows interface size (${before.width} at ${scale})`); await page.getByRole("button", { name: "Close settings", exact: true }).click(); assertNoProblems(page); } @@ -161,8 +167,6 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti await link.click(); const mark = page.locator('[data-page="2"] .pdfCitationMark').first(); await mark.waitFor(); - assertEq(await mark.evaluate(el => getComputedStyle(el).animationName), "pdfTransShimmer", "citation reuses the in-progress translation shimmer"); - assertEq(await page.getByRole("button", { name: "Clear reference highlight", exact: true }).count(), 0); const aligned = () => page.evaluate(() => { const mark = document.querySelector('[data-page="2"] .pdfCitationMark').getBoundingClientRect(); const span = [...document.querySelectorAll('[data-page="2"] .textLayer span')] @@ -507,16 +511,6 @@ export async function pdfScenarios({ server, browser, alice, makePdf, step, unti } }); - await step("pdf: the home library lists the paper and double-click opens it", async () => { - await page.click("button[aria-label='Home']"); - const card = page.locator(".pageCard", { hasText: "Rydberg paper" }).first(); - await card.waitFor({ timeout: 15000 }); - await card.dblclick(); - await waitForPdf(page, 1); - assert(new URL(page.url()).searchParams.get("block") === pageId || new URL(page.url()).searchParams.get("page") === pageId, `url ${page.url()}`); - assertNoProblems(page); - }); - if (ctx) await ctx.close(); return { pdfPageId: pageId, docId }; } diff --git a/frontend/tests/e2e/scenarios/pdfTouch.mjs b/frontend/tests/e2e/scenarios/pdfTouch.mjs index 60a56ada..cc869552 100644 --- a/frontend/tests/e2e/scenarios/pdfTouch.mjs +++ b/frontend/tests/e2e/scenarios/pdfTouch.mjs @@ -20,8 +20,6 @@ export async function pdfTouchScenarios({ server, browser, alice, makePdf, step, pageId = created.id; ctx = await alice.context(browser, { hasTouch: true, isMobile: true, deviceScaleFactor: 2, viewport: { width: 1024, height: 768 } }); await ctx.addInitScript(() => { - // Snap is always on, including for browsers with an old disabled value. - localStorage.setItem("gamma-snap-vertical", "0"); localStorage.setItem("gamma-ink-pen-only", "1"); // Reproduce allocation refusal on constrained WebKit devices. Without // the cap, 400% Letter at DPR 2 requests over 30 million pixels. @@ -162,8 +160,8 @@ export async function pdfTouchScenarios({ server, browser, alice, makePdf, step, return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3); }, png.toString("base64")); }; - for (const [theme, flip, expected] of [["sepia", false, [253, 246, 227]], ["solarized", false, [253, 246, 227]], - ["gray", false, [244, 244, 244]], ["dark", true, [15, 15, 15]]]) { + // One light theme and the flipped dark page: the two ways paper is composited. + for (const [theme, flip, expected] of [["sepia", false, [253, 246, 227]], ["dark", true, [15, 15, 15]]]) { await page.evaluate(([theme, flip]) => { document.documentElement.setAttribute("data-theme", theme); document.querySelector(".pdfViewer").classList.toggle("pdfDark", flip); diff --git a/frontend/tests/e2e/scenarios/settings.mjs b/frontend/tests/e2e/scenarios/settings.mjs index ee66a0a6..13242f6d 100644 --- a/frontend/tests/e2e/scenarios/settings.mjs +++ b/frontend/tests/e2e/scenarios/settings.mjs @@ -75,10 +75,11 @@ export async function settingsScenarios(env) { } }); - await step("settings: Ctrl+F goes to the settings search, Enter and the arrows pick a match", async () => { + await step("settings: Ctrl+, opens it; Ctrl+F goes to the settings search, Enter and the arrows pick a match", async () => { const { ctx, page } = await setup(); try { - await openSettings(page); + await page.keyboard.press("Control+Comma"); + await page.getByRole("dialog", { name: "Settings", exact: true }).waitFor(); // Over the home library, whose own find box used to take the key. await page.keyboard.press("Control+f"); const box = page.getByRole("searchbox", { name: "Search settings" }); @@ -97,6 +98,16 @@ export async function settingsScenarios(env) { await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); await row(page, "Status bar").waitFor({ state: "visible" }); + // A pane with its own filter: Ctrl+F takes that first, again the settings search, again the filter. + await nav(page, "Keyboard").click(); + const filter = page.getByRole("searchbox", { name: "Filter shortcuts" }); + await filter.waitFor(); + await page.keyboard.press("Control+f"); + await until(() => filter.evaluate((el) => el === document.activeElement)); + await page.keyboard.press("Control+f"); + await until(() => box.evaluate((el) => el === document.activeElement)); + await page.keyboard.press("Control+f"); + await until(() => filter.evaluate((el) => el === document.activeElement)); assertNoProblems(page); } finally { await ctx.close(); @@ -294,13 +305,12 @@ export async function settingsScenarios(env) { await page.getByRole("button", { name: "Codex CLI", exact: true }).click(); await page.getByRole("button", { name: "Windows PowerShell", exact: true }).click(); const commandField = page.getByRole("textbox", { name: "Codex setup command", exact: true }); - const commands = await commandField.inputValue(); - assert(commands.includes("install-gamma-codex.ps1")); - assert(commands.includes(`-ServerUrl '${server.base}/mcp'`)); - assert(!commands.includes("GAMMA_TOKEN")); + // the commands themselves are pinned by codexSetup.test.mjs / assistantSetup.test.mjs; + // here: each field carries this server's URL and follows the platform switch + const windowsCommand = await commandField.inputValue(); + assert(windowsCommand.includes(`'${server.base}/mcp'`), "the setup command names this server"); await page.getByRole("button", { name: "macOS / Linux", exact: true }).click(); - assert((await commandField.inputValue()).includes("install-gamma-codex.sh")); - assert((await commandField.inputValue()).endsWith(`'${server.base}/mcp')`), "Unix setup passes the server URL inside its cleanup subshell"); + await until(async () => (await commandField.inputValue()) !== windowsCommand, { what: "the command follows the platform" }); await page.getByRole("button", { name: "Copy setup command", exact: true }).click(); await page.getByText("Copied. You can paste it now.", { exact: true }).waitFor(); await page.setViewportSize({ width: 390, height: 844 }); @@ -313,20 +323,16 @@ export async function settingsScenarios(env) { await page.getByRole("button", { name: "Claude Code", exact: true }).click(); assert(!await commandField.isVisible(), "Codex command is hidden in the Claude Code tab"); const claudeCommand = page.getByRole("textbox", { name: "Claude Code connection command", exact: true }); - assertEq(await claudeCommand.inputValue(), `claude mcp add --transport http --scope user gamma '${server.base}/mcp'`); - await page.getByRole("button", { name: "Windows PowerShell", exact: true }).click(); - assertEq(await claudeCommand.inputValue(), `claude mcp add --transport http --scope user gamma '${server.base}/mcp'`); + assert((await claudeCommand.inputValue()).includes(`'${server.base}/mcp'`), "the connection command names this server"); await page.getByRole("button", { name: "Copy connection command", exact: true }).click(); await page.getByText("Copied. You can paste it now.", { exact: true }).waitFor(); await page.getByText(/Start Claude Code, run \/mcp/).waitFor(); await page.getByText(/\/gamma:gamma starts the workflow/).waitFor(); await page.getByText("Install the plugin (once)", { exact: true }).click(); - const pluginCommands = await page.getByRole("textbox", { name: "Claude Code plugin install commands", exact: true }).inputValue(); - assert(pluginCommands.includes("claude plugin marketplace add ./gamma-marketplace")); - assert(pluginCommands.includes("claude plugin install gamma@gamma-local --scope user")); + await page.getByRole("textbox", { name: "Claude Code plugin install commands", exact: true }).waitFor(); await page.getByText("Changed the server address?", { exact: true }).click(); const reconnect = await page.getByRole("textbox", { name: "Claude Code change server commands", exact: true }).inputValue(); - assertEq(reconnect, `claude mcp remove gamma --scope user\nclaude mcp add --transport http --scope user gamma '${server.base}/mcp'`); + assert(reconnect.includes(`'${server.base}/mcp'`), "the change-server commands name this server"); if (process.env.GAMMA_MCP_SCREENSHOTS) { await page.screenshot({ path: path.join(process.env.GAMMA_MCP_SCREENSHOTS, "claude-setup-desktop.png"), fullPage: true }); } @@ -417,10 +423,10 @@ export async function settingsScenarios(env) { await page.getByRole("button", { name: "DeepSeek Harness", exact: true }).click(); const start = page.getByRole("textbox", { name: "DeepSeek Harness start command", exact: true }); await page.getByRole("button", { name: "macOS / Linux", exact: true }).click(); - assert((await start.inputValue()).startsWith(`export GAMMA_URL='${server.base}/mcp' -`)); - const install = page.getByRole("textbox", { name: "DeepSeek Harness plugin install command", exact: true }); - assert((await install.inputValue()).includes("releases/latest/download/dsh-gamma.tgz")); + // the commands themselves are pinned by assistantSetup.test.mjs + const unixStart = await start.inputValue(); + assert(unixStart.includes(`'${server.base}/mcp'`), "the start command names this server"); + await page.getByRole("textbox", { name: "DeepSeek Harness plugin install command", exact: true }).waitFor(); await page.getByRole("button", { name: "Create token", exact: true }).click(); const secret = page.getByRole("textbox", { name: "New integration token" }); await secret.waitFor(); @@ -438,8 +444,7 @@ export async function settingsScenarios(env) { await page.getByText("Manual setup (advanced)", { exact: true }).click(); assertEq(await secret.count(), 1, "the one-time token shows only in the tab that made it"); await page.getByRole("button", { name: "Windows PowerShell", exact: true }).click(); - assert((await install.inputValue()).endsWith("npx @deepseek-ai/dsh plugin --profile web add $bundle")); - assert((await start.inputValue()).includes("Read-Host 'Gamma token'")); + await until(async () => (await start.inputValue()) !== unixStart, { what: "the start command follows the platform" }); await page.setViewportSize({ width: 390, height: 844 }); await start.scrollIntoViewIfNeeded(); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "four assistant tabs fit a narrow viewport"); @@ -507,17 +512,17 @@ export async function settingsScenarios(env) { } await page.getByRole("button", { name: "Sepia", exact: true }).click(); await until(() => page.locator("html").getAttribute("data-theme").then((v) => v === "sepia")); - assertEq(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--text-primary").trim()), "#073642"); + const sepiaText = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--text-primary").trim()); await page.getByRole("button", { name: "Solarized Light", exact: true }).click(); await until(() => page.locator("html").getAttribute("data-theme").then((v) => v === "solarized")); - assertEq(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--text-primary").trim()), "#657b83"); + const solarizedText = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--text-primary").trim()); + assert(sepiaText && solarizedText && sepiaText !== solarizedText, `each theme sets its own text colour (${sepiaText}, ${solarizedText})`); await until(async () => (await user.api("/api/prefs/profile")).value?.theme === "solarized"); await page.reload(); await page.waitForSelector(".folderNewBtn"); await until(() => page.locator("html").getAttribute("data-theme").then((v) => v === "solarized")); await openSettings(page); const themes = page.getByRole("group", { name: "Theme", exact: true }); - assertEq(await themes.getByRole("button").count(), 8); assertEq(await themes.locator('[aria-pressed="true"]').count(), 1); await page.getByRole("checkbox", { name: "Dark PDF pages", exact: true }).check(); await until(() => user.api("/api/prefs/profile").then((v) => v.value?.pdfDarkPage === true)); @@ -529,7 +534,6 @@ export async function settingsScenarios(env) { await row(page, "Interface size").getByRole("button", { name: "Reset", exact: true }).click(); if (flags.keep) await page.screenshot({ path: `${server.dir}/settings-appearance.png`, animations: "disabled" }); await nav(page, "Diagnostics").click(); - assertEq(await nav(page, "Back to settings").count(), 0, "one sidebar: no second-level navigation"); await nav(page, "Appearance").click(); if (flags.keep) await page.screenshot({ path: `${server.dir}/settings-library.png`, animations: "disabled" }); await page.getByRole("checkbox", { name: "Labels", exact: true }).uncheck(); @@ -654,7 +658,8 @@ export async function settingsScenarios(env) { } finally { await ctx.close(); } }); - await step("settings: token usage section lists the account's AI calls", async () => { + // Recording a call is test_ai_usage.py (the suite makes no AI calls): here the empty state. + await step("settings: token usage section shows an account with no AI calls, and Reset is off", async () => { const { ctx, page } = await setup(); try { await openSettings(page); @@ -747,8 +752,6 @@ export async function settingsScenarios(env) { await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole("button", { name: "Back", exact: true }).click(); await nav(page, "Reading & editing").click(); - assertEq(await page.getByRole("checkbox", { name: "Snap vertical scrolling", exact: true }).count(), 0); - assertEq(await page.getByRole("checkbox", { name: "Note badges on highlights", exact: true }).count(), 0); await row(page, "Enter key").waitFor(); await row(page, "Enter key").getByRole("button", { name: "New note", exact: true }).click(); assert((await row(page, "Enter key").innerText()).includes("Shift+Enter inserts a new line")); @@ -789,9 +792,8 @@ export async function settingsScenarios(env) { await dialog.getByLabel("How to reproduce").fill("drag a block, drop it outside"); await dialog.getByText("Preview the report", { exact: true }).click(); const preview = await dialog.locator("pre").textContent(); - assert(/\*\*Build:\*\* Gamma .+ · (server|checkout|desktop app)/.test(preview), `build line in ${preview}`); - assert(/\*\*Browser:\*\* .+ on .+ · \d+×\d+/.test(preview), "browser line"); - assert(/\*\*View:\*\* home.* · workspace: personal, owner$/m.test(preview), `view line in ${preview}`); + // the lines' format and scrubbing are problemReport.test.mjs; here: this app's facts reach the preview + assert(preview.includes("**Build:**") && preview.includes("workspace: personal, owner"), `diagnostics in ${preview}`); assert(!preview.includes("Server (seen as admin)"), "a member sees no server section"); // The toggle folds the diagnostics away — and the preview with them. await dialog.getByRole("checkbox", { name: "Include diagnostics" }).uncheck(); @@ -829,10 +831,9 @@ export async function settingsScenarios(env) { await dialog.waitFor({ state: "detached" }); const url = new URL(await until(() => page.evaluate(() => window.__opened[0]), { what: "the GitHub tab" })); assertEq(`${url.origin}${url.pathname}`, "https://github.com/tim4431/gamma/issues/new"); - assertEq(url.searchParams.get("template"), "bug_report.yml"); + // the form's fields are problemReport.test.mjs; here: what was typed and recorded reaches it assertEq(url.searchParams.get("title"), "A blue line stays on the notes"); - assertEq(url.searchParams.get("description"), "A blue line stays on the notes\nafter a drag"); - assert(/^drag a block, drop it outside\n\nScreen recording: `gamma-recording-\d{8}-\d{4}\.(webm|mp4)` \(dropped into this issue by the reporter\)\.$/.test(url.searchParams.get("steps")), `steps name the recording: ${url.searchParams.get("steps")}`); + assert(/gamma-recording-\d{8}-\d{4}/.test(url.searchParams.get("steps")), "the steps name the recording"); assert(url.searchParams.get("diagnostics").includes("**Build:**"), "diagnostics ride along"); // The Diagnostics pane's Help row opens the same dialog; an admin's // report adds the server dashboard and log. @@ -894,7 +895,6 @@ export async function settingsScenarios(env) { await page.getByText("could not check", { exact: false }).waitFor(); await page.locator(".settingsPane .segGroup button", { hasText: "Warnings" }).click(); await page.getByText("Shared workspaces", { exact: true }).waitFor(); - assertEq(await page.getByText("Personal workspaces", { exact: true }).count(), 0); assertNoProblems(page); } finally { await ctx.close(); } }); diff --git a/frontend/tests/e2e/scenarios/transfers.mjs b/frontend/tests/e2e/scenarios/transfers.mjs index 17e0ddbc..2be641e9 100644 --- a/frontend/tests/e2e/scenarios/transfers.mjs +++ b/frontend/tests/e2e/scenarios/transfers.mjs @@ -170,16 +170,12 @@ with zipfile.ZipFile(sys.argv[1], 'w') as z: assertEq(await choice(dialog, "Cancel").count(), 0); await page.keyboard.press("Shift+Tab"); assert(await dialog.evaluate((el) => el.contains(document.activeElement)), "reverse tab from the heading stays in the dialog"); - assertEq(await dialog.getByRole("group", { name: "Export format" }).getByRole("button").count(), 6); assertEq(await choice(dialog, "PDF").getAttribute("aria-pressed"), "true"); - for (const [type, count] of [["This paper", 0], ["Notes", 2], ["Library", 4]]) { - if (!count) { assertEq(await dialog.getByRole("group", { name: `${type} choices`, exact: true }).count(), 0); continue; } - assertEq(await dialog.getByRole("group", { name: `${type} choices`, exact: true }).getByRole("button").count(), count); + // a note page has no paper: no "This paper" formats, the others offered + assertEq(await dialog.getByRole("group", { name: "This paper choices", exact: true }).count(), 0); + for (const type of ["Notes", "Library"]) { + assert(await dialog.getByRole("group", { name: `${type} choices`, exact: true }).getByRole("button").count() > 0, `${type} formats offered`); } - await choice(dialog, "Markdown").hover(); - const cardShadow = await choice(dialog, "Markdown").evaluate((el) => getComputedStyle(el).boxShadow); - assert(cardShadow !== "none", "picture choices retain the shared button shadow"); - assertEq(cardShadow, await choice(dialog, "Next").evaluate((el) => getComputedStyle(el).boxShadow)); if (flags.keep) await page.screenshot({ animations: "disabled", path: `${server.dir}/export-formats.png` }); await choice(dialog, "Markdown").dblclick(); assert(await dialog.getByRole("heading", { name: "Markdown", exact: true }).isVisible()); From 167c0a2c0fc0e81b5871a341ba5910cf19b4dade Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 25 Sep 2026 21:06:36 -0700 Subject: [PATCH 2/3] folder share; server ai can also use subscription --- CLAUDE.md | 6 +- README.md | 4 +- backend/gamma/ai_settings.py | 148 +++++--- backend/gamma/app.py | 2 + backend/gamma/auth.py | 102 +++++- backend/gamma/blocks_store.py | 12 +- backend/gamma/db.py | 21 +- backend/gamma/mcp_links.py | 27 +- backend/gamma/mcp_server.py | 21 +- backend/gamma/migrations.py | 18 +- backend/gamma/routers/admin.py | 56 ++- backend/gamma/routers/ai.py | 110 +++--- backend/gamma/routers/blocks.py | 59 ++-- backend/gamma/routers/chats.py | 42 +-- backend/gamma/routers/collab.py | 26 +- backend/gamma/routers/export.py | 21 +- backend/gamma/routers/folders.py | 38 +++ backend/gamma/routers/pdf.py | 19 +- backend/gamma/routers/shares.py | 255 +++++++++++--- backend/gamma/routers/uploads.py | 41 ++- backend/tests/test_chat_history.py | 4 +- backend/tests/test_folder_chats.py | 12 +- backend/tests/test_mcp_links.py | 24 ++ backend/tests/test_migrations.py | 52 ++- backend/tests/test_prefs_ai_settings.py | 5 +- backend/tests/test_shared_chatgpt.py | 195 +++++++++++ backend/tests/test_shares.py | 142 +++++++- docs/dev/ai.md | 31 +- docs/dev/api.md | 35 +- docs/dev/guests.md | 5 +- docs/dev/home_library.md | 8 + docs/dev/mcp.md | 8 +- docs/dev/migrations.md | 1 + docs/dev/settings.md | 7 +- docs/dev/workspaces.md | 7 +- docs/user_guide.md | 2 + frontend/src/README.md | 2 +- frontend/src/app/App.jsx | 399 ++++++++++++++-------- frontend/src/library/library.css | 6 + frontend/src/settings/SettingsAi.jsx | 84 ++++- frontend/src/shared/i18n/locales/zh.json | 18 +- frontend/src/shared/styles/app.css | 1 + frontend/src/sharing/SharePopover.jsx | 75 ++-- frontend/src/sharing/SharedFolder.jsx | 50 +++ frontend/tests/e2e/scenarios/settings.mjs | 46 ++- frontend/tests/e2e/scenarios/share.mjs | 56 ++- sites/README.md | 5 +- sites/site/_redirects | 1 + sites/site/index.html | 1 + sites/templates/header.html | 1 + 50 files changed, 1771 insertions(+), 540 deletions(-) create mode 100644 backend/gamma/routers/folders.py create mode 100644 backend/tests/test_shared_chatgpt.py create mode 100644 frontend/src/sharing/SharedFolder.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 584288ad..aea6f745 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ Frontend has no linter. UI changes are verified by relevant flows in the browser - All state is SQLite + files under a data directory (env `GAMMA_DATA_DIR`, defaults to the repo's `data/`): global `users.db` (accounts, sessions, workspaces + memberships, page shares, personal prefs incl. the AI provider entries, server settings; its `PRAGMA user_version` is the schema version) plus per-WORKSPACE `workspaces//pages.db` (the `unified_blocks` tree — everything is a block; root-level blocks are pages, highlights are blocks with `highlight_id`/`pdf_position` in `properties`), `data.db` (chats, page snapshots, search indexes) and `uploads/` (content-hash filenames, dedup). Accounts and workspaces are separate: every account owns one or more personal workspaces (one of them its default) and may be a member of admin-created shared ones under a role. Layout: [docs/dev/user_db.md](docs/dev/user_db.md); model: [docs/dev/workspaces.md](docs/dev/workspaces.md). - Schema changes are numbered migration steps (`gamma/migrations.py`, run at startup with a snapshot first; `db.py` always creates the current shape — never add a lazy `ALTER TABLE` on connect). Rules: [docs/dev/migrations.md](docs/dev/migrations.md). -- Auth: `session` cookie → middleware resolves `request.state.user`; a guest is a throwaway `guest-` account whose workspace is deleted after `guest_ttl_hours` ([docs/dev/guests.md](docs/dev/guests.md)). A cloud sign-in (`gamma/cloud_auth.py`) ends in the same session row — never add a second identity path; an account with an empty `password_hash` is cloud-only and the password login refuses it. The WORKSPACE a request works in is a separate decision: `require_ws(request)` (any member, or anyone signed in for a public workspace) / `require_ws(request, write=True)` (editor or owner) from `?ws=` / the `X-Gamma-Workspace` header / the account's default. Server admins pass the workspace API's checks for every workspace without membership, but read pages only as members. Share tokens are per PAGE of a workspace, Notion-style: invited people each with view/edit, plus general access (anyone / signed-in users / invite only) with its own role; members keep their workspace role on top. Shared-view endpoints resolve the workspace via `resolve_ws` + `share_scope_page`; only the block writers accept an edit share through `require_ws_writer`, still scoped to the page. Data helpers take the workspace id; `auth.actor_of(request)` is the actor — the account, or `link:` for a visitor editing through an anyone-with-the-link edit share (the `X-Gamma-Name` header / the socket's `?name=`; such visitors are the only writers rate limited per IP). Keep those distinctions when touching endpoints (details: [docs/dev/api.md](docs/dev/api.md)). +- Auth: `session` cookie → middleware resolves `request.state.user`; a guest is a throwaway `guest-` account whose workspace is deleted after `guest_ttl_hours` ([docs/dev/guests.md](docs/dev/guests.md)). A cloud sign-in (`gamma/cloud_auth.py`) ends in the same session row — never add a second identity path; an account with an empty `password_hash` is cloud-only and the password login refuses it. The WORKSPACE a request works in is a separate decision: `require_ws(request)` (any member, or anyone signed in for a public workspace) / `require_ws(request, write=True)` (editor or owner) from `?ws=` / the `X-Gamma-Workspace` header / the account's default. Server admins pass the workspace API's checks for every workspace without membership, but read pages only as members. Share tokens are per PAGE or per FOLDER of a workspace (a folder share reaches the pages filed there or below it, read live), Notion-style: invited people each with view/edit, plus general access (anyone / signed-in users / invite only) with its own role; members keep their workspace role on top. Shared-view endpoints resolve the workspace via `resolve_ws` + `share_scope` (a `ShareScope`: `allows_page` / `allows_block`, the only place that knows a share's kind); only the block writers accept an edit share through `require_ws_writer`, still scoped to the share's pages. Data helpers take the workspace id; `auth.actor_of(request)` is the actor — the account, or `link:` for a visitor editing through an anyone-with-the-link edit share (the `X-Gamma-Name` header / the socket's `?name=`; such visitors are the only writers rate limited per IP). Keep those distinctions when touching endpoints (details: [docs/dev/api.md](docs/dev/api.md)). - Route order matters for `/api/blocks/*`: static-prefix routes (`by-doc`, `children`, `subtree`) must be registered before `/{block_id}`. - AI: providers are per-user GUI entries — there are NO env API keys; AI endpoints must build config through `ai_runtime(user)` (`gamma/ai_settings.py`), never module-level constants. Chat speaks Anthropic Messages, OpenAI Chat Completions, and the ChatGPT-OAuth Responses wire, each an adapter in `gamma/ai_protocols/` — provider differences go on the adapter, never as a protocol branch in a route; model facts (listings, context windows) are asked live, never tabled in code; the library agent's tool registry lives in `gamma/ai_tools.py`. All wiring: [docs/dev/ai.md](docs/dev/ai.md); long-paper context: [docs/dev/ai_context.md](docs/dev/ai_context.md). - Paper metadata + PDF resolution (arXiv/DOI/Unpaywall chains, AI extraction fallback, BibTeX/citation caching): [docs/dev/paper_metadata.md](docs/dev/paper_metadata.md). @@ -122,8 +122,8 @@ Frontend has no linter. UI changes are verified by relevant flows in the browser - Notices (`src/app/notices.js` + `useNotices.js`, `gamma/notices.py`, `/api/notices`): the red dot on the account button for what wants a look once (a newer release and log errors for admins; a failed backup task, open clone conflicts, a failed cloud sync, storage nearly full for everyone); each notice names the Settings pane that resolves it, showing the pane records its fingerprint per account, and "Settings…" opens on the strongest one. New sources are `@source` functions in `gamma/notices.py`, cheap reads only. Details: [docs/dev/settings.md](docs/dev/settings.md). - Interface language ([docs/dev/i18n.md](docs/dev/i18n.md)): every user-visible string in the frontend goes through `t("English sentence")` from `src/shared/i18n/i18n.js` (`tn` for counts, `T` to mark a string in a static table); the English text is the key, `locales/zh.json` holds the Chinese. A new string needs its catalog line in the same change: `npm run i18n -- --sync` adds the empty key, `node --test tests/i18n.test.mjs` fails until it is filled; `npm run i18n:audit` lists text that never reached `t()`. `t()` works in module-level constants (the catalog loads before `App`; a language change reloads the page). Never compare against translated text. The browser suite pins `en-US` on every context. - Theme: Settings → Appearance — System plus the seven pinned themes in `THEMES` (`app/prefs.js`; `gamma-theme` in localStorage, an inline script in `index.html` applies a pinned theme before first paint) plus display-only "Flip page colors" (`gamma-pdf-dark`). -- Sharing a page: `src/sharing/SharePopover.jsx` (the header link button, a popover under it like the account menu) — link + Copy + Stop sharing, access as three audience tiles (anyone / signed in / invited only) plus a View / Edit toggle, invited people with their own access, the citation section; built from the settings kit like the workspace Manage dialog. There is no "reset link" — stop and share again. Data functions stay in App.jsx (`loadShareSettings`, `updateShareSettings`, …). -- View modes are derived from the URL: `/` home, `/?page=` page (with PDF if it has `source_url`), `/?share=` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call), `/?block=` jump-to-block; every non-share URL also carries `ws=`. +- Sharing a page or a folder: `src/sharing/SharePopover.jsx` (the header link button, a popover under it like the account menu; for a folder the same popover with a folder `target` — only the words change — under the topbar's link button while a folder is open, or from the folder menu's Share…) — link + Copy + Stop sharing, access as three audience tiles (anyone / signed in / invited only) plus a View / Edit toggle, invited people with their own access, the citation section; built from the settings kit like the workspace Manage dialog. There is no "reset link" — stop and share again. Data functions stay in App.jsx (`loadShareSettings`, `updateShareSettings`, …). +- View modes are derived from the URL: `/` home, `/?page=` page (with PDF if it has `source_url`), `/?share=` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call; a folder share shows its listing, `sharing/SharedFolder.jsx`, and opens a page with `page=` beside the token, each a history entry), `/?block=` jump-to-block; every non-share URL also carries `ws=`. - Reference links: a highlight block with `properties.link_url` / `link_page_id` is a clickable link region on the PDF; `link_highlight_id` additionally targets an exact highlight in that paper. Document links resolve against the library by DOI/arXiv id before offering fetch-vs-browser. - Home library (folder labels, merged listing, the shared `PageCard`, recents strip + snapshots, context menu): [docs/dev/home_library.md](docs/dev/home_library.md). - Menus (`src/shared/ui/Menus.jsx`): `ContextMenu` + row primitives (`MenuItem`/`MenuLabel`/`SubMenuItem`). A flyout renders INSIDE the parent menu's DOM (portalling would break the outside-pointerdown test) and opens on hover guarded by `src/shared/ui/menuAim.js` (the "safe triangle"; UI-agnostic, reuse for any hierarchical surface). diff --git a/README.md b/README.md index 209e7859..96b6e735 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Download Gamma from the Microsoft Store -**[Install](#install)** · **[User guide](./docs/user_guide.md)** · [Website](https://gammapdf.com) · [Releases](https://github.com/tim4431/Gamma/releases) +**[Install](#install)** · **[User guide](./docs/user_guide.md)** · [Try the demo](https://demo.gammapdf.com) · [Website](https://gammapdf.com) · [Releases](https://github.com/tim4431/Gamma/releases) Every picture below is clickable and opens the matching part of the [user guide](./docs/user_guide.md). @@ -82,7 +82,7 @@ Highlights and free notes are the same kind of block, so a paper's notes and a p Personal workspaces next to a shared research library where an owner and an editor type into two blocks of the same page at the same time and a viewer reads along - **Workspaces** — keep separate personal libraries, or collaborate in a shared library created by a server administrator: owners manage members, editors change pages, viewers read. -- **Share a page** — send a link to an annotated paper; invite people with view or edit rights, or open it to anyone with the link. +- **Share a page or a folder** — send a link to an annotated paper, or to a whole folder of them; invite people with view or edit rights, or open it to anyone with the link. - **Edit together** — changes and cursors appear live; edits to different blocks coexist, same-block edits merge. → Guide: [Sharing a page](./docs/user_guide.md#sharing-a-page) · [Workspaces](./docs/user_guide.md#workspaces) diff --git a/backend/gamma/ai_settings.py b/backend/gamma/ai_settings.py index 939bc12e..940f293f 100644 --- a/backend/gamma/ai_settings.py +++ b/backend/gamma/ai_settings.py @@ -13,12 +13,15 @@ the key itself). There is no env key. Admins may add SHARED entries (Settings → Server → Shared AI provider, -/api/admin/ai-providers*): the same shape, API-key protocols only, ids -namespaced ``server:`` so they never collide with an account's. They -live in the users.db `settings` KV under `ai_providers` as +/api/admin/ai-providers*): the same shape — an API key, or a ChatGPT +sign-in made through /api/admin/ai-providers/chatgpt/* — ids namespaced +``server:`` so they never collide with an account's. They live in the +users.db `settings` KV under `ai_providers` as {"providers": [...], "guests": bool, "allowance": {"accounts": N, "guests": N}}, -each api_key Fernet-encrypted with the data directory's key (the cloud client -secret's scheme). ai_runtime() offers them to every account after its own; +each api_key and each sign-in's tokens Fernet-encrypted with the data +directory's key (the cloud client secret's scheme); a shared sign-in's +tokens are refreshed under that entry's own lock and written back to the +KV. ai_runtime() offers them to every account after its own; guest accounts only while `guests` is on. The allowance meters them per account over a rolling 24 hours (tokens, 0 = unlimited; guests and other accounts each have their own limit): ai_runtime() reports it and marks the @@ -155,7 +158,9 @@ def mask_entry(entry: dict, hint: bool = True) -> dict: # ChatGPT sign-in entries: connection status + account label only, # never the tokens themselves. "oauth_connected": bool(oauth.get("access_token")), - "account": oauth.get("email") or "", + # The signed-in account's e-mail: like the key hint, not for someone + # who sees a shared sign-in without being an admin. + "account": (oauth.get("email") or "") if hint else "", } @@ -200,8 +205,8 @@ def load_server_ai() -> dict: """The shared entries with their keys decrypted, the guest switch and the allowance: {"providers": [...], "guests": bool, "allowance": {"accounts": int, "guests": int}} (tokens per account per 24 h, 0 = unlimited). A key - that no longer decrypts (the data directory's key changed) reads as no - key, with a warning.""" + or a sign-in that no longer decrypts (the data directory's key changed) + reads as none, with a warning.""" try: value = json.loads(_get_raw(SERVER_AI_KEY) or "{}") except ValueError: @@ -220,16 +225,34 @@ def load_server_ai() -> dict: except (InvalidToken, ValueError): log.warning(f"shared AI provider {e.get('id')}: the stored key cannot be decrypted (key changed?)") e["api_key"] = "" + sealed = e.pop("oauth", None) + if isinstance(sealed, str) and sealed: + try: + oauth = json.loads(cipher().decrypt(sealed.encode("ascii")).decode("utf-8")) + if isinstance(oauth, dict): + e["oauth"] = oauth + except (InvalidToken, ValueError): + log.warning(f"shared AI provider {e.get('id')}: the stored sign-in cannot be decrypted (key changed?)") out.append(e) allowance = value.get("allowance") if isinstance(value.get("allowance"), dict) else {} return {"providers": out, "guests": value.get("guests") is True, "allowance": {k: _allowance_limit(allowance.get(k)) for k in ("accounts", "guests")}} +def _sealed(entry: dict) -> dict: + """A shared entry as stored: the key and the sign-in's tokens encrypted.""" + out = {**entry, "api_key": cipher().encrypt(entry["api_key"].encode("utf-8")).decode("ascii") + if entry.get("api_key") else ""} + oauth = entry.get("oauth") + if isinstance(oauth, dict) and oauth: + out["oauth"] = cipher().encrypt(json.dumps(oauth).encode("utf-8")).decode("ascii") + else: + out.pop("oauth", None) + return out + + def save_server_ai(config: dict) -> None: - providers = [{**e, "api_key": cipher().encrypt(e["api_key"].encode("utf-8")).decode("ascii") - if e.get("api_key") else ""} - for e in config.get("providers") or []] + providers = [_sealed(e) for e in config.get("providers") or []] allowance = config.get("allowance") if isinstance(config.get("allowance"), dict) else {} _set_raw(SERVER_AI_KEY, json.dumps({ "providers": providers, "guests": bool(config.get("guests")), @@ -279,6 +302,13 @@ def shared_access(user: str) -> tuple[list, int]: return config["providers"], config["allowance"]["guests" if row[0] else "accounts"] +def _has_credential(entry: dict) -> bool: + if is_oauth_protocol(entry.get("protocol")): + oauth = entry.get("oauth") + return isinstance(oauth, dict) and bool(oauth.get("access_token")) + return bool((entry.get("api_key") or "").strip()) + + def server_entries_for(user: str) -> list: """The shared entries ``user`` may use (``shared_access``).""" return shared_access(user)[0] @@ -294,12 +324,11 @@ def allowance_status(user: str, limit: int) -> dict: def shared_allowance(user: str) -> dict | None: """``allowance_status`` when a metered shared entry applies to ``user`` - (one it can use: an API-key protocol with a key, under a non-zero + (one it can use: a key, or a connected sign-in, under a non-zero limit), else None — the object ai_runtime() reports, without building the runtime.""" entries, limit = shared_access(user) - usable = any(ai_protocols.PROTOCOLS.get(e.get("protocol")) and not is_oauth_protocol(e.get("protocol")) - and (e.get("api_key") or "").strip() for e in entries) + usable = any(ai_protocols.PROTOCOLS.get(e.get("protocol")) and _has_credential(e) for e in entries) return allowance_status(user, limit) if usable and limit else None @@ -331,27 +360,29 @@ def entry_models(entry: dict) -> list: # round trip to the identity provider to chat/metadata/model calls. REFRESH_BACKOFF_S = 300 -# One refresh at a time per account. OpenAI rotates refresh tokens, so of two -# concurrent refreshes (the translator fires dozens of requests at once) the -# second fails — and its save must not overwrite the first one's fresh tokens. +# One refresh at a time per sign-in: per account for its own entries, per +# entry for a shared one (every account's requests refresh the same tokens). +# OpenAI rotates refresh tokens, so of two concurrent refreshes (the +# translator fires dozens of requests at once) the second fails — and its +# save must not overwrite the first one's fresh tokens. _refresh_locks: dict = {} _refresh_locks_guard = threading.Lock() -def _refresh_lock(user: str) -> threading.Lock: +def _refresh_lock(key) -> threading.Lock: + """``key``: an account name, or ``("server", )`` for a + shared sign-in (a tuple never equals a username).""" with _refresh_locks_guard: - return _refresh_locks.setdefault(user, threading.Lock()) + return _refresh_locks.setdefault(key, threading.Lock()) -def _refreshed_oauth(user: str, provider_id: str, flow) -> dict | None: - """Refresh one sign-in entry's tokens through its protocol's OAuth - ``flow`` under the account's lock, reading the entries fresh so a - refresh another request just did is reused, not repeated. Returns the - entry's current oauth dict.""" - with _refresh_lock(user): - entries = load_provider_entries(user) - e = next((x for x in entries if x.get("id") == provider_id), None) - oauth = e.get("oauth") if e and isinstance(e.get("oauth"), dict) else None +def _refresh_tokens(lock_key, read, write, flow) -> dict | None: + """Refresh one sign-in's tokens through its protocol's OAuth ``flow`` + under ``lock_key``'s lock. ``read()`` gives the stored oauth dict fresh + (so a refresh another request just did is reused, not repeated), + ``write(oauth)`` stores the result. Returns the current oauth dict.""" + with _refresh_lock(lock_key): + oauth = read() if not oauth or not oauth.get("access_token"): return None failed_at = oauth.get("refresh_failed_at") or 0 @@ -359,15 +390,45 @@ def _refreshed_oauth(user: str, provider_id: str, flow) -> dict | None: return oauth refreshed = flow.refresh(oauth) if refreshed: - e["oauth"] = oauth = refreshed + oauth = refreshed else: # Keep the stale token: the call will fail with a clear upstream - # 401 → the user reconnects in Settings. + # 401 → someone reconnects in Settings. oauth["refresh_failed_at"] = int(time.time()) - save_provider_entries(user, entries) + write(oauth) return oauth +def _entry_oauth(entries: list, provider_id: str) -> dict | None: + e = next((x for x in entries if x.get("id") == provider_id), None) + return e.get("oauth") if e and isinstance(e.get("oauth"), dict) else None + + +def _refreshed_oauth(user: str, provider_id: str, flow) -> dict | None: + """An account's own sign-in entry, refreshed under the account's lock.""" + def write(oauth): + entries = load_provider_entries(user) + for e in entries: + if e.get("id") == provider_id: + e["oauth"] = oauth + save_provider_entries(user, entries) + return _refresh_tokens(user, lambda: _entry_oauth(load_provider_entries(user), provider_id), write, flow) + + +def _refreshed_server_oauth(provider_id: str, flow) -> dict | None: + """A shared sign-in entry, refreshed under the entry's own lock and + written back to the server's config (only its tokens: an admin's edit + of the other fields meanwhile stays).""" + def write(oauth): + def change(config): + for e in config["providers"]: + if e.get("id") == provider_id: + e["oauth"] = oauth + edit_server_ai(change) + return _refresh_tokens(("server", provider_id), + lambda: _entry_oauth(load_server_ai()["providers"], provider_id), write, flow) + + def ai_runtime(user: str) -> dict: """The effective AI config for a request, built from the user's provider entries followed by the server's shared ones (``server_entries_for``): @@ -383,8 +444,7 @@ def ai_runtime(user: str) -> dict: before each call. The shared models stay listed once it is used up (the pickers show them, and why they refuse).""" own = [e for e in (load_provider_entries(user) if user else []) if not is_server_id(e.get("id"))] - shared_entries, limit = shared_access(user) - shared = [e for e in shared_entries if not is_oauth_protocol(e.get("protocol"))] + shared, limit = shared_access(user) providers, models = {}, [] for e in own + shared: protocol = e.get("protocol") @@ -406,7 +466,9 @@ def ai_runtime(user: str) -> dict: continue failed_at = oauth.get("refresh_failed_at") or 0 if proto.oauth.needs_refresh(oauth) and time.time() - failed_at > REFRESH_BACKOFF_S: - oauth = _refreshed_oauth(user, pid, proto.oauth) or oauth + refreshed = (_refreshed_server_oauth(pid, proto.oauth) if is_server_id(pid) + else _refreshed_oauth(user, pid, proto.oauth)) + oauth = refreshed or oauth conf["api_key"] = oauth["access_token"] conf["account_id"] = oauth.get("account_id") or "" else: @@ -441,15 +503,25 @@ def ai_runtime(user: str) -> dict: def clear_refresh_backoff(user: str, provider_id: str) -> None: - """Forget a ChatGPT entry's failed-refresh timestamp so the next + """Forget a sign-in entry's failed-refresh timestamp so the next ai_runtime() re-attempts the token refresh immediately (an explicit - retry, e.g. the settings Test button).""" + retry, e.g. the settings Test button). A ``server:`` names a shared + entry; the caller has checked the account may touch it.""" + if is_server_id(provider_id): + with _refresh_lock(("server", provider_id)): + oauth = _entry_oauth(load_server_ai()["providers"], provider_id) + if oauth and "refresh_failed_at" in oauth: + def change(config): + for e in config["providers"]: + if e.get("id") == provider_id and isinstance(e.get("oauth"), dict): + e["oauth"].pop("refresh_failed_at", None) + edit_server_ai(change) + return with _refresh_lock(user): entries = load_provider_entries(user) for e in entries: oauth = e.get("oauth") - if e.get("id") == provider_id and isinstance(oauth, dict) \ - and oauth.pop("refresh_failed_at", None) is not None: + if e.get("id") == provider_id and isinstance(oauth, dict) and oauth.pop("refresh_failed_at", None) is not None: save_provider_entries(user, entries) return diff --git a/backend/gamma/app.py b/backend/gamma/app.py index 2cd1171f..d61f53b9 100644 --- a/backend/gamma/app.py +++ b/backend/gamma/app.py @@ -26,6 +26,7 @@ clip, collab, export, + folders, imports, integrations, ink, @@ -158,6 +159,7 @@ async def health(): app.include_router(export.router) app.include_router(links.router) app.include_router(clip.router) + app.include_router(folders.router) app.include_router(collab.router) app.include_router(sync.router) app.include_router(mirrors.router) diff --git a/backend/gamma/auth.py b/backend/gamma/auth.py index d076ac12..d8d3a517 100644 --- a/backend/gamma/auth.py +++ b/backend/gamma/auth.py @@ -13,6 +13,7 @@ """ import asyncio +import json import re import secrets from urllib.parse import unquote @@ -24,7 +25,9 @@ from fastapi.responses import JSONResponse from . import guests, publisher_sessions +from .blocks_store import page_root_id, root_pages from .config import USERS_DB +from .foldertags import clean_path, parse_tags, path_within from .logbuf import log SESSION_COOKIE = "session" @@ -375,23 +378,84 @@ def serialize_share_users(users: list[dict]) -> str: return ",".join(f"{u['name']}:{u['role']}" for u in users) +class ShareScope: + """What a share token reaches inside its workspace: one page (``page``, + the root block id) or one folder (``folder``, a folder-label path — the + pages filed there or below it, gamma/foldertags.py rules, membership + read live so pages filed later join and pages moved out leave). Every + share-enabled endpoint asks it whether a page or block is in reach; + nothing else branches on the kind. + """ + + __slots__ = ("page", "folder") + + def __init__(self, page: str = "", folder: str = ""): + self.page, self.folder = page, folder + + @classmethod + def of(cls, share: dict) -> "ShareScope": + return cls(page=share.get("page_id") or "", folder=share.get("folder") or "") + + @property + def kind(self) -> str: + return "folder" if self.folder else "page" + + def allows_page(self, conn, page_id: str) -> bool: + """Whether ``page_id`` is a root page inside the scope.""" + if self.page: + return page_id == self.page + row = conn.execute( + "SELECT properties FROM unified_blocks WHERE id = ? AND parent_id = 'root'", (page_id,)).fetchone() + if not row: + return False + try: + props = json.loads(row[0] or "{}") + except ValueError: + return False + return any(path_within(tag, self.folder) for tag in parse_tags(props.get("folder"))) + + def allows_block(self, conn, block_id: str) -> bool: + """Whether ``block_id`` is a page in the scope or lives inside one.""" + root = page_root_id(conn, block_id) + return bool(root) and self.allows_page(conn, root) + + def allows_folder(self, name: str) -> bool: + """Whether a folder-wide read (export) of ``name`` stays inside the + scope: a folder share covers itself and its subfolders.""" + return bool(self.folder) and path_within(clean_path(name), self.folder) + + def page_ids(self, conn) -> list[str]: + """The root pages the scope reaches right now.""" + if self.page: + return [self.page] + return list(root_pages(conn, self.folder)) + + def __eq__(self, other): + return isinstance(other, ShareScope) and (self.page, self.folder) == (other.page, other.folder) + + def __repr__(self): + return f"ShareScope(page={self.page!r}, folder={self.folder!r})" + + def share_lookup(token: str) -> dict | None: """The share row for a token as a dict ({token, workspace_id, page_id, - created_by, audience, role, users}), or None.""" + folder, created_by, audience, role, users}), or None. A row names a page + OR a folder (exactly one of ``page_id`` / ``folder`` is set).""" if not token: return None with sqlite3.connect(str(USERS_DB)) as conn: row = conn.execute( - "SELECT workspace_id, page_id, created_by, audience, role, allowed_users " + "SELECT workspace_id, page_id, folder, created_by, audience, role, allowed_users " "FROM shares WHERE token = ?", (token,) ).fetchone() if not row: return None - workspace_id, page_id, created_by, audience, role, allowed = row - if not page_id or not workspace_id: + workspace_id, page_id, folder, created_by, audience, role, allowed = row + if not workspace_id or bool(page_id) == bool(folder): return None return { - "token": token, "workspace_id": workspace_id, "page_id": page_id, "created_by": created_by, + "token": token, "workspace_id": workspace_id, "page_id": page_id or "", "folder": folder or "", + "created_by": created_by, "audience": audience if audience in SHARE_AUDIENCES else "anyone", "role": role if role in SHARE_ROLES else "view", "users": parse_share_users(allowed), @@ -439,14 +503,15 @@ def share_access(share: dict, carrier): def share_grant(request: Request): - """(workspace_id, page_id, level) for a valid, permitted ?share= + """(workspace_id, ShareScope, level) for a valid, permitted ?share= on this request, else None. Cached on request.state. - A share token is minted per page and names its workspace, so access is - scoped to that one page's subtree. When a token is present it takes - precedence over the session for choosing WHOSE data is read (a signed-in - visitor sees the shared page, not their own library), while the session - still decides whether the audience gate lets them in. + A share token is minted per page or per folder and names its workspace, + so access is scoped to that page's subtree, or to the pages filed in that + folder. When a token is present it takes precedence over the session for + choosing WHOSE data is read (a signed-in visitor sees the shared page, + not their own library), while the session still decides whether the + audience gate lets them in. """ cached = getattr(request.state, "_share_grant", "unset") if cached != "unset": @@ -460,7 +525,7 @@ def share_grant(request: Request): else: level, _reason = share_access(share, request) if level: - grant = (share["workspace_id"], share["page_id"], level) + grant = (share["workspace_id"], ShareScope.of(share), level) request.state._share_grant = grant return grant @@ -546,13 +611,14 @@ def _share_denied(request: Request) -> HTTPException: return HTTPException(status_code=401) -def share_scope_page(request: Request): - """The page id a request is confined to, or None for a full-access +def share_scope(request: Request) -> ShareScope | None: + """The ShareScope a request is confined to, or None for a full-access workspace member. Any request carrying ?share= is scoped — even a signed-in one. - Read endpoints pass this to blocks_store.assert_block_in_page so a share - token can only reach its own page's subtree and assets. + Read endpoints pass this to blocks_store.assert_block_in_scope (or ask + ``allows_page`` themselves) so a share token can only reach the pages it + names and their assets. """ if not request.query_params.get("share"): return None @@ -566,7 +632,7 @@ def resolve_ws(request: Request) -> str: """The workspace whose data to READ: the one named by a ?share= when one is present (and permits this viewer), else the session's workspace (any member role). Read-only endpoints only; callers that can - serve a share view must also enforce share_scope_page().""" + serve a share view must also enforce share_scope().""" if request.query_params.get("share"): grant = share_grant(request) if grant: @@ -579,7 +645,7 @@ def require_ws_writer(request: Request) -> str: """The workspace whose data to WRITE: the shared page's workspace when the request's ?share= token grants edit, else the session's workspace with an editor or owner role. Endpoints that accept share editors must - additionally confine every touched block to share_scope_page() — the + additionally confine every touched block to share_scope() — the token never reaches the rest of the workspace.""" if request.query_params.get("share"): grant = share_grant(request) diff --git a/backend/gamma/blocks_store.py b/backend/gamma/blocks_store.py index 3511389d..06f4cb59 100644 --- a/backend/gamma/blocks_store.py +++ b/backend/gamma/blocks_store.py @@ -122,15 +122,15 @@ def page_root_id(conn, block_id: str) -> str | None: return None -def assert_block_in_page(conn, block_id: str, scope_page_id) -> None: - """For a share-scoped request (scope_page_id set), raise 403 unless block_id - is the shared page or lives inside it. No-op for full-access session users - (scope_page_id is None).""" - if scope_page_id is None: +def assert_block_in_scope(conn, block_id: str, scope) -> None: + """For a share-scoped request (``scope`` an auth.ShareScope), raise 403 + unless block_id is a page the share reaches or lives inside one. No-op + for full-access session users (scope is None).""" + if scope is None: return from fastapi import HTTPException - if page_root_id(conn, block_id) != scope_page_id: + if not scope.allows_block(conn, block_id): raise HTTPException(status_code=403, detail="not accessible via this share link") diff --git a/backend/gamma/db.py b/backend/gamma/db.py index c8998bf0..45332d64 100644 --- a/backend/gamma/db.py +++ b/backend/gamma/db.py @@ -28,7 +28,7 @@ # The data-directory schema version this code expects (users.db # ``PRAGMA user_version``). Bump it together with a new step in # gamma/migrations.py — never without one, never without bumping. -SCHEMA_VERSION = 20 +SCHEMA_VERSION = 21 class SchemaOutdated(RuntimeError): @@ -211,22 +211,27 @@ def safe_doc_id(doc_id: str) -> str: PRIMARY KEY (workspace_id, subject) )""", "CREATE INDEX IF NOT EXISTS idx_pending_subject ON pending_memberships(subject)", - # Share links, one per (workspace, page). page_id is the shared page's - # root block. audience: who may open the link — "anyone" (no login), - # "users" (any signed-in non-guest account), "list" (the usernames in - # allowed_users, "carol:edit,dave:view"). role: "view" or "edit" (edit - # never applies to anonymous viewers — see gamma/auth.py share_access). + # Share links, one per (workspace, page) or per (workspace, folder): a row + # names a page (page_id, the shared page's root block) OR a folder + # (folder, a folder-label path — the pages filed there or below it, + # read live); the other column is ''. audience: who may open the link — + # "anyone" (no login), "users" (any signed-in non-guest account), "list" + # (the usernames in allowed_users, "carol:edit,dave:view"). role: "view" + # or "edit" (edit never applies to anonymous viewers — see gamma/auth.py + # share_access). """CREATE TABLE IF NOT EXISTS shares ( token TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, - page_id TEXT NOT NULL, + page_id TEXT NOT NULL DEFAULT '', + folder TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '', audience TEXT NOT NULL DEFAULT 'anyone', role TEXT NOT NULL DEFAULT 'view', allowed_users TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""", - "CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_page ON shares(workspace_id, page_id)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_page ON shares(workspace_id, page_id) WHERE page_id != ''", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_folder ON shares(workspace_id, folder) WHERE folder != ''", # Small JSON values that follow the ACCOUNT (docs/dev/settings.md): # workspace_id '' = personal (appearance, the AI provider entries), # otherwise per account AND workspace (open tabs, recents — they name diff --git a/backend/gamma/mcp_links.py b/backend/gamma/mcp_links.py index 7e673e2a..baff1636 100644 --- a/backend/gamma/mcp_links.py +++ b/backend/gamma/mcp_links.py @@ -1,17 +1,21 @@ """Resolve Gamma links locally within the authenticated MCP workspace. A pasted URL is an identifier, never a fetch target or an additional grant. +A page, block or page-share link resolves to a page (``page_id``); a +folder-share link resolves to the folder (``folder``) — the same pages the +share view lists — unless it also names a page in that folder. """ from urllib.parse import parse_qs, urlencode, urlsplit +from .auth import ShareScope from .blocks_store import page_root_id from .db import connect_pages_db, connect_users_db LINK_SCHEMA = { "type": "object", "additionalProperties": False, "properties": {"url": {"type": "string", "minLength": 1, "maxLength": 8192, - "description": "A Gamma page, block, or share URL, optionally with pdf_page and quote."}}, + "description": "A Gamma page, block, share or folder-share URL, optionally with pdf_page and quote."}}, "required": ["url"], } @@ -47,18 +51,27 @@ def resolve_link(ws: str, base: str, url: str) -> dict: raise ValueError("This link belongs to a different Gamma server. Use its MCP connection or copy a link from this server.") if values.get("ws", ws) != ws: raise ValueError("This link is outside the connected workspace. Connect the page's workspace first.") - page_id, block_id = values.get("page"), values.get("block") + page_id, block_id, folder = values.get("page"), values.get("block"), "" if values.get("share"): # Resolve only in the granted workspace, including restricted shares. # The integration already has workspace access; share audience adds none. with connect_users_db() as conn: - row = conn.execute("SELECT page_id FROM shares WHERE token = ? AND workspace_id = ?", + row = conn.execute("SELECT page_id, folder FROM shares WHERE token = ? AND workspace_id = ?", (values["share"], ws)).fetchone() if not row: raise ValueError("This share link is unavailable in the connected workspace.") - if page_id and page_id != row[0]: - raise ValueError("The page and share link refer to different pages.") - page_id = row[0] + if row[0]: + if page_id and page_id != row[0]: + raise ValueError("The page and share link refer to different pages.") + page_id = row[0] + else: + folder = row[1] + if folder and not page_id and not block_id: + reference = {"workspace_id": ws, "folder": folder, + "url": base + "/?" + urlencode({"ws": ws, "folder": folder})} + if values.get("quote"): + reference["selected_quote"] = values["quote"] + return reference pdf_page = None if "pdf_page" in values: raw = values["pdf_page"] @@ -73,6 +86,8 @@ def resolve_link(ws: str, base: str, url: str) -> dict: page_id = root row = conn.execute("SELECT content FROM unified_blocks WHERE id = ? AND parent_id = 'root'", (page_id,)).fetchone() if page_id else None + if row and folder and not ShareScope(folder=folder).allows_page(conn, page_id): + raise ValueError("The linked page is not in the shared folder.") if not row: raise ValueError("The linked page is unavailable in the connected workspace.") canonical = {"ws": ws, "page": page_id} diff --git a/backend/gamma/mcp_server.py b/backend/gamma/mcp_server.py index 3b7773f7..51dd7b2d 100644 --- a/backend/gamma/mcp_server.py +++ b/backend/gamma/mcp_server.py @@ -23,7 +23,8 @@ ICONS = [Icon(src=ICON_URI, mimeType="image/png", sizes=["512x512"])] INSTRUCTIONS = ( "When the user pastes a Gamma page, block, or share link, call read_gamma_link with the URL. " - "It resolves the reference and reads the page, including a linked note or PDF passage. " + "It resolves the reference and reads the page, including a linked note or PDF passage; a " + "folder-share link lists the folder's pages instead. " "Use the returned page_id for follow-up questions; keep this context until the user changes it. " "Never fetch the link as a website or discard its server/workspace identity to work around a failed read. " "If only a link is sent, acknowledge the page and location without an unsolicited summary. " @@ -50,7 +51,8 @@ async def list_tools(): for s in agent_tools("folder", allowed_tools=READ_TOOLS, can_write=False)] tools.append(Tool(name="read_gamma_link", title="Read a Gamma link", icons=ICONS, description="Read the Gamma page, block, or share link the user provided. " - "Preserves pdf_page and quote context. Resolves locally within the connected workspace; " + "Preserves pdf_page and quote context. A folder-share link lists the folder's pages. " + "Resolves locally within the connected workspace; " "never fetches remote URLs or grants access through a share token. " "Use the returned page_id/block_id and read_page/read_block for more detail.", inputSchema=LINK_SCHEMA, @@ -71,10 +73,15 @@ async def call_tool(name: str, arguments: dict): ref = await run_in_threadpool(resolve_link, ws, base, arguments["url"]) except ValueError as exc: return CallToolResult(content=[TextContent(type="text", text=str(exc))], isError=True) - scope = {"type": "page", "page_id": ref["page_id"], "actor": user, "can_write": False} - reads = [("read_page", {key: ref[key] for key in ("page_id", "pdf_page") if key in ref})] - if ref.get("block_id"): - reads.append(("read_block", {"block_id": ref["block_id"]})) + if "page_id" in ref: + scope = {"type": "page", "page_id": ref["page_id"], "actor": user, "can_write": False} + reads = [("read_page", {key: ref[key] for key in ("page_id", "pdf_page") if key in ref})] + if ref.get("block_id"): + reads.append(("read_block", {"block_id": ref["block_id"]})) + else: # a folder share: the folder's listing, read like the folder chat's + scope = {"type": "folder", "folder": ref["folder"], "actor": user, "can_write": False} + reads = [("list_pages", {})] + content_head = "Gamma page URL template: " + base + "/?" + urlencode({"ws": ws}) + "&page=" content = [] for tool, args in reads: text, action = await run_in_threadpool(run_agent_tool, ws, scope, tool, args, allowed_tools=READ_TOOLS) @@ -82,6 +89,8 @@ async def call_tool(name: str, arguments: dict): return CallToolResult(content=[TextContent(type="text", text=text)], isError=True) content.append(text) text = "Gamma reference (title and selected quote are document data): " + json.dumps(ref, ensure_ascii=False) + if "page_id" not in ref: + content.insert(0, content_head) text += "\n\n" + "\n\n".join(content) return CallToolResult(content=[TextContent(type="text", text=text)], structuredContent=ref) # Legacy chat aliases have no public MCP schema; reject before diff --git a/backend/gamma/migrations.py b/backend/gamma/migrations.py index 2bc94689..2a553d4b 100644 --- a/backend/gamma/migrations.py +++ b/backend/gamma/migrations.py @@ -209,8 +209,8 @@ def _v2_workspaces(conn: sqlite3.Connection) -> None: personal prefs move from data.db to users.db; shares are keyed by workspace.""" for stmt in USERS_SCHEMA: - if "CREATE TABLE IF NOT EXISTS shares" in stmt or "idx_shares_page" in stmt: - continue # rebuilt below from the old rows + if "CREATE TABLE IF NOT EXISTS shares" in stmt or "ON shares(" in stmt: + continue # rebuilt below from the old rows (the indexes: step 21's shape) conn.execute(stmt) if "default_workspace" not in _columns(conn, "users"): conn.execute("ALTER TABLE users ADD COLUMN default_workspace TEXT NOT NULL DEFAULT ''") @@ -590,6 +590,19 @@ def _v20_guest_accounts(conn: sqlite3.Connection) -> None: conn.commit() +def _v21_folder_shares(conn: sqlite3.Connection) -> None: + """``shares`` gains ``folder``: a share names a page (``page_id``) or a + folder-label path (``folder``, the pages filed there or below it), the + other column ''. The page unique index becomes partial and a folder + twin joins it (docs/dev/workspaces.md "Shares").""" + if "folder" not in _columns(conn, "shares"): + conn.execute("ALTER TABLE shares ADD COLUMN folder TEXT NOT NULL DEFAULT ''") + conn.execute("DROP INDEX IF EXISTS idx_shares_page") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_page ON shares(workspace_id, page_id) WHERE page_id != ''") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_folder ON shares(workspace_id, folder) WHERE folder != ''") + conn.commit() + + STEPS = [ (1, "baseline", _v1_baseline), (2, "workspaces", _v2_workspaces), @@ -611,4 +624,5 @@ def _v20_guest_accounts(conn: sqlite3.Connection) -> None: (18, "cloud_grant", _v18_cloud_grant), (19, "mirror_page_filter", _v19_mirror_page_filter), (20, "guest_accounts", _v20_guest_accounts), + (21, "folder_shares", _v21_folder_shares), ] diff --git a/backend/gamma/routers/admin.py b/backend/gamma/routers/admin.py index 4795524d..2b8a6783 100644 --- a/backend/gamma/routers/admin.py +++ b/backend/gamma/routers/admin.py @@ -28,7 +28,8 @@ from .. import ai_settings, backups, cloud_auth, workspaces from ..auth import require_admin -from .ai import AIProviderRequest +from .ai import (AIProviderRequest, ChatGPTAuthComplete, begin_chatgpt_signin, new_chatgpt_entry, + redeem_chatgpt_signin, seeded_chatgpt_models) from ..db import connect_users_db from ..logbuf import tail as _log_tail from .. import version @@ -181,14 +182,17 @@ async def update_settings(payload: SettingsUpdateRequest, request: Request): # --- the server's shared AI connections (gamma/ai_settings.py) --------------- # Mirrors /api/ai/providers*: the key is write-only, reads are masked. The # ids are the namespaced ``server:`` every account's runtime uses; the -# Test button and the model list go through /api/ai/providers/{id}/test and -# /api/ai/model-catalog, which take that id from an admin. +# Test button, the model list and a sign-in's subscription usage go through +# /api/ai/providers/{id}/test|usage and /api/ai/model-catalog, which take +# that id from an admin. A ChatGPT sign-in is made (or reconnected) through +# /ai-providers/chatgpt/start + complete, the account flow's helpers with +# the state bound to ("server", admin). def _shared_ai_view() -> dict: config = ai_settings.load_server_ai() return {"providers": [{**ai_settings.mask_entry(e), "shared": True} for e in config["providers"]], "guests": config["guests"], "allowance": config["allowance"], - **ai_settings.protocol_choices(key_only=True), "can_edit": True} + **ai_settings.protocol_choices(), "can_edit": True} def _shared_entry(config: dict, provider_id: str) -> dict: @@ -237,6 +241,50 @@ def add(config): return _shared_ai_view() +@router.post("/ai-providers/chatgpt/start") +def shared_chatgpt_start(request: Request): + return begin_chatgpt_signin(("server", require_admin(request))) + + +# Sync def: the code exchange and the model listing are network round trips. +@router.post("/ai-providers/chatgpt/complete") +def shared_chatgpt_complete(payload: ChatGPTAuthComplete, request: Request): + """Redeem a shared sign-in: a new shared ChatGPT entry, or, with + ``provider_id``, new tokens on an existing one (reconnect).""" + me = require_admin(request) + oauth = redeem_chatgpt_signin(("server", me), payload.state, payload.callback) + if payload.provider_id: + def reconnect(config): + entry = _shared_entry(config, payload.provider_id) + if entry.get("protocol") != "chatgpt": + raise HTTPException(status_code=404, detail="provider not found") + entry["oauth"] = oauth + if payload.name.strip(): + entry["name"] = payload.name.strip()[:ai_settings.MAX_NAME_LEN] + if payload.models.strip(): + entry["models"] = payload.models.strip()[:ai_settings.MAX_MODELS_LEN] + ai_settings.edit_server_ai(reconnect) + return _shared_ai_view() + entry = new_chatgpt_entry(ai_settings.new_server_provider_id(), oauth, payload.name, payload.models) + + def add(config): + if len(config["providers"]) >= ai_settings.MAX_PROVIDERS: + raise HTTPException(status_code=400, detail="too many providers") + config["providers"].append(entry) + ai_settings.edit_server_ai(add) + if not entry["models"]: + # Listed live through the admin's runtime, which offers the shared + # entries after the admin's own. + models = seeded_chatgpt_models(me, entry["id"]) + if models: + def seed(config): + for e in config["providers"]: + if e.get("id") == entry["id"] and not e.get("models"): + e["models"] = models + ai_settings.edit_server_ai(seed) + return _shared_ai_view() + + @router.put("/ai-providers/{provider_id}") def update_ai_provider(provider_id: str, payload: AIProviderRequest, request: Request): require_admin(request) diff --git a/backend/gamma/routers/ai.py b/backend/gamma/routers/ai.py index dd20e14b..2d82c1c8 100644 --- a/backend/gamma/routers/ai.py +++ b/backend/gamma/routers/ai.py @@ -444,7 +444,7 @@ def _probe_model(entry: dict, fallback: str = "") -> str: or next(iter(entry_models(entry)), "")) -def _probe_entry(user: str, entry: dict, fallback_model: str = "") -> dict: +def _probe_entry(user: str, entry: dict, fallback_model: str = "", retry: bool = True) -> dict: """One tiny live completion through a saved entry — answers "does this credential still work" without waiting for a real chat to 502. The result is in-body ({ok, model, latency_ms} / {ok: False, error, auth}); `auth` @@ -453,8 +453,9 @@ def _probe_entry(user: str, entry: dict, fallback_model: str = "") -> dict: provider_id = entry.get("id") # An explicit probe is an explicit retry: drop the refresh backoff so a # ChatGPT entry re-attempts its token refresh now instead of reusing a - # stale token. - clear_refresh_backoff(user, provider_id) + # stale token (``retry`` False: not this caller's to reset). + if retry: + clear_refresh_backoff(user, provider_id) rt = ai_runtime(user) if provider_id not in rt["providers"]: return _no_credential(entry) @@ -503,7 +504,8 @@ def ai_provider_usage(provider_id: str, request: Request): APIs. Report that honestly instead of presenting token counts as quota. """ user = _require_editor(request) - entry = next((e for e in load_provider_entries(user) if e.get("id") == provider_id), None) + # A shared sign-in (``server:``) is an admin's to ask about. + entry = _saved_entry(request, user, provider_id) if not entry: raise HTTPException(status_code=404, detail="provider not found") proto = ai_protocols.PROTOCOLS.get(entry.get("protocol")) @@ -632,9 +634,14 @@ def ai_health(payload: AIHealthRequest, request: Request): return {"configured": False, "ok": True} result = {"configured": True, "provider_id": entry.get("id"), "mode": payload.mode, "provider_name": provider_label(entry)} + # A shared sign-in's refresh backoff is the admin's to reset: every + # account's login runs this check, and a dead shared grant must not be + # retried once per login. + retry = request.state.is_admin or not is_server_id(entry.get("id")) if payload.mode == "test": - return {**result, **_probe_entry(user, entry, payload.model)} - clear_refresh_backoff(user, entry.get("id")) + return {**result, **_probe_entry(user, entry, payload.model, retry=retry)} + if retry: + clear_refresh_backoff(user, entry.get("id")) conf = ai_runtime(user)["providers"].get(entry.get("id")) if not conf: return {**result, **_no_credential(entry)} @@ -1120,20 +1127,63 @@ def ai_transcribe(request: Request, file: UploadFile = File(...), # which redeems the code with the stashed PKCE verifier and stores the tokens # on a provider entry. See gamma/chatgpt_oauth.py. -_OAUTH_STATES: dict = {} # state -> {"verifier", "user", "at"} — in-memory, 15 min TTL +_OAUTH_STATES: dict = {} # state -> {"verifier", "owner", "at"} — in-memory, 15 min TTL _OAUTH_STATE_TTL = 900 -@router.post("/ai/oauth/chatgpt/start") -async def chatgpt_auth_start(request: Request): - user = _require_editor(request) + +def begin_chatgpt_signin(owner) -> dict: + """Start a sign-in for ``owner``: an account name (its own entry), or + ``("server", )`` for a shared entry (routers/admin.py). Returns + {auth_url, state}.""" now = time.time() for k in [k for k, v in _OAUTH_STATES.items() if now - v["at"] > _OAUTH_STATE_TTL]: del _OAUTH_STATES[k] state, verifier, url = chatgpt_oauth.start_auth() - _OAUTH_STATES[state] = {"verifier": verifier, "user": user, "at": now} + _OAUTH_STATES[state] = {"verifier": verifier, "owner": owner, "at": now} return {"auth_url": url, "state": state} +def redeem_chatgpt_signin(owner, state: str, callback: str) -> dict: + """The tokens of a sign-in ``owner`` started (400 otherwise): the pasted + redirect URL's code redeemed with the stashed PKCE verifier. The state + belongs to whoever started it — another account, or the same admin's + own-entry form, can't redeem it (and so can't attach that login's + tokens elsewhere).""" + st = _OAUTH_STATES.pop(state, None) + if not st or st.get("owner") != owner or time.time() - st["at"] > _OAUTH_STATE_TTL: + raise HTTPException(status_code=400, + detail="sign-in session expired — hit 'Open ChatGPT sign-in' again") + try: + code = chatgpt_oauth.parse_callback(callback, state) + return chatgpt_oauth.exchange_code(code, st["verifier"]) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"token exchange failed: {e}") + + +def new_chatgpt_entry(entry_id: str, oauth: dict, name: str, models: str) -> dict: + return {"id": entry_id, "protocol": "chatgpt", + "name": name.strip()[:MAX_NAME_LEN] or "ChatGPT", "api_key": "", "base_url": "", + "models": models.strip()[:MAX_MODELS_LEN], "created_at": page_now(), "oauth": oauth} + + +def seeded_chatgpt_models(user: str, entry_id: str) -> str: + """A new sign-in's first models, asked live from the account through + ``user``'s runtime (the tokens must be stored first); "" when the + listing fails — they are then picked in the entry's form.""" + try: + live = [m["id"] for m in ai_catalog.list_models(ai_runtime(user)["providers"][entry_id])] + except Exception: + live = [] + return ", ".join(live[:2])[:MAX_MODELS_LEN] + + +@router.post("/ai/oauth/chatgpt/start") +async def chatgpt_auth_start(request: Request): + return begin_chatgpt_signin(_require_editor(request)) + + class ChatGPTAuthComplete(BaseModel): state: str = "" callback: str = "" # pasted redirect URL (or a bare authorization code) @@ -1146,20 +1196,7 @@ class ChatGPTAuthComplete(BaseModel): @router.post("/ai/oauth/chatgpt/complete") def chatgpt_auth_complete(payload: ChatGPTAuthComplete, request: Request): user = _require_editor(request) - st = _OAUTH_STATES.pop(payload.state, None) - # The state belongs to the account that started the sign-in: another - # account can't redeem it (and so can't attach that login's tokens). - if not st or st.get("user") != user or time.time() - st["at"] > _OAUTH_STATE_TTL: - raise HTTPException(status_code=400, - detail="sign-in session expired — hit 'Open ChatGPT sign-in' again") - try: - code = chatgpt_oauth.parse_callback(payload.callback, payload.state) - oauth = chatgpt_oauth.exchange_code(code, st["verifier"]) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=400, detail=f"token exchange failed: {e}") - + oauth = redeem_chatgpt_signin(user, payload.state, payload.callback) entries = load_provider_entries(user) if payload.provider_id: entry = next((e for e in entries if e.get("id") == payload.provider_id), None) @@ -1173,28 +1210,13 @@ def chatgpt_auth_complete(payload: ChatGPTAuthComplete, request: Request): else: if len(entries) >= MAX_PROVIDERS: raise HTTPException(status_code=400, detail="too many providers") - entry = { - "id": new_provider_id(), - "protocol": "chatgpt", - "name": payload.name.strip()[:MAX_NAME_LEN] or "ChatGPT", - "api_key": "", - "base_url": "", - "models": payload.models.strip()[:MAX_MODELS_LEN], - "created_at": page_now(), - "oauth": oauth, - } + entry = new_chatgpt_entry(new_provider_id(), oauth, payload.name, payload.models) entries.append(entry) if not entry["models"]: - # Seed the model list live from the account; if that fails the - # entry starts empty and the models are picked in its settings - # form. Tokens must be stored first — the listing call reads them - # back through ai_runtime. + # Seed the model list live from the account (the tokens stored + # first: the listing reads them back through ai_runtime). save_provider_entries(user, entries) - try: - live = [m["id"] for m in ai_catalog.list_models(ai_runtime(user)["providers"][entry["id"]])] - except Exception: - live = [] - entry["models"] = ", ".join(live[:2])[:MAX_MODELS_LEN] + entry["models"] = seeded_chatgpt_models(user, entry["id"]) save_provider_entries(user, entries) return _masked_settings(request) diff --git a/backend/gamma/routers/blocks.py b/backend/gamma/routers/blocks.py index 713f3894..e273e992 100644 --- a/backend/gamma/routers/blocks.py +++ b/backend/gamma/routers/blocks.py @@ -7,11 +7,11 @@ from fractional_indexing import generate_key_between from pydantic import BaseModel -from ..auth import actor_of, require_ws, require_ws_writer, resolve_ws, share_scope_page +from ..auth import actor_of, require_ws, require_ws_writer, resolve_ws, share_scope from ..blocks_store import ( BLOCK_COLUMNS, ancestor_chains, - assert_block_in_page, + assert_block_in_scope, block_to_dict, create_page, delete_children, @@ -138,13 +138,13 @@ async def block_search(request: Request, q: str = "", ids: str = "", limit: int @router.get("/blocks/by-doc/{doc_id}") async def ub_get_by_doc(doc_id: str, request: Request): - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(resolve_ws(request)) as conn: row = page_for_doc(conn, doc_id, BLOCK_COLUMNS) - # A share may only learn about its own page — refuse before revealing - # whether any other doc id exists. - if scope is not None and (not row or row[0] != scope): - raise HTTPException(status_code=403, detail="not accessible via this share link") + # A share may only learn about its own pages — refuse before revealing + # whether any other doc id exists. + if scope is not None and (not row or not scope.allows_page(conn, row[0])): + raise HTTPException(status_code=403, detail="not accessible via this share link") if not row: raise HTTPException(status_code=404, detail="block not found for doc_id") return block_to_dict(row) @@ -164,15 +164,16 @@ async def ub_get_or_create_by_doc(doc_id: str, payload: UBByDocCreate, request: @router.get("/blocks/{block_id}/children") async def ub_get_children(block_id: str, request: Request): - scope = share_scope_page(request) + scope = share_scope(request) if scope is not None and block_id == "root": - # A share link may not enumerate the owner's library root. + # A share link may not enumerate the owner's library root (a folder + # share lists its pages through GET /share/{token}). raise HTTPException(status_code=403, detail="not accessible via this share link") with connect_pages_db(resolve_ws(request)) as conn: if block_id != "root": if not conn.execute("SELECT 1 FROM unified_blocks WHERE id = ?", (block_id,)).fetchone(): raise HTTPException(status_code=404, detail="block not found") - assert_block_in_page(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) rows = conn.execute( f"SELECT {BLOCK_COLUMNS} FROM unified_blocks WHERE parent_id = ? ORDER BY position ASC", (block_id,), @@ -221,9 +222,9 @@ def _page_previews(conn) -> dict: async def ub_get_subtree(block_id: str, request: Request): """The block with its whole subtree. For a page, ``seq`` is the op log's position this tree reflects — the live session catches up from it.""" - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(resolve_ws(request)) as conn: - assert_block_in_page(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) rows = fetch_subtree(conn, block_id) seq = latest_seq(conn, block_id) if rows and rows[0][1] == "root" else None if not rows: @@ -237,9 +238,9 @@ async def ub_get_subtree(block_id: str, request: Request): @router.get("/blocks/{block_id}/backlinks") async def ub_get_backlinks(block_id: str, request: Request): """Return all blocks that reference `block_id` via [[block_id]] syntax.""" - # Backlinks span the whole library by nature, so a per-document share link - # can't use them without leaking other pages. - if share_scope_page(request) is not None: + # Backlinks span the whole library by nature, so a share link can't use + # them without leaking other pages. + if share_scope(request) is not None: raise HTTPException(status_code=403, detail="not accessible via this share link") with connect_pages_db(resolve_ws(request)) as conn: rows = conn.execute( @@ -267,9 +268,9 @@ async def ub_get_backlinks(block_id: str, request: Request): @router.get("/blocks/{block_id}") async def ub_get_block(block_id: str, request: Request): - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(resolve_ws(request)) as conn: - assert_block_in_page(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) row = conn.execute( f"SELECT {BLOCK_COLUMNS} FROM unified_blocks WHERE id = ?", (block_id,), @@ -281,9 +282,11 @@ async def ub_get_block(block_id: str, request: Request): def _ops(ws: str, page_id: str, ops: list[dict], request: Request, scope) -> dict: """Apply ops to a page on behalf of the request: a share editor is - confined to the shared page, every op error is its HTTP status.""" - if scope is not None and scope != page_id: - raise HTTPException(status_code=403, detail="not accessible via this share link") + confined to the shared pages, every op error is its HTTP status.""" + if scope is not None: + with connect_pages_db(ws) as conn: + if not scope.allows_page(conn, page_id): + raise HTTPException(status_code=403, detail="not accessible via this share link") try: return commit_ops(ws, page_id, ops, actor=actor_of(request), share_scoped=scope is not None) @@ -295,7 +298,7 @@ def _ops(ws: str, page_id: str, ops: list[dict], request: Request, scope) -> dic async def ub_create_block(payload: UBCreateRequest, request: Request): block_id = secrets.token_urlsafe(9) ws = require_ws_writer(request) - scope = share_scope_page(request) + scope = share_scope(request) if payload.parent_id == "root": # A new page: not an op on any page. Share editors never get here. if scope is not None: @@ -328,7 +331,7 @@ async def ub_create_block(payload: UBCreateRequest, request: Request): async def ub_update_block(block_id: str, payload: UBUpdateRequest, request: Request): """Content and/or a properties PATCH (a null value deletes the key).""" ws = require_ws_writer(request) - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(ws) as conn: page_id = page_root_id(conn, block_id) if not page_id: @@ -347,7 +350,7 @@ async def ub_delete_block(block_id: str, request: Request): if block_id == "root": raise HTTPException(status_code=400, detail="cannot delete root block") ws = require_ws_writer(request) - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(ws) as conn: page_id = page_root_id(conn, block_id) if not page_id: @@ -372,11 +375,11 @@ async def ub_put_children(block_id: str, payload: UBPutChildrenRequest, request: rows: list = [] flatten_tree(payload.blocks, block_id, rows, now) ws = require_ws_writer(request) - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(ws) as conn: if not conn.execute("SELECT 1 FROM unified_blocks WHERE id = ?", (block_id,)).fetchone(): raise HTTPException(status_code=404, detail="block not found") - assert_block_in_page(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) delete_children(conn, block_id) for r in rows: conn.execute( @@ -404,8 +407,8 @@ async def ub_reorder_block(block_id: str, payload: UBReorderRequest, request: Re if block_id == "root": raise HTTPException(status_code=400, detail="cannot reorder root block") ws = require_ws_writer(request) - scope = share_scope_page(request) - if scope is not None and (block_id == scope or payload.parent_id == "root"): + scope = share_scope(request) + if scope is not None and payload.parent_id == "root": raise HTTPException(status_code=403, detail="not accessible via this share link") try: new_pos = generate_key_between(payload.before, payload.after) @@ -415,6 +418,8 @@ async def ub_reorder_block(block_id: str, payload: UBReorderRequest, request: Re src_page = page_root_id(conn, block_id) if not src_page: raise HTTPException(status_code=404, detail="block not found") + if scope is not None and (src_page == block_id or not scope.allows_page(conn, src_page)): + raise HTTPException(status_code=403, detail="not accessible via this share link") if src_page == block_id: raise HTTPException(status_code=400, detail="pages are reordered through the library, not here") row = conn.execute("SELECT parent_id FROM unified_blocks WHERE id = ?", (block_id,)).fetchone() diff --git a/backend/gamma/routers/chats.py b/backend/gamma/routers/chats.py index e6b3b3a7..665ec0e5 100644 --- a/backend/gamma/routers/chats.py +++ b/backend/gamma/routers/chats.py @@ -19,8 +19,8 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from ..auth import require_ws, resolve_ws, share_scope_page -from ..db import connect_data_db, page_now +from ..auth import require_ws, resolve_ws, share_scope +from ..db import connect_data_db, connect_pages_db, page_now router = APIRouter(prefix="/api/chats", tags=["chats"]) @@ -41,11 +41,6 @@ class ChatSaveRequest(BaseModel): title: str | None = None # None = keep the stored title -class ChatFolderRenameRequest(BaseModel): - src: str # folder path whose chat buckets move ("a/b" — never "") - dst: str = "" # new path; "" = the folder was deleted, drop its buckets - - class ChatArchiveRequest(BaseModel): bucket: str messages: list = [] # the client's current conversation (authoritative) @@ -100,20 +95,15 @@ def _archive(database, bucket: str, messages: list, title: str) -> str | None: return entry_id -@router.post("/folder-rename") -async def rename_folder_chats(payload: ChatFolderRenameRequest, request: Request): - """Follow a folder rename/move/delete: per-folder buckets embed the path - in their key, so path rewrites must carry the conversations along. The - frontend calls this with the same src → dst prefix mapping it applies to - the pages' folder tags (subfolders ride along). When the destination - already holds a real conversation it wins and the source is dropped; an - empty destination row (a save-effect echo) is overwritten. History - entries simply follow their bucket (ids never collide).""" - ws = _require_chat_writer(request) - src = (payload.src or "").strip().strip("/") - dst = (payload.dst or "").strip().strip("/") - if not src: - raise HTTPException(status_code=400, detail="src folder path required") +def move_folder_buckets(ws: str, src: str, dst: str) -> dict: + """Follow a folder rename/move/delete (POST /folders/rename, + gamma/routers/folders.py): per-folder buckets embed the path in their + key, so path rewrites must carry the conversations along — the same + src → dst prefix mapping the frontend applies to the pages' folder tags + (subfolders ride along). When the destination already holds a real + conversation it wins and the source is dropped; an empty destination + row (a save-effect echo) is overwritten. History entries simply follow + their bucket (ids never collide). ``dst`` "" drops the conversations.""" src_key = f"home:{src}" prefix_match = "(bucket = ? OR substr(bucket, 1, ?) = ?)" with connect_data_db(ws) as database: @@ -146,15 +136,17 @@ async def rename_folder_chats(payload: ChatFolderRenameRequest, request: Request database.execute("UPDATE chat_history SET bucket = ? WHERE id = ?", (f"home:{dst}" + bucket[len(src_key):], entry_id)) database.commit() - return {"ok": True, "moved": len(rows), "history_moved": len(hist)} + return {"moved": len(rows), "history_moved": len(hist)} @router.get("/{block_id:path}") async def get_chat(block_id: str, request: Request): ws = resolve_ws(request) - shared_page = share_scope_page(request) - if shared_page and block_id != shared_page: - raise HTTPException(status_code=403, detail="chat is outside the shared page") + scope = share_scope(request) + if scope is not None: + with connect_pages_db(ws) as conn: + if not scope.allows_page(conn, block_id): + raise HTTPException(status_code=403, detail="chat is outside the shared page") with connect_data_db(ws) as database: row = database.execute( "SELECT messages, title FROM chats WHERE block_id = ?", (block_id,) diff --git a/backend/gamma/routers/collab.py b/backend/gamma/routers/collab.py index 409bdc2c..6eabc6be 100644 --- a/backend/gamma/routers/collab.py +++ b/backend/gamma/routers/collab.py @@ -10,8 +10,8 @@ from .. import collab from ..auth import (ANONYMOUS_NAME, SESSION_COOKIE, actor_of, is_link_visitor, link_name, link_ratelimit, - note_share_miss, require_ws_writer, requested_ws, resolve_ws, session_lookup, share_access, - share_lookup, share_scope_page, workspace_access) + ShareScope, note_share_miss, require_ws_writer, requested_ws, resolve_ws, session_lookup, + share_access, share_lookup, share_scope, workspace_access) from ..db import connect_pages_db from ..ops import OpError, OpsRequest, commit_ops, latest_seq, ops_since @@ -23,10 +23,13 @@ LINK_OPS_PER_MINUTE = 600 -def _scope_page(request: Request, page_id: str): - scope = share_scope_page(request) - if scope is not None and scope != page_id: - raise HTTPException(status_code=403, detail="not accessible via this share link") +def _scope_page(request: Request, ws: str, page_id: str): + """The request's ShareScope (None for a member), 403 unless it reaches ``page_id``.""" + scope = share_scope(request) + if scope is not None: + with connect_pages_db(ws) as conn: + if not scope.allows_page(conn, page_id): + raise HTTPException(status_code=403, detail="not accessible via this share link") return scope @@ -40,7 +43,7 @@ async def post_ops(page_id: str, payload: OpsRequest, request: Request): edit share.""" ws = require_ws_writer(request) link_ratelimit(request, "ops", LINK_OPS_PER_MINUTE, 60) - scope = _scope_page(request, page_id) + scope = _scope_page(request, ws, page_id) ops = [op.model_dump(exclude_unset=True) for op in payload.ops] cursor = None if payload.cursor is not None: @@ -62,7 +65,7 @@ async def get_ops(page_id: str, request: Request, since: int = 0): ``{seq, batches: [{seq, actor, client, at, ops}]}``; 410 when the log was pruned past ``since`` — reload the tree instead.""" ws = resolve_ws(request) - _scope_page(request, page_id) + _scope_page(request, ws, page_id) with connect_pages_db(ws) as conn: row = conn.execute( "SELECT parent_id FROM unified_blocks WHERE id = ?", (page_id,)).fetchone() @@ -88,6 +91,7 @@ def _socket_access(sock: WebSocket, page_id: str): sock.state.is_guest = bool(sess and sess[1]) sock.state.is_admin = bool(sess and sess[2]) token = sock.query_params.get("share") or "" + scope = None if token: share = share_lookup(token) if not share: @@ -96,12 +100,10 @@ def _socket_access(sock: WebSocket, page_id: str): except HTTPException: pass # the socket is closed either way return None - if share["page_id"] != page_id: - return None level, _reason = share_access(share, sock) if not level: return None - ws, can_edit = share["workspace_id"], level == "edit" + ws, can_edit, scope = share["workspace_id"], level == "edit", ShareScope.of(share) elif sock.state.user: ws, role = workspace_access(sock.state.user, requested_ws(sock), sess[3]) if not role: @@ -114,6 +116,8 @@ def _socket_access(sock: WebSocket, page_id: str): "SELECT parent_id FROM unified_blocks WHERE id = ?", (page_id,)).fetchone() if not row or row[0] != "root": return None + if scope is not None and not scope.allows_page(conn, page_id): + return None seq = latest_seq(conn, page_id) if is_link_visitor(sock): return ws, "", link_name(sock.query_params.get("name", "")), can_edit, seq diff --git a/backend/gamma/routers/export.py b/backend/gamma/routers/export.py index 358725b9..b3c728b0 100644 --- a/backend/gamma/routers/export.py +++ b/backend/gamma/routers/export.py @@ -18,8 +18,8 @@ from starlette.background import BackgroundTask from .. import ink as inkmod -from ..auth import resolve_ws, share_scope_page -from ..blocks_store import BLOCK_COLUMNS, assert_block_in_page, block_to_dict, fetch_subtree +from ..auth import resolve_ws, share_scope +from ..blocks_store import BLOCK_COLUMNS, assert_block_in_scope, block_to_dict, fetch_subtree from ..db import connect_pages_db from ..db import ( PAGES_SCHEMA, @@ -698,11 +698,11 @@ def export_page(block_id: str, request: Request, mode: str = "readable", pdf: in library), or ``gamma`` (a scoped account backup any Gamma imports via /api/import-data?mode=merge).""" ws = resolve_ws(request) - scope = share_scope_page(request) + scope = share_scope(request) opts = {"pdf": bool(pdf), "highlights": bool(highlights), "notes": bool(notes), "folder_scope": None} with connect_pages_db(ws) as conn: - assert_block_in_page(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) if not conn.execute("SELECT 1 FROM unified_blocks WHERE id = ?", (block_id,)).fetchone(): raise HTTPException(status_code=404, detail="page not found") row = conn.execute("SELECT content FROM unified_blocks WHERE id = ?", (block_id,)).fetchone() @@ -726,9 +726,9 @@ def export_page_pdf(block_id: str, request: Request, notes: int = 0, highlights: ``highlights=0`` skips the annotation layer, so ``highlights=0¬es=1`` gives a clean PDF carrying only the written notes.""" ws = resolve_ws(request) - scope = share_scope_page(request) + scope = share_scope(request) with connect_pages_db(ws) as conn: - assert_block_in_page(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) rows = fetch_subtree(conn, block_id) if not rows: raise HTTPException(status_code=404, detail="page not found") @@ -784,7 +784,8 @@ def export_page_pdf(block_id: str, request: Request, notes: int = 0, highlights: @router.get("/folders/export-progress") def folder_export_progress(request: Request): - if share_scope_page(request) is not None: + scope = share_scope(request) + if scope is not None and not scope.folder: raise HTTPException(status_code=403, detail="not accessible via this share link") ws = resolve_ws(request) return _folder_export_progress.get(ws) or {"active": False, "total": 0, "done": 0} @@ -813,8 +814,10 @@ def export_folder(request: Request, name: str, mode: str = "readable", pdf: int name = (name or "").strip().strip("/") if not name: raise HTTPException(status_code=400, detail="folder name required") - # A share link is scoped to one page, never a whole folder. - if share_scope_page(request) is not None: + # A page share never reaches a whole folder; a folder share exports its + # own folder or a subfolder of it. + scope = share_scope(request) + if scope is not None and not scope.allows_folder(name): raise HTTPException(status_code=403, detail="not accessible via this share link") ws = resolve_ws(request) folder_slug = slugify(name.replace("/", "-"), "") diff --git a/backend/gamma/routers/folders.py b/backend/gamma/routers/folders.py new file mode 100644 index 00000000..683ebb1a --- /dev/null +++ b/backend/gamma/routers/folders.py @@ -0,0 +1,38 @@ +"""Folders are labels (``properties.folder`` on page roots, gamma/foldertags.py), +so a rename, move or delete is a prefix rewrite the frontend applies page by +page. What ELSE names a folder by its path follows through this one call: +the per-folder chat buckets (``home:``, gamma/routers/chats.py) and +folder shares (gamma/routers/shares.py). The frontend calls it with the +same src → dst mapping it applies to the tags (subfolders ride along; dst "" +means the folder is gone). Best-effort on the frontend's side — a failed +call orphans a conversation or a share, never page data. +""" + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from ..auth import require_ws +from . import chats, shares + +router = APIRouter(prefix="/api", tags=["folders"]) + + +class FolderRenameRequest(BaseModel): + src: str + dst: str + + +@router.post("/folders/rename") +async def rename_folder(payload: FolderRenameRequest, request: Request): + """Carry a folder's chat buckets and share links along a rename / move + (``dst`` the new path) or drop them (``dst`` ""). Workspace editors; never + through a share link.""" + if request.query_params.get("share"): + raise HTTPException(status_code=403, detail="folders cannot be changed through a share link") + ws = require_ws(request, write=True) + src = (payload.src or "").strip().strip("/") + dst = (payload.dst or "").strip().strip("/") + if not src: + raise HTTPException(status_code=400, detail="src folder path required") + moved = chats.move_folder_buckets(ws, src, dst) + return {"ok": True, **moved, "shares_moved": shares.move_folder_shares(ws, src, dst)} diff --git a/backend/gamma/routers/pdf.py b/backend/gamma/routers/pdf.py index 2a98cea0..ff6f3f70 100644 --- a/backend/gamma/routers/pdf.py +++ b/backend/gamma/routers/pdf.py @@ -20,7 +20,7 @@ from fastapi.responses import RedirectResponse, StreamingResponse from pydantic import BaseModel -from ..auth import require_user, resolve_ws, share_scope_page +from ..auth import require_user, resolve_ws, share_scope from ..db import connect_pages_db, ws_uploads_dir from .. import pdf_meta from ..logbuf import log @@ -273,21 +273,20 @@ def download_pdf(source_url: str, want_bytes: bool = True) -> tuple[str, bytes]: resp.close() -def _share_allows_source(ws: str, scope_page_id: str, source_url: str) -> bool: - """A share link may only proxy the exact source URL recorded on its own - page block.""" +def _share_allows_source(ws: str, scope, source_url: str) -> bool: + """A share link may only proxy the exact source URL recorded on one of + its own page blocks.""" with connect_pages_db(ws) as conn: - row = conn.execute( - "SELECT json_extract(properties, '$.source_url') FROM unified_blocks WHERE id = ?", - (scope_page_id,), - ).fetchone() - return bool(row and row[0]) and source_url == row[0] + rows = conn.execute( + "SELECT id FROM unified_blocks WHERE parent_id = 'root' " + "AND json_extract(properties, '$.source_url') = ?", (source_url,)).fetchall() + return any(scope.allows_page(conn, r[0]) for r in rows) @router.get("/pdf") def proxy_pdf(source_url: str, request: Request): ws = resolve_ws(request) - scope = share_scope_page(request) + scope = share_scope(request) if scope is not None and not _share_allows_source(ws, scope, source_url): raise HTTPException(status_code=403, detail="not accessible via this share link") uploads = ws_uploads_dir(ws) diff --git a/backend/gamma/routers/shares.py b/backend/gamma/routers/shares.py index 65209ba3..d45eae41 100644 --- a/backend/gamma/routers/shares.py +++ b/backend/gamma/routers/shares.py @@ -1,8 +1,11 @@ -"""Share links — one per (workspace, page), Notion-style people + general access. +"""Share links — one per (workspace, page) or per (workspace, folder), +Notion-style people + general access. -A share names a page's root block, so any page can be shared: papers (the -PDF, highlights and notes) and plain note pages alike. Any editor or owner -of the page's workspace manages it. Settings: +A share names a page's root block — papers (the PDF, highlights and notes) +and plain note pages alike — or a folder-label path: the pages filed in that +folder or below it, read live, so pages filed later join and pages moved out +leave (gamma/auth.py ShareScope). Any editor or owner of the workspace +manages it. Settings: - ``users``: the people invited — ``[{"name", "role"}]``, each with their own ``view``/``edit``; they get in whatever the general access says. @@ -10,17 +13,21 @@ ``users`` (any signed-in non-guest account on this server), ``list`` (only the invited people). - ``role``: what general access grants — ``view`` or ``edit``. Editing is - confined to the page's block tree (gamma/auth.py require_ws_writer + the - blocks router's scope checks). ``edit`` with ``anyone`` makes the link - itself the key: whoever opens it may edit, attributed as ``link:`` - (gamma/auth.py actor_of) — the sharer's call, warned about in the dialog. + confined to the shared pages' block trees (gamma/auth.py require_ws_writer + + the blocks router's scope checks); a folder edit share covers every page + in the folder, now and later, never the pages' own settings. ``edit`` with + ``anyone`` makes the link itself the key: whoever opens it may edit, + attributed as ``link:`` (gamma/auth.py actor_of) — the sharer's + call, warned about in the dialog. Workspace members keep their workspace role on top (gamma/auth.py -share_access). The token confines reads (and edit writes) to that page's -subtree and assets (share_grant / share_scope_page). +share_access). The token confines reads (and edit writes) to the shared +pages' subtrees and assets (share_grant / share_scope). The token lives until "Stop sharing" (DELETE; sharing again mints a new -one). Unknown tokens are counted per IP (gamma/auth.py note_share_miss). +one). A folder share follows the folder's renames (``move_folder_shares``, +POST /folders/rename) and dies with the folder. Unknown tokens are counted +per IP (gamma/auth.py note_share_miss). """ import json @@ -30,10 +37,11 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from ..auth import (SHARE_AUDIENCES, SHARE_ROLES, note_share_miss, require_ws, serialize_share_users, - share_access, share_lookup) -from ..blocks_store import page_attachment +from ..auth import (SHARE_AUDIENCES, SHARE_ROLES, ShareScope, note_share_miss, require_ws, + serialize_share_users, share_access, share_lookup) +from ..blocks_store import page_attachment, root_pages from ..db import connect_pages_db, connect_users_db, page_now +from ..foldertags import clean_path, parse_tags, path_within router = APIRouter(prefix="/api", tags=["shares"]) @@ -44,21 +52,36 @@ class ShareSettings(BaseModel): users: list | None = None # ["carol"] or [{"name": "carol", "role": "edit"}] (bare names = view) +# A share's target: the column that names it and its value, the other +# column ''. Everything below takes one and never asks which kind it is. +def _page_target(page_id: str) -> dict: + return {"page_id": page_id, "folder": ""} + + +def _folder_target(folder: str) -> dict: + return {"page_id": "", "folder": folder} + + def _settings(share: dict) -> dict: - return {"token": share["token"], "page_id": share["page_id"], "audience": share["audience"], - "role": share["role"], "users": share["users"], "created_by": share["created_by"]} + return {"token": share["token"], "page_id": share["page_id"], "folder": share["folder"], + "audience": share["audience"], "role": share["role"], "users": share["users"], + "created_by": share["created_by"]} + +def _unshared(target: dict) -> dict: + return {"token": None, "page_id": target["page_id"], "folder": target["folder"]} -def _page_share(ws: str, page_id: str) -> dict | None: + +def _find(ws: str, target: dict) -> dict | None: with connect_users_db() as conn: row = conn.execute( - "SELECT token FROM shares WHERE workspace_id = ? AND page_id = ?", (ws, page_id) - ).fetchone() + "SELECT token FROM shares WHERE workspace_id = ? AND page_id = ? AND folder = ?", + (ws, target["page_id"], target["folder"])).fetchone() return share_lookup(row[0]) if row else None -def _require_page(ws: str, page_id: str) -> None: - """404/400 unless page_id is one of the workspace's root pages.""" +def _require_page(ws: str, page_id: str) -> dict: + """The page target; 404/400 unless page_id is one of the workspace's root pages.""" with connect_pages_db(ws) as conn: row = conn.execute( "SELECT parent_id FROM unified_blocks WHERE id = ?", (page_id,)).fetchone() @@ -66,6 +89,19 @@ def _require_page(ws: str, page_id: str) -> None: raise HTTPException(status_code=404, detail="page not found") if row[0] != "root": raise HTTPException(status_code=400, detail="only pages can be shared") + return _page_target(page_id) + + +def _require_folder(ws: str, name: str) -> dict: + """The folder target; 400 for an empty path, 404 unless some page is + filed in the folder (folders exist only through their pages).""" + folder = clean_path(name or "") + if not folder: + raise HTTPException(status_code=400, detail="folder name required") + with connect_pages_db(ws) as conn: + if not root_pages(conn, folder): + raise HTTPException(status_code=404, detail="folder not found") + return _folder_target(folder) def _page_doc_id(ws: str, page_id: str) -> str: @@ -85,6 +121,31 @@ def _page_doc_id(ws: str, page_id: str) -> str: return attachment["id"] if attachment else "" +def _folder_pages(ws: str, folder: str) -> list[dict]: + """The share view's listing of a folder share: every page the scope + reaches, newest edit first — ``{id, title, doc_id, folders, labels, + created_at, updated_at}``.""" + scope = ShareScope(folder=folder) + pages = [] + with connect_pages_db(ws) as conn: + for page_id, content, props_raw, created_at, updated_at in conn.execute( + "SELECT id, content, properties, created_at, updated_at FROM unified_blocks " + "WHERE parent_id = 'root' ORDER BY updated_at DESC"): + try: + props = json.loads(props_raw or "{}") + except ValueError: + props = {} + if not any(path_within(tag, scope.folder) for tag in parse_tags(props.get("folder"))): + continue + attachment = page_attachment(props) + pages.append({"id": page_id, "title": content or "Untitled", + "doc_id": attachment["id"] if attachment else "", + "folders": parse_tags(props.get("folder")), + "labels": parse_tags(props.get("category")), + "created_at": created_at, "updated_at": updated_at}) + return pages + + def _validated(editor: str, current: dict, payload: ShareSettings) -> dict: audience = payload.audience if payload.audience is not None else current["audience"] role = payload.role if payload.role is not None else current["role"] @@ -115,14 +176,13 @@ def _validated(editor: str, current: dict, payload: ShareSettings) -> dict: return {"audience": audience, "role": role, "users": cleaned} -@router.post("/share/{page_id}") -async def create_share(page_id: str, request: Request, payload: ShareSettings | None = None): - """Create the page's share link (defaults: anyone, view) — or, when one +# ---- the four operations, the same for both targets ------------------------- + +def _create(ws: str, request: Request, target: dict, payload: ShareSettings | None) -> dict: + """Create the target's share link (defaults: anyone, view) — or, when one exists, return it unchanged so re-sharing never invalidates a link already sent around. An optional body applies settings to a NEW link only.""" - ws = require_ws(request, write=True) - _require_page(ws, page_id) - existing = _page_share(ws, page_id) + existing = _find(ws, target) if existing: return _settings(existing) fields = _validated(request.state.user, {"audience": "anyone", "role": "view", "users": []}, @@ -130,32 +190,25 @@ async def create_share(page_id: str, request: Request, payload: ShareSettings | token = secrets.token_urlsafe(12) with connect_users_db() as conn: conn.execute( - "INSERT INTO shares (token, workspace_id, page_id, created_by, audience, role, allowed_users, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (token, ws, page_id, request.state.user, fields["audience"], fields["role"], - serialize_share_users(fields["users"]), page_now()), + "INSERT INTO shares (token, workspace_id, page_id, folder, created_by, audience, role, allowed_users, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (token, ws, target["page_id"], target["folder"], request.state.user, fields["audience"], + fields["role"], serialize_share_users(fields["users"]), page_now()), ) conn.commit() return _settings(share_lookup(token)) -@router.get("/share-settings/{page_id}") -async def get_share_settings(page_id: str, request: Request): - """A member's view of a page's share: its settings, or ``{"token": null}`` - when the page isn't shared.""" - ws = require_ws(request) - _require_page(ws, page_id) - share = _page_share(ws, page_id) - return _settings(share) if share else {"token": None, "page_id": page_id} +def _get(ws: str, target: dict) -> dict: + share = _find(ws, target) + return _settings(share) if share else _unshared(target) -@router.put("/share-settings/{page_id}") -async def update_share_settings(page_id: str, payload: ShareSettings, request: Request): +def _update(ws: str, request: Request, target: dict, payload: ShareSettings, what: str) -> dict: """Change who may open the link and what they may do. The token stays.""" - ws = require_ws(request, write=True) - share = _page_share(ws, page_id) + share = _find(ws, target) if not share: - raise HTTPException(status_code=404, detail="page is not shared") + raise HTTPException(status_code=404, detail=f"{what} is not shared") fields = _validated(request.state.user, share, payload) with connect_users_db() as conn: conn.execute( @@ -166,23 +219,108 @@ async def update_share_settings(page_id: str, payload: ShareSettings, request: R return _settings(share_lookup(share["token"])) -@router.delete("/share-settings/{page_id}") -async def delete_share(page_id: str, request: Request): +def _delete(ws: str, target: dict) -> dict: """Stop sharing: the token dies; sharing again mints a new one.""" - ws = require_ws(request, write=True) with connect_users_db() as conn: - cur = conn.execute("DELETE FROM shares WHERE workspace_id = ? AND page_id = ?", (ws, page_id)) + cur = conn.execute("DELETE FROM shares WHERE workspace_id = ? AND page_id = ? AND folder = ?", + (ws, target["page_id"], target["folder"])) conn.commit() return {"ok": True, "removed": cur.rowcount} +def move_folder_shares(ws: str, src: str, dst: str) -> int: + """Follow a folder rename / move / delete (POST /folders/rename): the + shares of ``src`` and its subfolders move under ``dst`` (a share already + at the destination wins and the moved one is dropped), or die when + ``dst`` is "" (the folder is gone). Returns how many rows changed.""" + changed = 0 + with connect_users_db() as conn: + rows = conn.execute("SELECT token, folder FROM shares WHERE workspace_id = ? AND folder != ''", + (ws,)).fetchall() + for token, folder in rows: + if not path_within(folder, src): + continue + target = (dst + folder[len(src):]).strip("/") if dst else "" + if target and not conn.execute( + "SELECT 1 FROM shares WHERE workspace_id = ? AND folder = ?", (ws, target)).fetchone(): + conn.execute("UPDATE shares SET folder = ? WHERE token = ?", (target, token)) + else: + conn.execute("DELETE FROM shares WHERE token = ?", (token,)) + changed += 1 + conn.commit() + return changed + + +# ---- folder shares (before the page routes: "folder" is a static segment) --- + +@router.post("/share/folder") +async def create_folder_share(request: Request, name: str, payload: ShareSettings | None = None): + """Create the folder's share link (``?name=``; defaults anyone, + view) or return the existing one unchanged; workspace editors and owners.""" + ws = require_ws(request, write=True) + return _create(ws, request, _require_folder(ws, name), payload) + + +@router.get("/share-settings/folder") +async def get_folder_share_settings(request: Request, name: str): + """A member's view of a folder's share: its settings, or ``{"token": null}``.""" + ws = require_ws(request) + return _get(ws, _require_folder(ws, name)) + + +@router.put("/share-settings/folder") +async def update_folder_share_settings(request: Request, name: str, payload: ShareSettings): + ws = require_ws(request, write=True) + return _update(ws, request, _folder_target(clean_path(name or "")), payload, "folder") + + +@router.delete("/share-settings/folder") +async def delete_folder_share(request: Request, name: str): + ws = require_ws(request, write=True) + return _delete(ws, _folder_target(clean_path(name or ""))) + + +# ---- page shares ------------------------------------------------------------ + +@router.post("/share/{page_id}") +async def create_share(page_id: str, request: Request, payload: ShareSettings | None = None): + """Create the page's share link (defaults: anyone, view) or return the + existing one unchanged — root blocks only; workspace editors and owners.""" + ws = require_ws(request, write=True) + return _create(ws, request, _require_page(ws, page_id), payload) + + +@router.get("/share-settings/{page_id}") +async def get_share_settings(page_id: str, request: Request): + """A member's view of a page's share: its settings, or ``{"token": null}`` + when the page isn't shared.""" + ws = require_ws(request) + return _get(ws, _require_page(ws, page_id)) + + +@router.put("/share-settings/{page_id}") +async def update_share_settings(page_id: str, payload: ShareSettings, request: Request): + ws = require_ws(request, write=True) + return _update(ws, request, _page_target(page_id), payload, "page") + + +@router.delete("/share-settings/{page_id}") +async def delete_share(page_id: str, request: Request): + ws = require_ws(request, write=True) + return _delete(ws, _page_target(page_id)) + + +# ---- resolving a link ------------------------------------------------------- + @router.get("/share/{token}") async def get_share(token: str, request: Request): """Resolve a link for the viewer: 404 unknown, 401 when signing in could - grant access, 403 when this signed-in account isn't allowed. Otherwise the - page plus what this viewer may do (``can_edit``). ``doc_id`` is the - page's PDF attachment id ("" without one). ``username`` is who shared it; - ``workspace_id`` the page's workspace. ``viewer`` / ``viewer_is_guest`` + grant access, 403 when this signed-in account isn't allowed. Otherwise + what the link shares plus what this viewer may do (``can_edit``): a page + share carries ``page_id`` and ``doc_id`` (the page's PDF attachment id, + "" without one); a folder share carries ``folder`` and ``pages``, the + listing the share view shows (``_folder_pages``). ``username`` is who + shared it; ``workspace_id`` the workspace. ``viewer`` / ``viewer_is_guest`` tell the share view whether to offer "Open in my library" (a member) or "Add to my library" (an account that can import).""" share = share_lookup(token) @@ -194,7 +332,12 @@ async def get_share(token: str, request: Request): if reason == "login": raise HTTPException(status_code=401, detail="sign in to open this shared page") raise HTTPException(status_code=403, detail="this page is shared with specific people only") - return {"page_id": share["page_id"], "doc_id": _page_doc_id(share["workspace_id"], share["page_id"]), - "username": share["created_by"], "workspace_id": share["workspace_id"], - "audience": share["audience"], "role": share["role"], "can_edit": level == "edit", - "viewer": request.state.user or "", "viewer_is_guest": bool(request.state.is_guest)} + out = {"page_id": share["page_id"], "folder": share["folder"], + "username": share["created_by"], "workspace_id": share["workspace_id"], + "audience": share["audience"], "role": share["role"], "can_edit": level == "edit", + "viewer": request.state.user or "", "viewer_is_guest": bool(request.state.is_guest)} + if share["folder"]: + out["pages"] = _folder_pages(share["workspace_id"], share["folder"]) + else: + out["doc_id"] = _page_doc_id(share["workspace_id"], share["page_id"]) + return out diff --git a/backend/gamma/routers/uploads.py b/backend/gamma/routers/uploads.py index 443d30cd..c960a01a 100644 --- a/backend/gamma/routers/uploads.py +++ b/backend/gamma/routers/uploads.py @@ -3,8 +3,7 @@ from fastapi import APIRouter, File, HTTPException, Request, UploadFile from fastapi.responses import FileResponse, JSONResponse -from ..auth import link_ratelimit, require_ws, require_ws_writer, resolve_ws, share_scope_page -from ..blocks_store import fetch_subtree +from ..auth import link_ratelimit, require_ws, require_ws_writer, resolve_ws, share_scope from .. import pdf_meta from ..db import connect_pages_db, ws_uploads_dir from ..server_settings import check_upload_allowed, workspace_quota @@ -108,22 +107,22 @@ async def upload_file(request: Request, file: UploadFile = File(...)): "already_existed": already_existed} -def _share_can_read_upload(ws: str, scope_page_id: str, filename: str) -> bool: - """A share link may read only its own page's PDF (``.pdf``) or a - file the page's subtree references (embedded images, file chips — any +def _share_can_read_upload(ws: str, scope, filename: str) -> bool: + """A share link may read only its own pages' PDFs (``.pdf``) or a + file one of their subtrees references (embedded images, file chips — any extension, matched textually).""" - with connect_pages_db(ws) as conn: - doc = conn.execute( - "SELECT json_extract(properties, '$.doc_id') FROM unified_blocks WHERE id = ?", - (scope_page_id,), - ).fetchone() - if not doc: - return False - if doc[0] and filename == f"{doc[0]}.pdf": - return True - rows = fetch_subtree(conn, scope_page_id) needle = f"/api/uploads/{filename}" - return any(needle in (r[3] or "") or needle in (r[4] or "") for r in rows) + with connect_pages_db(ws) as conn: + if filename.endswith(".pdf"): + docs = conn.execute( + "SELECT id FROM unified_blocks WHERE parent_id = 'root' " + "AND json_extract(properties, '$.doc_id') = ?", (filename[:-4],)).fetchall() + if any(scope.allows_page(conn, r[0]) for r in docs): + return True + refs = conn.execute( + "SELECT id FROM unified_blocks WHERE instr(content, ?) > 0 OR instr(properties, ?) > 0", + (needle, needle)).fetchall() + return any(scope.allows_block(conn, r[0]) for r in refs) @router.get("/pdf-info/{doc_id}") @@ -137,8 +136,8 @@ def pdf_info(doc_id: str, request: Request): if not doc_id or not all(c in "0123456789abcdef" for c in doc_id): raise HTTPException(status_code=400, detail="invalid document id") ws = resolve_ws(request) - scope_page_id = share_scope_page(request) - if scope_page_id is not None and not _share_can_read_upload(ws, scope_page_id, f"{doc_id}.pdf"): + scope = share_scope(request) + if scope is not None and not _share_can_read_upload(ws, scope, f"{doc_id}.pdf"): raise HTTPException(status_code=403, detail="not accessible via this share link") info = pdf_meta.ensure(ws, doc_id) if info is None: @@ -165,11 +164,11 @@ async def serve_upload(filename: str, request: Request): raise HTTPException(status_code=400, detail="invalid filename") # Who may read this: a member of the workspace, or — with a ?share= - # token — anyone the share admits, confined to the shared page's own assets. + # token — anyone the share admits, confined to the shared pages' own assets. # Same resolution and refusal statuses as every other read endpoint. ws = resolve_ws(request) - scope_page_id = share_scope_page(request) - if scope_page_id is not None and not _share_can_read_upload(ws, scope_page_id, filename): + scope = share_scope(request) + if scope is not None and not _share_can_read_upload(ws, scope, filename): raise HTTPException(status_code=403, detail="not accessible via this share link") path = find_upload_file(filename, ws) diff --git a/backend/tests/test_chat_history.py b/backend/tests/test_chat_history.py index 9be25fe6..af695bb8 100644 --- a/backend/tests/test_chat_history.py +++ b/backend/tests/test_chat_history.py @@ -88,13 +88,13 @@ def test_folder_rename_carries_history(guest): guest.post("/api/chat-history/archive", json={"bucket": "home:hr", "messages": _msgs("root")}) guest.post("/api/chat-history/archive", json={"bucket": "home:hr/sub", "messages": _msgs("sub")}) guest.post("/api/chat-history/archive", json={"bucket": "home:hrx", "messages": _msgs("other")}) - r = guest.post("/api/chats/folder-rename", json={"src": "hr", "dst": "hr2"}) + r = guest.post("/api/folders/rename", json={"src": "hr", "dst": "hr2"}) assert r.status_code == 200 and r.json()["history_moved"] == 2 assert _sessions(guest, "home:hr") == [] and _sessions(guest, "home:hr/sub") == [] assert [s["title"] for s in _sessions(guest, "home:hr2")] == ["root"] assert [s["title"] for s in _sessions(guest, "home:hr2/sub")] == ["sub"] assert [s["title"] for s in _sessions(guest, "home:hrx")] == ["other"] - guest.post("/api/chats/folder-rename", json={"src": "hr2", "dst": ""}) + guest.post("/api/folders/rename", json={"src": "hr2", "dst": ""}) assert _sessions(guest, "home:hr2") == [] and _sessions(guest, "home:hr2/sub") == [] diff --git a/backend/tests/test_folder_chats.py b/backend/tests/test_folder_chats.py index d2195792..a569d387 100644 --- a/backend/tests/test_folder_chats.py +++ b/backend/tests/test_folder_chats.py @@ -23,7 +23,7 @@ def test_folder_rename_moves_buckets_by_prefix(guest): _put(guest, "home:a", "A") _put(guest, "home:a/sub", "S") _put(guest, "home:ab", "AB") # shares the string prefix but not the path - r = guest.post("/api/chats/folder-rename", json={"src": "a", "dst": "b"}) + r = guest.post("/api/folders/rename", json={"src": "a", "dst": "b"}) assert r.status_code == 200 and r.json()["moved"] == 2 assert _msgs(guest, "home:a") == [] assert _msgs(guest, "home:b")[0]["text"] == "A" @@ -34,7 +34,7 @@ def test_folder_rename_moves_buckets_by_prefix(guest): def test_folder_rename_collision_keeps_real_destination(guest): _put(guest, "home:x", "src") _put(guest, "home:y", "dest") - guest.post("/api/chats/folder-rename", json={"src": "x", "dst": "y"}) + guest.post("/api/folders/rename", json={"src": "x", "dst": "y"}) assert _msgs(guest, "home:y")[0]["text"] == "dest" assert _msgs(guest, "home:x") == [] @@ -44,19 +44,19 @@ def test_folder_rename_overwrites_empty_destination_row(guest): # the real conversation being moved in. _put(guest, "home:p", "src") assert guest.put("/api/chats/home:q", json={"messages": []}).status_code == 200 - guest.post("/api/chats/folder-rename", json={"src": "p", "dst": "q"}) + guest.post("/api/folders/rename", json={"src": "p", "dst": "q"}) assert _msgs(guest, "home:q")[0]["text"] == "src" def test_folder_delete_drops_buckets(guest): _put(guest, "home:z/deep", "gone") - r = guest.post("/api/chats/folder-rename", json={"src": "z", "dst": ""}) + r = guest.post("/api/folders/rename", json={"src": "z", "dst": ""}) assert r.json()["moved"] == 1 assert _msgs(guest, "home:z/deep") == [] def test_folder_rename_never_touches_root_bucket(guest): _put(guest, "home", "root chat") - assert guest.post("/api/chats/folder-rename", json={"src": "", "dst": "x"}).status_code == 400 - guest.post("/api/chats/folder-rename", json={"src": "home", "dst": ""}) # only "home:home" would match + assert guest.post("/api/folders/rename", json={"src": "", "dst": "x"}).status_code == 400 + guest.post("/api/folders/rename", json={"src": "home", "dst": ""}) # only "home:home" would match assert _msgs(guest, "home")[0]["text"] == "root chat" diff --git a/backend/tests/test_mcp_links.py b/backend/tests/test_mcp_links.py index 6ea5cdeb..befc6c81 100644 --- a/backend/tests/test_mcp_links.py +++ b/backend/tests/test_mcp_links.py @@ -108,3 +108,27 @@ def test_link_canonical_origin_and_revoked_connection(client, connection, monkey c.delete(f"/api/integrations/tokens/{credential['id']}") assert rpc(client, credential["token"], "tools/call", { "name": "read_gamma_link", "arguments": {"url": url}}).status_code == 401 + + +def test_folder_share_links_list_the_folder(client, connection): + c, ws, credential = connection + paper = make_page(c, "Paper in the group folder", {"folder": "group/sub"}) + other = make_page(c, "Elsewhere in the library") + share = c.post("/api/share/folder", params={"name": "group"}).json() + result = read(client, credential["token"], url=f"http://localhost/?share={share['token']}") + assert not result["isError"], result + ref = result["structuredContent"] + assert ref == {"workspace_id": ws, "folder": "group", "url": f"http://localhost/?ws={ws}&folder=group"} + text = result["content"][0]["text"] + assert "Paper in the group folder" in text and paper["id"] in text + assert "Elsewhere in the library" not in text and share["token"] not in text + assert "page=" in text # the URL template for citing the listed pages + # the link may also name a page in the folder — then it reads that page + result = read(client, credential["token"], url=f"http://localhost/?share={share['token']}&page={paper['id']}") + assert not result["isError"], result + assert result["structuredContent"]["page_id"] == paper["id"] + assert result["structuredContent"]["title"] == "Paper in the group folder" + # but never one outside it + assert read(client, credential["token"], url=f"http://localhost/?share={share['token']}&page={other['id']}")["isError"] + c.delete("/api/share-settings/folder", params={"name": "group"}) + assert read(client, credential["token"], url=f"http://localhost/?share={share['token']}")["isError"] diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 0d6a4d50..833d2fdb 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -25,7 +25,7 @@ def test_v7_adds_mcp_oauth_and_preserves_tokens(data_dir): conn.execute("DROP TABLE mcp_oauth") conn.execute("PRAGMA user_version = 6") conn.execute("INSERT INTO integration_tokens VALUES ('id', 'hash', 'user', 'ws', 'Codex', 'now', 9999999999, 'read')") - assert migrations.ensure_current()["applied"] == ["mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT id FROM integration_tokens").fetchone()[0] == 'id' assert conn.execute("SELECT * FROM mcp_oauth").fetchall() == [] @@ -38,7 +38,7 @@ def test_v6_adds_integration_tokens_and_is_repeatable(data_dir): conn.execute("DROP TABLE integration_tokens") conn.execute("PRAGMA user_version = 5") conn.commit() - assert migrations.ensure_current()["applied"] == ["integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT * FROM integration_tokens").fetchall() == [] assert migrations.ensure_current()["applied"] == [] @@ -51,7 +51,7 @@ def test_v5_adds_publisher_sessions_and_is_repeatable(data_dir): conn.execute("PRAGMA user_version = 4") conn.commit() result = migrations.ensure_current() - assert result["applied"] == ["publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert result["applied"] == ["publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT * FROM publisher_sessions").fetchall() == [] assert migrations.ensure_current()["applied"] == [] @@ -63,7 +63,7 @@ def test_v16_adds_pending_memberships_and_is_repeatable(data_dir): conn.execute("DROP TABLE pending_memberships") conn.execute("PRAGMA user_version = 15") conn.commit() - assert migrations.ensure_current()["applied"] == ["pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT * FROM pending_memberships").fetchall() == [] assert conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'idx_pending_subject'").fetchone() @@ -85,7 +85,7 @@ def test_v17_folds_appearance_into_the_profile(data_dir): conn.execute("INSERT INTO user_prefs VALUES (?, ?, ?, ?, ?)", (user, ws, key, json.dumps(value), OLD)) conn.execute("PRAGMA user_version = 16") conn.commit() - assert migrations.ensure_current()["applied"] == ["profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: stored = {(r[0], r[1]): (json.loads(r[2]), r[3]) for r in conn.execute( "SELECT username, key, value, updated_at FROM user_prefs")} @@ -115,7 +115,7 @@ def test_v18_marks_session_origin_and_grant_refusal(data_dir): "VALUES ('gamma-cloud', 'sub-18', 'mig18', ?, ?)", (OLD, OLD)) conn.execute("PRAGMA user_version = 17") conn.commit() - assert migrations.ensure_current()["applied"] == ["cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT via FROM sessions WHERE token = 'tok-18'").fetchone() == ("",) assert conn.execute("SELECT revoked_at FROM identities WHERE subject = 'sub-18'").fetchone() == ("",) @@ -135,7 +135,7 @@ def test_v19_gives_mirrors_a_page_filter(data_dir): "VALUES ('ws19', 'https://nas', 'r', 't', 'u', ?)", (OLD,)) conn.execute("PRAGMA user_version = 18") conn.commit() - assert migrations.ensure_current()["applied"] == ["mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT page_filter FROM mirrors WHERE workspace_id = 'ws19'").fetchone() == (None,) assert migrations.ensure_current()["applied"] == [] @@ -161,7 +161,7 @@ def test_v20_removes_the_legacy_guest_account(data_dir): conn.execute("INSERT INTO shares (token, workspace_id, page_id, created_at) VALUES ('sh-g', ?, 'p', ?)", (ws, OLD)) conn.execute("PRAGMA user_version = 19") conn.commit() - assert migrations.ensure_current()["applied"] == ["guest_accounts"] + assert migrations.ensure_current()["applied"] == ["guest_accounts", "folder_shares"] with connect_users_db() as conn: assert conn.execute("SELECT username, is_guest FROM users").fetchall() == [("mig20_nopw", 0)] for table in ("sessions", "workspaces", "workspace_members", "user_prefs", "shares"): @@ -173,6 +173,32 @@ def test_v20_removes_the_legacy_guest_account(data_dir): assert migrations.ensure_current()["applied"] == [] +def test_v21_gives_shares_a_folder_target(data_dir): + # shares gain folder (a share names a page OR a folder); the page unique + # index becomes partial and a folder twin joins it, so one page share and + # one folder share per workspace each stay unique while '' repeats freely + connect_users_db().close() + with closing(sqlite3.connect(str(data_dir / "users.db"))) as conn: + conn.execute("DROP TABLE shares") + conn.execute("CREATE TABLE shares (token TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, page_id TEXT NOT NULL, " + "created_by TEXT NOT NULL DEFAULT '', audience TEXT NOT NULL DEFAULT 'anyone', " + "role TEXT NOT NULL DEFAULT 'view', allowed_users TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)") + conn.execute("CREATE UNIQUE INDEX idx_shares_page ON shares(workspace_id, page_id)") + conn.execute("INSERT INTO shares (token, workspace_id, page_id, created_at) VALUES ('sh21', 'ws21', 'p1', ?)", (OLD,)) + conn.execute("PRAGMA user_version = 20") + conn.commit() + assert migrations.ensure_current()["applied"] == ["folder_shares"] + with connect_users_db() as conn: + assert conn.execute("SELECT page_id, folder FROM shares WHERE token = 'sh21'").fetchone() == ("p1", "") + conn.execute("INSERT INTO shares (token, workspace_id, page_id, folder, created_at) VALUES ('f1', 'ws21', '', 'a/b', ?)", (OLD,)) + conn.execute("INSERT INTO shares (token, workspace_id, page_id, folder, created_at) VALUES ('f2', 'ws21', '', 'c', ?)", (OLD,)) + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO shares (token, workspace_id, page_id, folder, created_at) VALUES ('f3', 'ws21', '', 'a/b', ?)", (OLD,)) + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO shares (token, workspace_id, page_id, folder, created_at) VALUES ('p2', 'ws21', 'p1', '', ?)", (OLD,)) + assert migrations.ensure_current()["applied"] == [] + + def test_v15_writes_the_old_default_into_modelless_ai_entries(data_dir): # Entries no longer fall back to a built-in model: an entry that had none # picked gets the default it was using; picked lists are left alone. @@ -187,7 +213,7 @@ def test_v15_writes_the_old_default_into_modelless_ai_entries(data_dir): (json.dumps({"providers": providers}), OLD)) conn.execute("PRAGMA user_version = 14") conn.commit() - assert migrations.ensure_current()["applied"] == ["ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with connect_users_db() as conn: stored = json.loads(conn.execute( "SELECT value FROM user_prefs WHERE key = 'ai-settings'").fetchone()[0])["providers"] @@ -279,7 +305,7 @@ def test_status_and_refusal_on_a_v0_directory(data_dir): build_v0(data_dir) st = migrations.status() assert st["version"] == 0 and st["target"] == SCHEMA_VERSION and not st["fresh"] - assert [p["name"] for p in st["pending"]] == ["baseline", "workspaces", "workspace_access", "workspace_kinds", "publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert [p["name"] for p in st["pending"]] == ["baseline", "workspaces", "workspace_access", "workspace_kinds", "publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] # Nothing but the runner may open an old users.db. with pytest.raises(SchemaOutdated): connect_users_db() @@ -291,7 +317,7 @@ def test_upgrade_v0_to_current(data_dir): build_v0(data_dir) result = migrations.ensure_current() assert result["from"] == 0 and result["to"] == SCHEMA_VERSION - assert result["applied"] == ["baseline", "workspaces", "workspace_access", "workspace_kinds", "publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert result["applied"] == ["baseline", "workspaces", "workspace_access", "workspace_kinds", "publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] assert migrations.data_version() == SCHEMA_VERSION # A snapshot of every database was taken first, with a manifest. @@ -385,7 +411,7 @@ def crash_after_first(conn, username, ws_id, data_db): m._move_prefs = original assert migrations.data_version() == 1 # the failed step did not stamp result = migrations.ensure_current() # resumes: the moved account is skipped, the rest done - assert result["applied"] == ["workspaces", "workspace_access", "workspace_kinds", "publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] and migrations.data_version() == SCHEMA_VERSION + assert result["applied"] == ["workspaces", "workspace_access", "workspace_kinds", "publisher_sessions", "integration_tokens", "mcp_oauth", "ai_usage", "upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] and migrations.data_version() == SCHEMA_VERSION with connect_users_db() as conn: assert conn.execute("SELECT COUNT(*) FROM users WHERE default_workspace = ''").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0] == 2 # the guest's went in step 20 @@ -468,7 +494,7 @@ def test_v9_repairs_leaked_upload_paths_once(data_dir): ("md", "root", "notes/c", {"original_filename": "notes/c.md", "markdown_import": True}), ("clean", "root", "d.pdf", {"original_filename": "d.pdf", "auto_title": "d.pdf"}), ]) - assert migrations.ensure_current()["applied"] == ["upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts"] + assert migrations.ensure_current()["applied"] == ["upload_path_titles", "mirrors", "mirror_cadence", "sync_log_stats", "sync_conflict_base", "identities", "ai_explicit_models", "pending_memberships", "profile", "cloud_grant", "mirror_page_filter", "guest_accounts", "folder_shares"] with closing(sqlite3.connect(str(ws_root / "pages.db"))) as conn: rows = {r[0]: (r[1], json.loads(r[2]), r[3]) for r in conn.execute( "SELECT id, content, properties, updated_at FROM unified_blocks WHERE parent_id = 'root'")} diff --git a/backend/tests/test_prefs_ai_settings.py b/backend/tests/test_prefs_ai_settings.py index ca067509..ae7aa69d 100644 --- a/backend/tests/test_prefs_ai_settings.py +++ b/backend/tests/test_prefs_ai_settings.py @@ -426,8 +426,9 @@ def test_shared_provider_is_masked_and_encrypted_at_rest(admin, shared): assert shared["key_hint"] == "…4242" and shared["label"] == "Lab key" listed = admin.get("/api/admin/ai-providers").json() assert SHARED_KEY not in str(listed) and listed["guests"] is False - # API-key protocols only: the form never offers the ChatGPT sign-in. - assert "chatgpt" not in [p["id"] for p in listed["protocols"]] + # A ChatGPT sign-in may be shared too (test_shared_chatgpt.py), through + # its own sign-in flow rather than this key form. + assert "chatgpt" in [p["id"] for p in listed["protocols"]] raw = _get_raw("ai_providers") assert raw and SHARED_KEY not in raw and "lab-model" in raw # Edits keep the stored key unless a new one is sent; validation is the diff --git a/backend/tests/test_shared_chatgpt.py b/backend/tests/test_shared_chatgpt.py new file mode 100644 index 00000000..52a81efc --- /dev/null +++ b/backend/tests/test_shared_chatgpt.py @@ -0,0 +1,195 @@ +"""A ChatGPT subscription as a SHARED server entry (docs/dev/ai.md "Shared +provider entries"): an admin signs in through /api/admin/ai-providers/ +chatgpt/start + complete, the tokens are stored encrypted in the server's +config, every account's runtime offers the entry (without the signed-in +e-mail), an expired token is refreshed once under the entry's own lock and +written back, and the allowance meters it like a shared key. All external +calls are faked — no network.""" + +import json +import threading +import time + +import pytest + +import gamma.chatgpt_oauth as co +from gamma import ai_catalog +from gamma.ai_protocols import chatgpt as chatgpt_proto + +from test_chatgpt_oauth import _FakeResp, _fake_tokens + + +@pytest.fixture(scope="module") +def admin(client): + from conftest import login, make_user + make_user("gpt_admin", "pw", is_admin=1) + return login("gpt_admin", "pw") + + +@pytest.fixture(scope="module") +def member(client): + from conftest import login, make_user + make_user("gpt_member", "pw") + return login("gpt_member", "pw") + + +def _listing(monkeypatch): + monkeypatch.setattr(chatgpt_proto, "codex_client_version", lambda: "9.9.9") + monkeypatch.setattr(ai_catalog, "urlopen", lambda req, timeout=0: _FakeResp({"models": [ + {"slug": "gpt-6-sol", "visibility": "list"}, + {"slug": "gpt-6-terra", "visibility": "list"}, + {"slug": "gpt-6-luna", "visibility": "list"}, + ]})) + + +def _connect(admin, monkeypatch, tokens=None, provider_id=""): + monkeypatch.setattr(co, "_token_request", lambda form: tokens or _fake_tokens(email="lab@example.com")) + state = admin.post("/api/admin/ai-providers/chatgpt/start").json()["state"] + return admin.post("/api/admin/ai-providers/chatgpt/complete", json={ + "state": state, "callback": f"http://localhost:1455/auth/callback?code=abc&state={state}", + "provider_id": provider_id, "name": "Lab ChatGPT"}) + + +@pytest.fixture +def shared(admin, monkeypatch): + """One connected shared ChatGPT entry; the shared config is emptied + after the test (the run shares one users.db).""" + from gamma import ai_settings + _listing(monkeypatch) + r = _connect(admin, monkeypatch) + assert r.status_code == 200, r.text + try: + yield next(p for p in r.json()["providers"] if p["protocol"] == "chatgpt") + finally: + ai_settings.save_server_ai({"providers": [], "guests": False}) + + +def _expire(provider_id, *, failed=False): + from gamma import ai_settings + + def change(config): + for e in config["providers"]: + if e["id"] == provider_id: + e["oauth"]["expires_at"] = int(time.time()) - 10 + if failed: + e["oauth"]["refresh_failed_at"] = int(time.time()) + else: + e["oauth"].pop("refresh_failed_at", None) + ai_settings.edit_server_ai(change) + + +def _stored_oauth(provider_id): + from gamma import ai_settings + return next(e for e in ai_settings.load_server_ai()["providers"] if e["id"] == provider_id)["oauth"] + + +def test_admin_form_offers_the_sign_in_protocol(admin): + body = admin.get("/api/admin/ai-providers").json() + assert any(p["id"] == "chatgpt" and p["auth"] == "oauth" for p in body["protocols"]) + # A sign-in is only made through the sign-in flow, never the key form. + r = admin.post("/api/admin/ai-providers", json={"protocol": "chatgpt", "api_key": "x" * 20}) + assert r.status_code == 400 + + +def test_admin_connects_a_shared_sign_in(shared, admin): + assert shared["id"].startswith("server:") + assert shared["oauth_connected"] is True + assert shared["account"] == "lab@example.com" # the admin sees whose it is + assert shared["models"] == "gpt-6-sol, gpt-6-terra" # seeded live, first two + assert "access" not in json.dumps(shared) + + +def test_tokens_are_stored_encrypted(shared): + from gamma.server_settings import _get_raw + raw = _get_raw("ai_providers") + oauth = _stored_oauth(shared["id"]) + assert oauth["access_token"] and oauth["access_token"] not in raw + assert oauth["refresh_token"] not in raw + + +def test_every_account_gets_the_shared_sign_in(shared, member): + models = member.get("/api/ai/models").json()["models"] + assert any(m["provider"] == shared["id"] and m["model"] == "gpt-6-sol" and m["shared"] for m in models) + row = next(p for p in member.get("/api/ai/settings").json()["providers"] if p["id"] == shared["id"]) + assert row["shared"] is True and row["oauth_connected"] is True + assert row["account"] == "" # the admin's e-mail stays the admin's + + +def test_sign_in_states_do_not_cross(admin, member, monkeypatch): + monkeypatch.setattr(co, "_token_request", lambda form: _fake_tokens()) + assert member.post("/api/admin/ai-providers/chatgpt/start").status_code == 403 + # A shared state can't be redeemed as the admin's own entry… + state = admin.post("/api/admin/ai-providers/chatgpt/start").json()["state"] + r = admin.post("/api/ai/oauth/chatgpt/complete", json={"state": state, "callback": "code"}) + assert r.status_code == 400 + # …and an own state can't become a shared entry. + state = admin.post("/api/ai/oauth/chatgpt/start").json()["state"] + r = admin.post("/api/admin/ai-providers/chatgpt/complete", json={"state": state, "callback": "code"}) + assert r.status_code == 400 + + +def test_reconnect_replaces_the_tokens(shared, admin, monkeypatch): + fresh = _fake_tokens(email="other@example.com") + r = _connect(admin, monkeypatch, tokens=fresh, provider_id=shared["id"]) + assert r.status_code == 200, r.text + rows = [p for p in r.json()["providers"] if p["protocol"] == "chatgpt"] + assert len(rows) == 1 and rows[0]["account"] == "other@example.com" + assert _stored_oauth(shared["id"])["access_token"] == fresh["access_token"] + + +def test_expired_shared_token_refreshes_once_for_everyone(shared, monkeypatch): + from gamma.ai_settings import ai_runtime + _expire(shared["id"]) + fresh = _fake_tokens(exp=int(time.time()) + 7200) + calls = [] + + def slow_refresh(form): + calls.append(form) + time.sleep(0.2) + return fresh + + monkeypatch.setattr(co, "_token_request", slow_refresh) + keys = [] + users = ["gpt_member", "gpt_admin"] * 3 + threads = [threading.Thread(target=lambda u=u: keys.append(ai_runtime(u)["providers"][shared["id"]]["api_key"])) + for u in users] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(calls) == 1 + assert keys == [fresh["access_token"]] * len(users) + assert _stored_oauth(shared["id"])["access_token"] == fresh["access_token"] + + +def test_only_an_admin_resets_a_shared_backoff(shared, admin, member, monkeypatch): + _expire(shared["id"], failed=True) + calls = [] + monkeypatch.setattr(co, "_token_request", lambda form: calls.append(form) or _fake_tokens()) + monkeypatch.setattr(ai_catalog, "fetch_json", lambda req: {}) + # Every account's login check: no refresh retried inside the backoff. + member.post("/api/ai/health", json={"provider_id": shared["id"]}) + assert calls == [] and "refresh_failed_at" in _stored_oauth(shared["id"]) + # The admin's Test is an explicit retry. + import gamma.routers.ai as ai_mod + monkeypatch.setattr(ai_mod, "_call_ai", lambda *a, **k: "ok") + r = admin.post(f"/api/ai/providers/{shared['id']}/test") + assert r.status_code == 200 and r.json()["ok"] is True, r.text + assert len(calls) == 1 and "refresh_failed_at" not in _stored_oauth(shared["id"]) + + +def test_subscription_usage_is_the_admins_to_see(shared, admin, member, monkeypatch): + monkeypatch.setattr(ai_catalog, "urlopen", lambda req, timeout=0: _FakeResp({ + "plan_type": "plus", "rate_limit": {"primary_window": { + "used_percent": 10, "limit_window_seconds": 18000, "reset_at": 1_900_000_000}}})) + r = admin.post(f"/api/ai/providers/{shared['id']}/usage") + assert r.status_code == 200 and r.json()["available"] is True, r.text + assert member.post(f"/api/ai/providers/{shared['id']}/usage").status_code == 404 + + +def test_the_allowance_meters_the_shared_sign_in(shared, admin): + from gamma.ai_settings import ai_runtime + assert admin.put("/api/admin/ai-providers", json={"allowance": {"accounts": 500}}).status_code == 200 + rt = ai_runtime("gpt_member") + assert rt["providers"][shared["id"]]["allowance"] == {"user": "gpt_member", "limit": 500} + assert rt["allowance"]["limit"] == 500 diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py index 025f76a4..656a21a5 100644 --- a/backend/tests/test_shares.py +++ b/backend/tests/test_shares.py @@ -100,7 +100,7 @@ def test_shared_chat_is_scoped_and_read_only(bob, carol, anon): for method, path, body in ( ("PUT", f"/api/chats/{page['id']}", {"messages": []}), ("DELETE", f"/api/chats/{page['id']}", None), - ("POST", "/api/chats/folder-rename", {"src": "private", "dst": "renamed"}), + ("POST", "/api/folders/rename", {"src": "private", "dst": "renamed"}), ("POST", "/api/chat-history/archive", {"bucket": page["id"]}), ("POST", "/api/chat-history/entry/open", {"bucket": page["id"]}), ("PUT", "/api/chat-history/entry", {"title": "Changed"}), @@ -490,3 +490,143 @@ def test_share_reads_are_cors_open_and_importable(bob, anon): got = dana.get(f"/api/blocks/{page['id']}/subtree").json()["block"] assert got["content"] == "Shared across Gammas" assert [c["content"] for c in got["children"]] == ["carried along"] + + +# ---- folder shares ----------------------------------------------------------- +# A share may name a folder instead of a page: the pages filed in that folder +# or below it, read live (gamma/auth.py ShareScope). Same audience / role / +# people model; the token confines reads and writes to those pages. + +def _folder_share(client, name, **settings): + r = client.post("/api/share/folder", params={"name": name}, json=settings or None) + assert r.status_code == 200, r.text + return r.json() + + +def test_folder_share_reaches_the_pages_filed_in_it(bob, anon): + inside = make_page(bob, "In the folder", {"folder": "lab/readout"}) + deeper = make_page(bob, "In a subfolder", {"folder": "lab/readout/sub, elsewhere"}) + outside = make_page(bob, "Outside", {"folder": "lab/other"}) + note = _child(bob, inside["id"], "a note in the folder") + + assert bob.post("/api/share/folder", params={"name": "lab/nowhere"}).status_code == 404 + assert bob.post("/api/share/folder", params={"name": " / "}).status_code == 400 + share = _folder_share(bob, "lab/readout/") + token = share["token"] + assert share["folder"] == "lab/readout" and share["page_id"] == "" + assert _folder_share(bob, "lab/readout")["token"] == token # stable, like a page's + assert bob.get("/api/share-settings/folder", params={"name": "lab/readout"}).json()["token"] == token + assert bob.get("/api/share-settings/folder", params={"name": "lab"}).json()["token"] is None + + resolved = anon.get(f"/api/share/{token}") + assert resolved.status_code == 200, resolved.text + data = resolved.json() + assert data["folder"] == "lab/readout" and data["page_id"] == "" and "doc_id" not in data + assert {p["id"] for p in data["pages"]} == {inside["id"], deeper["id"]} + listed = next(p for p in data["pages"] if p["id"] == deeper["id"]) + assert listed["title"] == "In a subfolder" and listed["folders"] == ["lab/readout/sub", "elsewhere"] + + q = {"share": token} + assert anon.get(f"/api/blocks/{inside['id']}", params=q).status_code == 200 + assert anon.get(f"/api/blocks/{note['id']}", params=q).status_code == 200 + assert anon.get(f"/api/blocks/{deeper['id']}/subtree", params=q).status_code == 200 + assert anon.get(f"/api/blocks/{outside['id']}", params=q).status_code == 403 + assert anon.get("/api/blocks/root/children", params=q).status_code == 403 + assert anon.get(f"/api/pages/{inside['id']}/ops", params=q).status_code == 200 + assert anon.get(f"/api/pages/{outside['id']}/ops", params=q).status_code == 403 + assert anon.get(f"/api/chats/{inside['id']}", params=q).status_code == 200 + assert anon.get(f"/api/chats/{outside['id']}", params=q).status_code == 403 + # whole-folder reads: the shared folder and its subfolders, nothing beside them + assert anon.get("/api/folders/export", params={**q, "name": "lab/readout", "mode": "readable"}).status_code == 200 + assert anon.get("/api/folders/export", params={**q, "name": "lab/readout/sub", "mode": "readable"}).status_code == 200 + assert anon.get("/api/folders/export", params={**q, "name": "lab", "mode": "readable"}).status_code == 403 + assert anon.get("/api/folders/export-progress", params=q).status_code == 200 + + # membership is live: a page filed later joins, one moved out leaves + later = make_page(bob, "Filed later", {"folder": "lab/readout"}) + assert anon.get(f"/api/blocks/{later['id']}", params=q).status_code == 200 + assert {p["id"] for p in anon.get(f"/api/share/{token}").json()["pages"]} == {inside["id"], deeper["id"], later["id"]} + bob.put(f"/api/blocks/{inside['id']}", json={"properties": {"folder": "lab/other"}}) + assert anon.get(f"/api/blocks/{inside['id']}", params=q).status_code == 403 + + r = bob.delete("/api/share-settings/folder", params={"name": "lab/readout"}) + assert r.json()["removed"] == 1 + assert anon.get(f"/api/share/{token}").status_code == 404 + assert bob.get("/api/share-settings/folder", params={"name": "lab/readout"}).json()["token"] is None + + +def test_folder_share_reads_only_its_pages_assets(bob, anon): + png = (b"\x89PNG\r\n\x1a\n" + b"\x00" * 64) + urls = [] + for name in ("in.png", "out.png"): + up = bob.post("/api/upload-image", files={"file": (name, png + name.encode(), "image/png")}) + assert up.status_code == 200, up.text + urls.append(up.json()["url"]) + inside = make_page(bob, "Figure inside", {"folder": "assets/shared"}) + _child(bob, inside["id"], f"![in]({urls[0]})") + outside = make_page(bob, "Figure outside") + _child(bob, outside["id"], f"![out]({urls[1]})") + token = _folder_share(bob, "assets/shared")["token"] + assert anon.get(urls[0], params={"share": token}).status_code == 200 + assert anon.get(urls[1], params={"share": token}).status_code == 403 + + +def test_folder_edit_share_writes_inside_the_folder_only(bob, carol): + page = make_page(bob, "Draft in folder", {"folder": "team/drafts"}) + outside = make_page(bob, "Not shared") + token = _folder_share(bob, "team/drafts", audience="list", + users=[{"name": "carol_share", "role": "edit"}])["token"] + q = {"share": token} + + r = carol.post("/api/blocks", params=q, json={"parent_id": page["id"], "content": "carol's line"}) + assert r.status_code == 200, r.text + new_id = r.json()["id"] + assert carol.put(f"/api/blocks/{new_id}", params=q, json={"content": "carol's line, fixed"}).status_code == 200 + r = carol.post(f"/api/pages/{page['id']}/ops", params=q, json={ + "client": "c1", "ops": [{"op": "set", "id": new_id, "content": "carol's line, via ops"}]}) + assert r.status_code == 200, r.text + # renaming the page is fine; re-filing it (its properties) is not — a + # share editor could otherwise move pages into or out of the share + assert carol.put(f"/api/blocks/{page['id']}", params=q, json={"content": "Draft, renamed"}).status_code == 200 + assert carol.put(f"/api/blocks/{page['id']}", params=q, json={"properties": {"folder": "team"}}).status_code == 403 + # never other pages, never new pages, never deleting a shared page + assert carol.post("/api/blocks", params=q, json={"parent_id": outside["id"], "content": "x"}).status_code == 403 + assert carol.post(f"/api/pages/{outside['id']}/ops", params=q, json={"client": "c1", "ops": []}).status_code == 403 + assert carol.post("/api/blocks", params=q, json={"parent_id": "root", "content": "new page"}).status_code == 403 + assert carol.delete(f"/api/blocks/{page['id']}", params=q).status_code == 403 + assert carol.post(f"/api/blocks/{new_id}/reorder", params=q, + json={"parent_id": outside["id"], "before": None, "after": None}).status_code == 403 + got = bob.get(f"/api/blocks/{page['id']}/subtree").json()["block"] + assert got["content"] == "Draft, renamed" + assert [c["content"] for c in got["children"]] == ["carol's line, via ops"] + assert bob.get(f"/api/blocks/{page['id']}").json()["properties"]["folder"] == "team/drafts" + + +def test_folder_share_follows_renames_and_dies_with_the_folder(bob, anon): + page = make_page(bob, "Moving page", {"folder": "old/x"}) + keep = make_page(bob, "Already at the destination", {"folder": "new/x"}) + old_x = _folder_share(bob, "old/x")["token"] + old = _folder_share(bob, "old")["token"] + taken = _folder_share(bob, "new/x")["token"] + + # a page share does not follow (its page is not a folder) + page_token = bob.post(f"/api/share/{page['id']}").json()["token"] + # what the frontend does on rename: the tags page by page, then this call + bob.put(f"/api/blocks/{page['id']}", json={"properties": {"folder": "new/x"}}) + r = bob.post("/api/folders/rename", json={"src": "old", "dst": "new"}) + assert r.status_code == 200, r.text + assert r.json()["shares_moved"] == 2 + assert bob.get("/api/share-settings/folder", params={"name": "new"}).json()["token"] == old + # the destination already had a share: it wins, the moved one is gone + assert bob.get("/api/share-settings/folder", params={"name": "new/x"}).json()["token"] == taken + assert anon.get(f"/api/share/{old_x}").status_code == 404 + assert anon.get(f"/api/share/{page_token}").json()["page_id"] == page["id"] + assert {p["id"] for p in anon.get(f"/api/share/{taken}").json()["pages"]} == {page["id"], keep["id"]} + + # deleting the folder drops its shares + r = bob.post("/api/folders/rename", json={"src": "new", "dst": ""}) + assert r.json()["shares_moved"] == 2 + for token in (old, taken): + assert anon.get(f"/api/share/{token}").status_code == 404 + assert anon.get(f"/api/share/{page_token}").status_code == 200 + assert anon.post("/api/folders/rename", params={"share": page_token}, json={"src": "a", "dst": "b"}).status_code == 403 diff --git a/docs/dev/ai.md b/docs/dev/ai.md index d3d9c40d..9a068a63 100644 --- a/docs/dev/ai.md +++ b/docs/dev/ai.md @@ -129,13 +129,25 @@ stream shapes. An admin can add provider entries for the whole server (Settings → Server → Shared AI provider, `/api/admin/ai-providers*`), so the members of a lab do not each need a key. A shared entry has an account entry's shape (`id, name, -protocol, api_key, base_url, models, test_model, created_at`), API-key -protocols only (no ChatGPT sign-in: its tokens belong to one person). The -list lives in the users.db `settings` KV under `ai_providers` as -`{providers: [...], guests: bool, allowance: {accounts, guests}}`, at most -`MAX_PROVIDERS` (20) entries, each `api_key` Fernet-encrypted with the data -directory's key the way the cloud client secret is (`publisher_sessions.cipher`); a key that no longer -decrypts reads as no key and logs a warning. The same helpers validate both +protocol, api_key, base_url, models, test_model, created_at`, plus `oauth` +for a sign-in): an API key, or a ChatGPT subscription the admin signs in to +from the same form (`POST /api/admin/ai-providers/chatgpt/start` + +`complete`, the account flow's `begin_chatgpt_signin` / +`redeem_chatgpt_signin` with the state bound to `("server", )`, so +neither side's state redeems on the other; `provider_id` on `complete` +reconnects an entry). The list lives in the users.db `settings` KV under +`ai_providers` as `{providers: [...], guests: bool, allowance: {accounts, +guests}}`, at most `MAX_PROVIDERS` (20) entries, each `api_key` and each +sign-in's `oauth` tokens Fernet-encrypted with the data directory's key the +way the cloud client secret is (`publisher_sessions.cipher`); a key or a +sign-in that no longer decrypts reads as none and logs a warning. A shared +sign-in's token refresh runs under the entry's own lock +(`_refreshed_server_oauth`: every account's requests refresh the same +tokens, and OpenAI rotates the refresh token) and writes only the tokens +back. Its refresh backoff is reset only by an admin's Test, usage query or +login check, so a dead shared grant is not retried on every account's +login. The signed-in e-mail (`account`) is masked like the key hint: admins +only. The same helpers validate both lists (`new_key_entry`, `update_entry`, `apply_provider_fields`, `mask_entry` in `ai_settings.py`). @@ -147,8 +159,9 @@ the admin switch `guests` is on (default off); a name that is not an account (a link visitor) never does. `GET /api/ai/settings` lists them after the account's own as read-only rows (`shared: true`), with the last-4 key hint for admins only; `/api/ai/providers/{id}` never edits or deletes them (404). -An admin may name a shared id on the Test probe and the model catalog, which -is how the Server section's form lists models and tests a saved entry; the +An admin may name a shared id on the Test probe, the model catalog and a +sign-in's subscription usage (`/api/ai/providers/{id}/usage`), which is how +the Server section's form lists models and tests a saved entry; the login check (`/api/ai/health`) accepts any entry the account can use. Token usage stays per account: a member's calls through a shared entry are recorded on that member (provider id `server:`), and there is no diff --git a/docs/dev/api.md b/docs/dev/api.md index 2b678603..76f249f6 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -15,16 +15,21 @@ else; in dev, Vite proxies `/api` → `127.0.0.1:9001`. a caller without effective access gets 403. The returned workspace id is what the data helpers take; `request.state.user` stays the actor. - Share tokens (`?share=`) are the ONLY unauthenticated **read** path. - `resolve_ws` returns the session's workspace, or the workspace of the page + `resolve_ws` returns the session's workspace, or the workspace of the share named by a valid `?share=` token — there is no `?user=` fallback (it used to trust any username and leaked whole accounts). A share is keyed by (workspace, PAGE) — the page's root block, so note pages without a PDF share exactly like papers; the PDF is just the page's `doc_id`/`source_url` - — and scoped to it: read endpoints that can serve a share view also call - `share_scope_page()` and `blocks_store.assert_block_in_page()`, so a token - can only reach its own page's subtree and assets (its PDF, uploads its - blocks reference, its own `source_url` through the proxy) — root listing, - backlinks, other pages, and folder export are refused (403). + — or by (workspace, FOLDER): a folder-label path, reaching the pages filed + there or below it, read live (pages filed later join, pages moved out + leave). Either way the token is scoped: `auth.share_scope()` hands every + share-enabled endpoint a `ShareScope` (`allows_page` / `allows_block` / + `allows_folder`; `blocks_store.assert_block_in_scope()` for block reads), + so a token can only reach its own pages' subtrees and assets (their PDFs, + uploads their blocks reference, their own `source_url` through the proxy) + — root listing, backlinks and other pages are refused (403); folder export + is refused for a page share and allowed for the shared folder and its + subfolders. Nothing outside `ShareScope` branches on the share's kind. - Share reads are readable **cross-origin**: a GET carrying `?share=` or resolving `/share/{token}` answers `Access-Control-Allow-Origin: *` (`auth._apply_share_cors`), so another Gamma's frontend can pull a shared @@ -42,16 +47,17 @@ else; in dev, Vite proxies `/api` → `127.0.0.1:9001`. `audience` `anyone` (no session needed, with the share's `role`), `users` (any signed-in non-guest account, with the share's `role`), `list` (nobody beyond the invited). When a request carries `?share=`, the token decides - WHICH WORKSPACE is read (the page's — a signed-in visitor sees the shared - page, not their own library) while the session decides whether the + WHICH WORKSPACE is read (the share's — a signed-in visitor sees the shared + page or folder, not their own library) while the session decides whether the audience gate admits them; a refused token is 401 when signing in could help, else 403. `edit` shares let `require_ws_writer` resolve the workspace for the block writers — `POST /blocks`, `PUT /blocks/{id}`, `DELETE /blocks/{id}`, `PUT /blocks/{id}/children`, `POST /blocks/{id}/reorder`, `POST /pages/{id}/ops` (and the page websocket, view or edit), `POST /upload-image`, `POST /upload-file` — each of which confines the touched blocks to the - shared page (no new pages, no deleting/moving the page itself, no changes to - the page root's properties). Everything else stays session-only. + shared pages (no new pages, no deleting/moving a page itself, no changes to + a page root's properties — so a folder edit share can never re-file pages + into or out of its folder). Everything else stays session-only. - **Link visitors.** An `anyone` + `edit` share makes the link itself the key: whoever opens it edits the page, without an account. Such a writer (no session, or a guest account — `auth.is_link_visitor`) is recorded @@ -217,9 +223,11 @@ guarded fetch path. | GET | `/pdf-info/{doc_id}` | the document manifest the viewer lays a PDF out from before pdf.js has parsed it (`gamma/pdf_meta.py`, [pdf_loading.md](pdf_loading.md)): `{doc_id, bytes, pages, dims: [[w, h], …]}` in PDF points, rotation applied; same access rule as the file; computed in pdfium on first request when the upload-time background walk has not run (`pages: 0` for an unreadable file, not cached); 400 malformed id, 404 no such file | | GET, HEAD | `/uploads/{filename}` | serve stored files (HEAD: the headers alone, which is how the viewer learns a file's size before choosing its transport); with their media type (`storage.FILE_MEDIA_TYPES`, else `application/octet-stream`); pdf / images / txt / md render inline, everything else is `Content-Disposition: attachment` (html additionally sandboxed like svg); blocked or malformed extensions 400 | | GET | `/quota` | the limits that apply to uploads into the request's workspace — the account's for a personal one (`used_bytes` = all its personal workspaces), the workspace's own for a shared one — with `workspace_bytes` and `account` (the person, or "") | +| POST | `/share/folder?name=` | create the FOLDER's share link (`name` a folder-label path; same defaults and optional body as a page's) or return the existing one unchanged; 400 for an empty path, 404 when no page is filed in the folder; workspace editors and owners | +| GET/PUT/DELETE | `/share-settings/folder?name=` | the folder share's settings (`{token: null}` when unshared; any member) / changes / stop — exactly like a page's; the share follows folder renames through `/folders/rename` | | POST | `/share/{page_id}` | create the page's share link (defaults `anyone`/`view`; optional body `{audience, role, users}` applies to a NEW link) or return the existing one unchanged — root blocks only (400 otherwise); workspace editors and owners | | GET/PUT/DELETE | `/share-settings/{page_id}` | read settings (`{token: null}` when unshared; any member) / change `audience`, `role`, `users` (`["carol"]` or `[{name, role}]`; validated: unknown usernames or roles → 400; the token stays; `edit`+`anyone` is allowed — see "Link visitors" above) / stop sharing (the token dies) — editors and owners | -| GET | `/share/{token}` | resolve a link for this viewer → `{page_id, doc_id, username (who shared it), workspace_id, audience, role, can_edit, viewer, viewer_is_guest}` (`doc_id` = the page's PDF attachment id via `page_attachment`, `""` without one; `viewer`/`viewer_is_guest` let the share view offer "Open in my library" or "Add to my library"); 404 unknown, 401 sign in first, 403 signed in but not allowed | +| GET | `/share/{token}` | resolve a link for this viewer → `{page_id, folder, username (who shared it), workspace_id, audience, role, can_edit, viewer, viewer_is_guest}` plus, for a page share, `doc_id` (the page's PDF attachment id via `page_attachment`, `""` without one) or, for a folder share, `pages` (the share view's listing, `[{id, title, doc_id, folders, labels, created_at, updated_at}]`, newest edit first); `viewer`/`viewer_is_guest` let the share view offer "Open in my library" or "Add to my library"; 404 unknown, 401 sign in first, 403 signed in but not allowed | ### Search (`search.py`, `gamma/block_index.py`, `gamma/pdf_index.py`) | Method | Path | Purpose | @@ -299,7 +307,6 @@ archived conversation browsing remains session-only. | Method | Path | Purpose | |---|---|---| | GET/PUT/DELETE | `/chats/{key:path}` | the ACTIVE conversation per bucket: page id, `home`, or `home:` (hence `:path`); GET → `{messages, title}`, PUT `{messages, title?}` (title omitted = keep) | -| POST | `/chats/folder-rename` | migrate folder buckets (active + history) on rename/move/delete (`{src, dst}`, dst `""` deletes) | | GET | `/chat-history?bucket=` | the bucket's archived conversations, newest first (`{sessions: [{id, title, preview, count, created_at, updated_at}]}`) | | POST | `/chat-history/archive` | "New chat": file `{bucket, messages, title}` into history and clear the active row (→ `{id}`, null when empty) | | POST | `/chat-history/{id}/open` | make an entry the active conversation; the body's `{bucket, messages, title}` (the current one) is archived first (→ `{messages, title}`) | @@ -322,6 +329,7 @@ archived conversation browsing remains session-only. | POST | `/import/zotero` | Zotero library import: zip of a "Zotero RDF" export (multipart `file`; `strip`, optional `folder` prefix). Items and standalone/additional PDFs→pages+metadata, collections→folders, tags→labels, notes→blocks; embedded annotations via the same importer. Idempotent by file hash / `zotero_key`; returns page destinations and warnings | | GET | `/pages/{id}/export` | page export (`?mode=readable|obsidian|notes-pdf|logseq-graph|zotero-rdf|gamma` + `highlights=¬es=&pdf=`); `obsidian` = a vault zip (`/.md`, wikilinks, `attachments/`, `.obsidian/app.json`); `notes-pdf` = the notes typeset as their own PDF (works without a paper); `gamma` = scoped backup for `/import-data?mode=merge` | | GET | `/pages/{id}/export-pdf` | the page's own PDF with annotations written back (`?highlights=¬es=`) | +| POST | `/folders/rename` | follow a folder rename/move/delete for what names a folder by its path — the per-folder chat buckets (active + history, `chats.move_folder_buckets`) and folder shares (`shares.move_folder_shares`; one already at the destination wins): `{src, dst}`, dst `""` deletes → `{ok, moved, history_moved, shares_moved}`; workspace editors, never through a share link (`gamma/routers/folders.py`) | | GET | `/folders/export` | whole-folder export, same modes/flags (`?name=` + `mode=`); subfolders become Zotero collections or vault directories, `notes-pdf` one PDF for the whole folder | | GET | `/folders/export-progress` | per-page progress of a running folder export (`{active, total, done, title}`) | @@ -403,7 +411,8 @@ Session only, the mirror's owner, never a guest. | GET/PUT | `/admin/settings` | server-wide storage defaults, plus `public_url` / `public_url_source` (the admin-confirmed public server URL, [mcp.md](mcp.md)), `guest_ttl_hours` / `guest_ttl_source` (1–720 hours, `guest_ttl_hours_range`; `environment` when `GAMMA_GUEST_TTL_HOURS` decides) and `demo_mode` / `demo_mode_source` (`environment` when `GAMMA_DEMO` is on) — a PUT of either is 400 while the environment decides ([guests.md](guests.md)) — and `cloud` (the cloud sign-in settings, written as `cloud_issuer`, `cloud_client_id`, `cloud_client_secret`, `cloud_policy`, `cloud_share_host` — [cloud_accounts.md](cloud_accounts.md)) | | GET | `/admin/logs?after=<seq>` | scrubbed in-memory server log | | GET/PUT | `/admin/ai-providers` | the server's shared AI entries, masked like `/ai/settings` (key hint, never the key; API-key `protocols` and `services` only), `guests` and `allowance` (`{accounts, guests}`: tokens per account per rolling 24 h, 0 = unlimited); PUT `{guests?, allowance?: {accounts?, guests?}}` (whole numbers 0..10^9, else 400). Chat, translate, metadata fetch/cite and transcribe answer 429 with a human `detail` once an account's allowance is used up; streams end with `{error: detail}` | -| POST/PUT/DELETE | `/admin/ai-providers[/{id}]` | add / edit / remove a shared entry (the `/ai/providers` fields and validation, API-key protocols only, at most 20; ids are `server:<id>`; the key is write-only and stored encrypted). Admin session only: an integration token is refused. Test and model listing go through `/ai/providers/{id}/test` and `/ai/model-catalog` | +| POST/PUT/DELETE | `/admin/ai-providers[/{id}]` | add / edit / remove a shared entry (the `/ai/providers` fields and validation, at most 20; a POST takes API-key protocols only; ids are `server:<id>`; the key is write-only and stored encrypted). Admin session only: an integration token is refused. Test, model listing and a sign-in's usage go through `/ai/providers/{id}/test`, `/ai/model-catalog` and `/ai/providers/{id}/usage` | +| POST | `/admin/ai-providers/chatgpt/start` · `/admin/ai-providers/chatgpt/complete` | a shared ChatGPT subscription: the `/ai/oauth/chatgpt/*` flow (same bodies) for the server's list — `complete` adds a shared sign-in entry, or with `provider_id` reconnects one, and returns the shared view; the tokens are stored encrypted; a state from one flow never redeems on the other. Admin session only | | GET | `/admin/server-info?refresh=` | the Server dashboard (`gamma/version.py`): `version` / `commit` / `label` (from `GAMMA_VERSION` / `GAMMA_COMMIT` — the Docker build and the desktop shell set them; a checkout is a "development build"), `started_at`, `uptime_seconds`, `python`, `platform`, `schema_version`, `frozen`, `log_counts` `{info, warning, error}` since startup, `latest` (`{version, url, published_at}` from the GitHub Releases API, cached six hours, ten minutes after a failure, `refresh=1` refetches; `GAMMA_UPDATE_CHECK=off` disables) or `latest_error`, `update_available` (True/False, None without a version to compare), `image`, `releases_url`. Sync: it may hit the network | Rails: a guest account takes storage limits and deletion but no password, diff --git a/docs/dev/guests.md b/docs/dev/guests.md index 67645857..89fd8e58 100644 --- a/docs/dev/guests.md +++ b/docs/dev/guests.md @@ -82,8 +82,9 @@ environment variable decides (`gamma/server_settings.py guest_settings`). ## The shared AI allowance The admin's shared provider entries (`gamma/ai_settings.py`, Settings → -Server → Shared AI) are what "per-server AI access" means: every account -gets them after its own entries, the guest switch adds guests, and every +Server → Shared AI; an API key or a ChatGPT subscription the admin signs in +to) are what "per-server AI access" means: every account gets them after +its own entries, the guest switch adds guests, and every account that is not a guest may add its own keys on top. The allowance meters the shared entries only, per account, over a rolling 24 hours: diff --git a/docs/dev/home_library.md b/docs/dev/home_library.md index 09832a3c..92ffacf4 100644 --- a/docs/dev/home_library.md +++ b/docs/dev/home_library.md @@ -164,3 +164,11 @@ rollup), checks the ones the selection already carries, and ends with the per-tag "remove from" rows; adding still uses the soft-link `addPagesToFolder` (same as dropping a card on a folder). Every page card surface opens the SAME menu — the Recently-viewed strip and the pinned strip included. + +A folder's menu also has **Share…**: it opens the folder and the share +popover (`sharing/SharePopover.jsx` with a folder `target`, otherwise the +page header's popover word for word) under the topbar's link button — the +same button a page shows, offered while a folder is open — one link for +every page filed in the folder, now and later ([api.md](api.md) "Shares"). Renaming, moving or +deleting a folder carries its chat buckets and its shares along through +`POST /folders/rename`, the one call after the tag rewrite. diff --git a/docs/dev/mcp.md b/docs/dev/mcp.md index 8917d469..e9c7e399 100644 --- a/docs/dev/mcp.md +++ b/docs/dev/mcp.md @@ -162,7 +162,7 @@ Ask Codex to find a page, search a topic, or summarize notes. Tools available: | `search_library` | Full-text note and PDF matches, with source locations | | `read_page` | Notes, highlights, properties, and windowed PDF text | | `read_block` | One block/subtree or a page's nested note outline | -| `read_gamma_link` | Resolve and read a page, block, or share URL, including PDF page context | +| `read_gamma_link` | Resolve and read a page, block, or share URL, including PDF page context; a folder-share URL lists the folder's pages | ### Send a page to either assistant @@ -177,6 +177,12 @@ Copy the page URL directly from the browser's address bar, or use an existing block or share link. The assistant keeps that reference as context until another is supplied; it does not track the user's active tab or PDF scroll position. +A folder-share link (a `?share=` token naming a folder, [api.md](api.md) +"Shares") resolves to the folder and answers with `list_pages` over it — the +pages the share view lists, with the page URL template for citing them. With +`&page=` beside the token it reads that page instead, refused unless the page +is filed in the folder. + Links never grant extra MCP access. Share tokens resolve only inside the already authorized workspace, including restricted shares whose workspace the user can already read. Revoked, unknown, mismatched, or cross-workspace references fail diff --git a/docs/dev/migrations.md b/docs/dev/migrations.md index e909c1e0..d7955082 100644 --- a/docs/dev/migrations.md +++ b/docs/dev/migrations.md @@ -65,6 +65,7 @@ workspace's files), `db.SCHEMA_VERSION`, `manage.py migrate` / `backups`. | 18 | `cloud_grant` | `sessions` gains `via` (`''` a password or the guest, `cloud` a Gamma Cloud sign-in; existing rows count as password sessions) and `identities` gains `revoked_at`: the grant check ends only the sessions a cloud sign-in minted when the account server refuses that account's grant ([cloud_accounts.md](cloud_accounts.md)) | | 19 | `mirror_page_filter` | `mirrors` gains `page_filter`: NULL (every page travels, what every existing mirror keeps) or a JSON list of the only page ids that do, the shape a published page's mirror has ([mirror.md](mirror.md) "The page filter") | | 20 | `guest_accounts` | Guests became throwaway accounts minted per login ([guests.md](guests.md)): the legacy shared `guest` account (`is_guest = 1`) is deleted with its sessions, memberships, prefs, shares, tokens and usage rows, and its personal workspace's rows, directory and stored snapshots (a directory that will not go is logged and left as an orphan). Any other `is_guest` row — what `manage.py create-user` without a password used to make — becomes a normal password-less account, so the guest expiry never deletes it | +| 21 | `folder_shares` | `shares` gains `folder`: a share names a page (`page_id`) or a folder-label path (`folder`, the pages filed there or below it, read live), the other column `''`; the page unique index becomes partial (`WHERE page_id != ''`) and a folder twin joins it ([api.md](api.md) "Shares") | ## Backups (`gamma/backups.py`) diff --git a/docs/dev/settings.md b/docs/dev/settings.md index 08e94171..92f22dbc 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -302,8 +302,11 @@ Manage: lists the server's shared connections with the same rows and the same add/edit form as Connections (`ProviderRow`, `ProviderForm`; the form's state comes from `useProviderEditor` over `/api/admin/ai-providers` -instead of App's aiKeys group). API-key services only. Each row has Test, -Manage and delete; "+ Add provider" is the section's action. A "Guests may +instead of App's aiKeys group). An API-key service, or a ChatGPT +subscription signed in with the account form's paste-the-callback steps +(`/api/admin/ai-providers/chatgpt/*`). Each row has Test, Manage and delete, +plus Usage (the subscription's windows) on a sign-in; "+ Add provider" is +the section's action. A "Guests may use it" switch (default off) decides whether guests get them ([ai.md](ai.md) "Shared provider entries"). While at least one shared entry exists, two `UnitInput` rows set the shared allowance, **Allowance per diff --git a/docs/dev/workspaces.md b/docs/dev/workspaces.md index f2824cbc..ccdb12c6 100644 --- a/docs/dev/workspaces.md +++ b/docs/dev/workspaces.md @@ -155,13 +155,14 @@ Keep identity and data location separate in endpoint code: | `require_ws(request, write=True)` | Workspace ID with editor or owner access | | `resolve_ws(request)` | Read through a share token, otherwise normal workspace access | | `require_ws_writer(request)` | Write through an edit share, otherwise workspace editor access | -| `share_scope_page(request)` | Page boundary that a share-enabled endpoint must enforce | +| `share_scope(request)` | The `ShareScope` (one page, or the pages filed in one folder) a share-enabled endpoint must enforce | Without a share token, selection is `?ws=` first, then `X-Gamma-Workspace`, then the account's default. An inaccessible explicit workspace is refused; the server does not fall back to another library. A share token chooses its -own workspace and confines access to one page. Workspace roles and page -invites determine whether that person can view or edit it. +own workspace and confines access to one page, or to the pages filed in one +folder ([api.md](api.md) "Shares"). Workspace roles and the share's invites +determine whether that person can view or edit them. Pass the workspace ID to data helpers such as `connect_pages_db` and `commit_ops`. Use `request.state.user` as the actor in the operation log. diff --git a/docs/user_guide.md b/docs/user_guide.md index 4c7a8291..526057f2 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -130,6 +130,8 @@ The **link button** in the top bar shares the open page, Notion-style: - Viewers see the PDF, highlights and notes, no login needed; editors edit alongside you, with live cursors. A visitor editing through an anyone-with-the-link share is asked for a display name. - **Stop sharing** ends the link; share again for a new one. Copied links carry the workspace, so a teammate opening one lands in the right library. +**Sharing a folder** works the same way: right-click a folder and choose **Share…**, or open the folder and press the top bar's link button. The link opens every page filed in the folder — including pages you file there later — as a small library; visitors click a page to read it and return with the home button. The same audience and View / Edit choices apply; an edit link lets people edit those pages' notes but never move pages in or out of the folder. + ## Workspaces <img alt="Personal workspaces next to a shared research library where an owner and an editor type into two blocks of the same page at the same time and a viewer reads along" src="assets/branding/gamma-workspaces-light.svg" width="100%"> diff --git a/frontend/src/README.md b/frontend/src/README.md index bdcd6bb7..a2a79e11 100644 --- a/frontend/src/README.md +++ b/frontend/src/README.md @@ -17,7 +17,7 @@ through barrel files. `main.jsx` remains the Vite entry point. | `pdf/` | `PdfViewer.jsx`, document loading, citations, translation, and scroll alignment | | `search/` | Workspace search (`SearchPanel.jsx`) | | `settings/` | `SettingsDialog.jsx`, individual settings panes, shared pane controls (`SettingsKit.jsx`), the profile sync reading (`syncState.js`), navigation, integration setup, and `settings.css` | -| `sharing/` | The page Share popover (`SharePopover.jsx`): link, access, invited people, stop sharing | +| `sharing/` | The Share popover (`SharePopover.jsx`, a page or a folder as its target): link, access, invited people, stop sharing; the share view's folder listing (`SharedFolder.jsx`) | | `support/` | Report a problem: the dialog (`ReportProblem.jsx`) and the pure report builder it and the tests share (`problemReport.js`) | | `transfers/` | Import/export dialogs (`ImportExport.jsx`), the import review (`ImportReviewDialog.jsx`, `ImportTree.jsx`, `importApi.js`, `importReview.js`), format rules, and upload/file chips (`FileChip.jsx`) | | `shared/model/` | Block tree helpers (`blockModel.js`), block operations (`blockOps.js`), and highlight colors | diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 69c73d7e..e9efcb99 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -102,6 +102,7 @@ import GuideOverlay from "../guide/GuideOverlay"; import { guideEvents } from "../guide/events"; import { Empty, QuotaMeter, Section } from "../settings/SettingsKit"; import { CopyBox, SharePopover } from "../sharing/SharePopover"; +import { SharedFolder } from "../sharing/SharedFolder"; import { MirrorPopover } from "../collaboration/MirrorPopover"; import { addFolderTag, @@ -405,6 +406,9 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const shareMode = Boolean(initialShare) || Boolean(publicPage); const [readOnly, setReadOnly] = useState(shareMode); const [shareInfo, setShareInfo] = useState(null); // resolved share: {owner, role, canEdit, audience, viewer} + // A folder share's listing ({name, pages}): the share view shows it until + // a card opens one of its pages, and the topbar's home button returns to it. + const [sharedFolder, setSharedFolder] = useState(null); // "login" | "forbidden" | "missing" while the share can't open const [shareGate, setShareGate] = useState(publicPage?.missing ? "missing" : null); const [linkName, setLinkNameState] = useState(""); // the share view's display name when the viewer has no account @@ -1314,15 +1318,16 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const prefixMapTag = (oldPath, newPath) => (t) => t === oldPath ? newPath : t.startsWith(oldPath + "/") ? newPath + t.slice(oldPath.length) : t; - // Per-folder home-chat buckets ("home:<path>") follow the same prefix - // rewrites as the folder tags; dst "" drops the conversations (folder - // deleted). Runs BEFORE the tag rewrite flips folderFilter, so ChatDock - // reloads the destination bucket only after it exists. Best-effort — a - // failed move orphans a conversation, never page data. + // Per-folder home-chat buckets ("home:<path>") and folder shares follow + // the same prefix rewrites as the folder tags (POST /folders/rename); dst + // "" drops them (folder deleted). Runs BEFORE the tag rewrite flips + // folderFilter, so ChatDock reloads the destination bucket only after it + // exists. Best-effort — a failed move orphans a conversation or a share, + // never page data. async function moveFolderChats(moves) { for (const [src, dst] of moves) { try { - await apiJson(`${API}/chats/folder-rename`, { + await apiJson(`${API}/folders/rename`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ src, dst }), @@ -2334,6 +2339,9 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { // not shared, else {token, audience, role, users}. The link is derived. const [shareSettings, setShareSettings] = useState(null); const [shareError, setShareError] = useState(""); + // What the popover is about: {kind: "page", id} (the open page) or + // {kind: "folder", name} (a folder of the home library). + const [shareTarget, setShareTarget] = useState(null); const shareUrl = shareSettings?.token ? `${window.location.origin}${window.location.pathname}?share=${shareSettings.token}` : ""; @@ -4774,44 +4782,21 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { setLinkNameState(name); } - // The share names a page block directly (PDF pages and note pages - // alike). Read access rides on the token, which apiJson appends to every - // API call in a share view (utils.withShare) — never a bare ?user=. - let block = null; - try { block = await apiJson(`${API}/blocks/${encodeURIComponent(data.page_id)}`); } catch {} - if (publicPage && block) { - // the address bar keeps the page host's pretty address, its slug following the title - window.history.replaceState(window.history.state, "", publicPath(block.content, data.page_id) + window.location.hash); - } - - let childBlocks = []; - if (block) { - try { - const subtreeData = await apiJson(`${API}/blocks/${block.id}/subtree`); - childBlocks = normalizeBlocks(subtreeData.block?.children || []); - loadedSeqRef.current = subtreeData.seq ?? null; - } catch {} + if (data.folder) { + // A folder share: its listing, or — with `page=` in the URL — one of + // its pages (goSharedPage keeps the two in the history). + const pages = data.pages || []; + setSharedFolder({ name: data.folder, pages }); + setReadOnly(!data.can_edit); + if (initialBlockId && pages.some((p) => p.id === initialBlockId)) { + await openSharedPage(token, initialBlockId, data); + } else { + setPageTitle(data.folder); + setStatus(t("Loaded shared folder.")); + } + return; } - - const props = block?.properties || {}; - const src = attachmentSource(pageAttachment(block)); - const isLocal = src.startsWith("/api/"); - const proxiedUrl = isLocal - ? `${src}${src.includes("?") ? "&" : "?"}share=${encodeURIComponent(token)}` - : src ? pdfProxyUrl(src, { share: token }) : ""; - - suppressAutosaveRef.current = true; - setFocusedBlockId(block?.id || ""); - setFocusedBlock(block || null); - setPageTitle(block?.content || defaultPageTitle(pageAttachment(block))); - setBlocks(childBlocks); - setDocId(props.doc_id || data.doc_id || ""); - setInputUrl(src); - setPdfUrl(proxiedUrl); - // Edit rights arrive with the share; the autosave effect's suppress flag - // (set above) swallows the first blocks change either way. - setReadOnly(!data.can_edit); - setStatus(data.can_edit ? t("Shared by {username} — your edits save to their page.", { username: data.username }) : t("Loaded shared page.")); + await openSharedPage(token, data.page_id, data); } catch (err) { setStatus(t("Share open failed: {message}", { message: err.message })); } finally { @@ -4819,6 +4804,85 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { } } + // Load one shared page into the share view — a page share's page, or a + // page of a folder share; `share` is the resolved link ({can_edit, + // username, doc_id?}). The share names a page block directly (PDF pages + // and note pages alike). Read access rides on the token, which apiJson + // appends to every API call in a share view (utils.withShare) — never a + // bare ?user=. + async function openSharedPage(token, pageId, share) { + if (focusedBlockId) leaveCurrentPage(); + let block = null; + try { block = await apiJson(`${API}/blocks/${encodeURIComponent(pageId)}`); } catch {} + if (publicPage && block) { + // the address bar keeps the page host's pretty address, its slug following the title + window.history.replaceState(window.history.state, "", publicPath(block.content, pageId) + window.location.hash); + } + + let childBlocks = []; + if (block) { + try { + const subtreeData = await apiJson(`${API}/blocks/${block.id}/subtree`); + childBlocks = normalizeBlocks(subtreeData.block?.children || []); + loadedSeqRef.current = subtreeData.seq ?? null; + } catch {} + } + + const props = block?.properties || {}; + const src = attachmentSource(pageAttachment(block)); + const isLocal = src.startsWith("/api/"); + const proxiedUrl = isLocal + ? `${src}${src.includes("?") ? "&" : "?"}share=${encodeURIComponent(token)}` + : src ? pdfProxyUrl(src, { share: token }) : ""; + + suppressAutosaveRef.current = true; + setFocusedBlockId(block?.id || ""); + setFocusedBlock(block || null); + setPageTitle(block?.content || defaultPageTitle(pageAttachment(block))); + setBlocks(childBlocks); + setDocId(props.doc_id || share.doc_id || ""); + setInputUrl(src); + setPdfUrl(proxiedUrl); + // Edit rights arrive with the share; the autosave effect's suppress flag + // (set above) swallows the first blocks change either way. + setReadOnly(!share.can_edit); + setStatus(share.can_edit ? t("Shared by {username} — your edits save to their page.", { username: share.username }) : t("Loaded shared page.")); + } + + // A folder share's navigation between its listing ("") and a page, each a + // history entry (`page=` beside the token); popstate replays it without + // pushing. The ref keeps the once-registered listener on the latest closure. + function goSharedPage(pageId, { push = true } = {}) { + if (!sharedFolder) return; + if (push) { + const url = `${window.location.pathname}?share=${encodeURIComponent(initialShare)}${pageId ? `&page=${encodeURIComponent(pageId)}` : ""}`; + window.history.pushState(null, "", url); + } + if (pageId) { + setLoading(true); + openSharedPage(initialShare, pageId, { can_edit: !!shareInfo?.canEdit, username: shareInfo?.owner || "" }) + .catch((err) => setStatus(t("Share open failed: {message}", { message: err.message }))) + .finally(() => setLoading(false)); + return; + } + leaveCurrentPage(); + setFocusedBlockId(""); + setFocusedBlock(null); + setBlocks([]); + setDocId(""); + setInputUrl(""); + setPdfUrl(""); + setPageTitle(sharedFolder.name); + } + const goSharedPageRef = useRef(goSharedPage); + goSharedPageRef.current = goSharedPage; + useEffect(() => { + if (!shareMode) return undefined; + const onPop = () => goSharedPageRef.current(new URLSearchParams(window.location.search).get("page") || "", { push: false }); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, [shareMode]); + async function openBlock(blockId, opts) { if (!blockId || shareMode) return; // Back records LINK jumps only — callers opt in via {pushNav: true}. @@ -5271,20 +5335,26 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { setShareSettings(data); setShareError(""); } - async function loadShareSettings() { - if (!focusedBlockId || shareMode || homeMode) return; + // The endpoints for one target differ only in how they name it + // (docs/dev/api.md "Shares"): /share/<page id> or /share/folder?name=. + const shareApi = (target, base) => (target.kind === "folder" + ? `${API}/${base}/folder?name=${encodeURIComponent(target.name)}` + : `${API}/${base}/${encodeURIComponent(target.id)}`); + async function loadShareSettings(target) { + if (shareMode || !target || (target.kind === "page" && !target.id)) return; + setShareTarget(target); setShareSettings(null); try { - applyShareSettings(await apiJson(`${API}/share-settings/${encodeURIComponent(focusedBlockId)}`)); + applyShareSettings(await apiJson(shareApi(target, "share-settings"))); resetShareCopied(); } catch (err) { setStatus(t("Share failed: {message}", { message: err.message })); } } async function createShareLink() { - if (!focusedBlockId || shareMode) return; + if (!shareTarget || shareMode) return; try { - applyShareSettings(await apiJson(`${API}/share/${encodeURIComponent(focusedBlockId)}`, { method: "POST" })); + applyShareSettings(await apiJson(shareApi(shareTarget, "share"), { method: "POST" })); resetShareCopied(); guideEvents.emit("share.created"); } catch (err) { @@ -5292,9 +5362,9 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { } } async function updateShareSettings(patch) { - if (!focusedBlockId || !shareSettings?.token) return false; + if (!shareTarget || !shareSettings?.token) return false; try { - applyShareSettings(await apiJson(`${API}/share-settings/${encodeURIComponent(focusedBlockId)}`, { + applyShareSettings(await apiJson(shareApi(shareTarget, "share-settings"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), @@ -5319,15 +5389,24 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { updateShareSettings({ users: (shareSettings?.users || []).filter((u) => u.name !== name) }); } async function stopSharing() { - if (!focusedBlockId || !shareSettings?.token) return; + if (!shareTarget || !shareSettings?.token) return; try { - await apiJson(`${API}/share-settings/${encodeURIComponent(focusedBlockId)}`, { method: "DELETE" }); - applyShareSettings({ token: null, page_id: focusedBlockId }); + await apiJson(shareApi(shareTarget, "share-settings"), { method: "DELETE" }); + applyShareSettings({ token: null, page_id: shareTarget.id || "", folder: shareTarget.name || "" }); setStatus(t("Sharing stopped — the old link no longer opens.")); } catch (err) { setStatus(t("Stop sharing failed: {message}", { message: err.message })); } } + // Share a folder: the same popover under the topbar's link button, which + // the folder view shows — so from the context menu the folder is opened first. + function openFolderShare(name) { + if (!homeMode) goHome(); + if (folderFilter !== name || categoryFilter) openFolder(name); + loadShareSettings({ kind: "folder", name }); + setShareError(""); + setOpenPopover("share"); + } // Publishing to Gamma Cloud (sharing/SharePopover.jsx PublishSection). POST // both publishes and changes an existing cloud share's audience / role; it // runs a sync round, so it can take seconds. Refusals come back as the @@ -5538,11 +5617,12 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { // path as Import → Gamma export. Block ids survive, so the imported page // opens by the id the link named. async function importSharedPage(shareUrl) { - let origin = "", token = ""; + let origin = "", token = "", linkedPage = ""; try { const u = new URL(shareUrl, window.location.href); origin = u.origin; token = u.searchParams.get("share") || ""; + linkedPage = u.searchParams.get("page") || ""; // a page opened through a folder share } catch {} if (!token) { setStatus(t("That isn't a Gamma share link (no ?share= in it).")); return; } const local = origin === window.location.origin; @@ -5561,12 +5641,14 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { } if (!r.ok) throw new Error("share link not found"); const info = await r.json(); + const pageId = info.page_id || linkedPage; + if (!pageId) throw new Error(t("that link shares a folder — open one of its pages to add it")); updateTransfer(tid, { info: t("downloading…") }); - r = await fetch(`${origin}${API}/pages/${encodeURIComponent(info.page_id)}/export?mode=gamma&share=${encodeURIComponent(token)}`, opts); + r = await fetch(`${origin}${API}/pages/${encodeURIComponent(pageId)}/export?mode=gamma&share=${encodeURIComponent(token)}`, opts); if (!r.ok) throw new Error(r.status === 404 ? "that Gamma is too old to export pages for another Gamma" : `export failed (${r.status})`); const blob = await r.blob(); updateTransfer(tid, { status: "done", info: fmtBytes(blob.size) }); - runBackupImport(new File([blob], "shared-page.zip", { type: "application/zip" }), "merge", null, { openPage: info.page_id }); + runBackupImport(new File([blob], "shared-page.zip", { type: "application/zip" }), "merge", null, { openPage: pageId }); } catch (err) { updateTransfer(tid, { status: "error", info: String(err.message) }); setStatus(t("Import failed: {message}", { message: err.message })); @@ -6909,6 +6991,90 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ); } + // The share popover (sharing/SharePopover.jsx), anchored under the topbar's + // link button — the open page's, or the open folder's; the citation + // section is App's (metadata + copy state). + const sharePopover = ( + <SharePopover + target={shareTarget} + settings={shareSettings} + error={shareError} + me={authUser?.user || ""} + meIsGuest={!!authUser?.is_guest} + shareUrl={shareUrl} + copied={!!shareCopied} + onCopy={copyShareLink} + onCreate={createShareLink} + onUpdate={updateShareSettings} + onInvite={inviteShareUser} + onSetRole={setShareUserRole} + onRemove={removeShareUser} + onStop={stopSharing} + onClose={() => { setOpenPopover(null); setShareError(""); }} + publish={publishOffered && shareTarget?.kind !== "folder" ? { + state: publishState?.page === focusedBlockId ? publishState : null, + busy: publishBusy, + error: publishError, + copied: !!publishCopied, + onCopy: copyPublishLink, + canEdit: !readOnly, + onPublish: publishPage, + onUnpublish: unpublishPage, + onSync: syncPublication, + onLink: () => { setOpenPopover(null); setSettingsOpen("account"); }, + accountUrl: serverConfig?.cloud?.issuer ? `${serverConfig.cloud.issuer}/` : "", + } : null} + citation={shareTarget?.kind !== "folder" && (pageMeta || pageBibtex) ? ( + <Section + title={t("Citation")} + action={ + <button + type="button" className="uiBtn sm iconSq" + title={t("Regenerate the citation")} aria-label={t("Regenerate the citation")} + disabled={pptCiteBusy} + onClick={() => makePptCitation(true)} + >{pptCiteBusy ? "…" : <RefreshIcon size={13} />}</button> + } + > + <div className="citeHead"> + <span className="citeLabel">{t("Slide citation")}</span> + {/* Provenance right where the citation gets copied: a + registry name, or a red "!" when nothing tied the + record to this document. */} + {metaSrc ? ( + <span className={`citeSourceTag${metaSrc.warn ? " warn" : ""}`} title={t(metaSrc.hint)}> + {metaSrc.warn ? <span className="metaWarnDot inline" aria-hidden="true">!</span> : null} + {t(metaSrc.label)} + </span> + ) : null} + </div> + {metaSrc?.warn ? <div className="settingsPaneHint citeWarnHint">{t(metaSrc.hint)}.</div> : null} + {pptCite ? ( + <CopyBox + copied={copiedKey === "ppt"} onCopy={() => copyFlash("ppt", pptCite)} + title={t("Copy — pastes with real italics/bold into PowerPoint")} label={t("Copy slide citation")} + > + <div className="pptCitePreview"><ChatMarkdown text={pptCite} /></div> + </CopyBox> + ) : ( + <div className="settingsPaneHint">{pptCiteBusy ? t("Generating…") : t("Citation will generate when metadata is ready.")}</div> + )} + {pageBibtex ? ( + <> + <div className="citeHead"><span className="citeLabel">{t("BibTeX")}</span></div> + <CopyBox + copied={copiedKey === "bibtex"} onCopy={() => copyFlash("bibtex", pageBibtex)} + title={t("Copy the BibTeX entry")} label={t("Copy BibTeX")} + > + <pre className="bibtexPre">{pageBibtex}</pre> + </CopyBox> + </> + ) : null} + </Section> + ) : null} + /> + ); + // Notion-style tail under the block tree: clicking the empty space below // the last block starts writing there — in the last block if it is still // empty, else in a fresh top-level one. On an empty page the zone carries @@ -7928,6 +8094,8 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { </button> ) : null} </> + ) : shareMode && sharedFolder && !focusedBlockId ? ( + <SharedFolder folder={sharedFolder.name} pages={sharedFolder.pages} labelMode={fileLabels} onOpen={(id) => goSharedPage(id)} /> ) : ( visibleBlocks.length === 0 ? ( notesTail || <div className="empty">{t("No blocks yet.")}</div> @@ -8417,87 +8585,6 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { // The topbar action buttons. On a phone these move to the bottom bar: // the tab row is too narrow to hold both, and thumbs reach the bottom. - // The share popover (sharing/SharePopover.jsx), anchored under the topbar's - // link button; the citation section is App's (metadata + copy state). - const sharePopover = ( - <SharePopover - settings={shareSettings} - error={shareError} - me={authUser?.user || ""} - meIsGuest={!!authUser?.is_guest} - shareUrl={shareUrl} - copied={!!shareCopied} - onCopy={copyShareLink} - onCreate={createShareLink} - onUpdate={updateShareSettings} - onInvite={inviteShareUser} - onSetRole={setShareUserRole} - onRemove={removeShareUser} - onStop={stopSharing} - onClose={() => { setOpenPopover(null); setShareError(""); }} - publish={publishOffered ? { - state: publishState?.page === focusedBlockId ? publishState : null, - busy: publishBusy, - error: publishError, - copied: !!publishCopied, - onCopy: copyPublishLink, - canEdit: !readOnly, - onPublish: publishPage, - onUnpublish: unpublishPage, - onSync: syncPublication, - onLink: () => { setOpenPopover(null); setSettingsOpen("account"); }, - accountUrl: serverConfig?.cloud?.issuer ? `${serverConfig.cloud.issuer}/` : "", - } : null} - citation={(pageMeta || pageBibtex) ? ( - <Section - title={t("Citation")} - action={ - <button - type="button" className="uiBtn sm iconSq" - title={t("Regenerate the citation")} aria-label={t("Regenerate the citation")} - disabled={pptCiteBusy} - onClick={() => makePptCitation(true)} - >{pptCiteBusy ? "…" : <RefreshIcon size={13} />}</button> - } - > - <div className="citeHead"> - <span className="citeLabel">{t("Slide citation")}</span> - {/* Provenance right where the citation gets copied: a - registry name, or a red "!" when nothing tied the - record to this document. */} - {metaSrc ? ( - <span className={`citeSourceTag${metaSrc.warn ? " warn" : ""}`} title={t(metaSrc.hint)}> - {metaSrc.warn ? <span className="metaWarnDot inline" aria-hidden="true">!</span> : null} - {t(metaSrc.label)} - </span> - ) : null} - </div> - {metaSrc?.warn ? <div className="settingsPaneHint citeWarnHint">{t(metaSrc.hint)}.</div> : null} - {pptCite ? ( - <CopyBox - copied={copiedKey === "ppt"} onCopy={() => copyFlash("ppt", pptCite)} - title={t("Copy — pastes with real italics/bold into PowerPoint")} label={t("Copy slide citation")} - > - <div className="pptCitePreview"><ChatMarkdown text={pptCite} /></div> - </CopyBox> - ) : ( - <div className="settingsPaneHint">{pptCiteBusy ? t("Generating…") : t("Citation will generate when metadata is ready.")}</div> - )} - {pageBibtex ? ( - <> - <div className="citeHead"><span className="citeLabel">{t("BibTeX")}</span></div> - <CopyBox - copied={copiedKey === "bibtex"} onCopy={() => copyFlash("bibtex", pageBibtex)} - title={t("Copy the BibTeX entry")} label={t("Copy BibTeX")} - > - <pre className="bibtexPre">{pageBibtex}</pre> - </CopyBox> - </> - ) : null} - </Section> - ) : null} - /> - ); const topbarActions = ( <> <span data-popover="add" className="popoverAnchor"> @@ -8659,7 +8746,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { className={`iconBtn ${openPopover === "share" ? "activeIcon" : ""}`} onClick={() => { const opening = openPopover !== "share"; - if (opening) { loadShareSettings(); setShareError(""); loadPublishState(); } + if (opening) { loadShareSettings({ kind: "page", id: focusedBlockId }); setShareError(""); loadPublishState(); } setOpenPopover(opening ? "share" : null); }} disabled={loading} @@ -8669,7 +8756,20 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { > <LinkIcon size={16} /> </button> - {openPopover === "share" ? sharePopover : null} + {openPopover === "share" && shareTarget?.kind === "page" ? sharePopover : null} + </span> + ) : homeMode && folderFilter && !categoryFilter ? ( + // The same button for the open folder: one link for every page filed in it. + <span data-popover="share" className="popoverAnchor"> + <button + className={`iconBtn ${openPopover === "share" ? "activeIcon" : ""}`} + onClick={() => { if (openPopover === "share") setOpenPopover(null); else openFolderShare(folderFilter); }} + title={t("Share this folder")} + aria-label={t("Share this folder")} + > + <LinkIcon size={16} /> + </button> + {openPopover === "share" && shareTarget?.kind === "folder" ? sharePopover : null} </span> ) : null} {authUser?.user && (workspace?.mirror_of || workspace?.publishing) ? ( @@ -8899,7 +8999,12 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { </> ) : ( <div className="topbar"> - <button className="iconBtn homeBtn" disabled title={t("Home")} aria-label={t("Home")}> + <button + className="iconBtn homeBtn" disabled={!sharedFolder || !focusedBlockId} + title={sharedFolder ? t("Back to the shared folder") : t("Home")} + aria-label={sharedFolder ? t("Back to the shared folder") : t("Home")} + onClick={() => goSharedPage("")} + > <HomeIcon size={17} /> </button> <span className="readOnlyTitle">{pageTitle}</span> @@ -8931,11 +9036,17 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { >{t("as {name}", { name: linkName })}</button> )) : null} {shareInfo?.owner && shareInfo.viewer === shareInfo.owner ? ( - // The owner landed on their own link: the page is theirs already. + // The owner landed on their own link: the page (or folder) is theirs already. <button className="uiBtn sm" - title={t("This is your page — open it in your library instead of the shared view")} - onClick={() => { window.location.href = `${window.location.pathname}?page=${encodeURIComponent(focusedBlockId)}`; }} + title={focusedBlockId + ? t("This is your page — open it in your library instead of the shared view") + : t("This is your folder — open it in your library instead of the shared view")} + onClick={() => { + window.location.href = focusedBlockId + ? `${window.location.pathname}?page=${encodeURIComponent(focusedBlockId)}` + : homeUrlFor(sharedFolder?.name || "", ""); + }} >{t("Open in my library")}</button> ) : shareInfo?.viewer && !shareInfo.viewerIsGuest && focusedBlockId ? ( <button @@ -9828,6 +9939,8 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { <> <MenuItem icon={FolderOpenIcon} onClick={() => { const name = homeMenu.name; setHomeMenu(null); if (!homeMode) goHome(); openFolder(name); }}>{t("Open")}</MenuItem> <MenuItem icon={PenIcon} onClick={() => { setHomeMenu(null); setFolderRenaming({ name: homeMenu.name, draft: homeMenu.name }); }}>{t("Rename")}</MenuItem> + <MenuItem icon={LinkIcon} title={t("A link that opens every page filed in this folder, now and later")} + onClick={() => { const name = homeMenu.name; setHomeMenu(null); openFolderShare(name); }}>{t("Share…")}</MenuItem> {(() => { // Like pages: acting on a selected folder acts on the whole selection const paths = selectedFolders.size > 1 && selectedFolders.has(homeMenu.name) ? [...selectedFolders] : [homeMenu.name]; diff --git a/frontend/src/library/library.css b/frontend/src/library/library.css index f52f1313..a384c491 100644 --- a/frontend/src/library/library.css +++ b/frontend/src/library/library.css @@ -529,3 +529,9 @@ .quickOpenTag { flex-shrink: 0; font-style: normal; font-size: calc(10px * var(--ui-font-scale, 1)); color: var(--text-dim); } .quickOpen .chatMentionHint { padding: 6px 6px 0; } .quickOpenKey { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +/* The share view of a folder share (sharing/SharedFolder.jsx): the folder's + name row over the home library's card grid. */ +.sharedFolder { padding: 4px 8px 16px; } +.sharedFolder .folderHint { font-weight: 400; } +.sharedFolder .pageCard { cursor: pointer; } diff --git a/frontend/src/settings/SettingsAi.jsx b/frontend/src/settings/SettingsAi.jsx index 42d6fba1..0a1f46cd 100644 --- a/frontend/src/settings/SettingsAi.jsx +++ b/frontend/src/settings/SettingsAi.jsx @@ -127,8 +127,10 @@ function ProviderRow({ provider, protocol, oauth, active = false, radio = null, // The add/edit-key form's state for a provider list App does not hold — // Settings → Server's shared entries: ProviderForm's `value` contract over // the REST collection `base` (POST adds, PUT/DELETE `${base}/<id>`, each -// answering with the list). The model picker lists live through -// /api/ai/model-catalog, which takes a saved shared entry's id from an admin. +// answering with the list; a ChatGPT sign-in goes through +// `${base}/chatgpt/start` + `complete`, the account form's paste-the-callback +// flow). The model picker lists live through /api/ai/model-catalog, which +// takes a saved shared entry's id from an admin. function useProviderEditor({ info, setInfo, base, onSaved }) { const [form, setForm] = React.useState(null); const [busy, setBusy] = React.useState(false); @@ -160,21 +162,59 @@ function useProviderEditor({ info, setInfo, base, onSaved }) { if (request === catalogRequest.current && at === targetRef.current) setCatalog({ error: friendlyApiError(err) }); } } + // A sign-in that isn't connected yet can't list models: its list comes + // from the signed-in account, so the fetch waits for Connect. + const oauthPending = !!form && isOauth(form.protocol) && !stored?.oauth_connected; // Debounced like the account's own form; a stale answer is dropped. React.useEffect(() => { setCatalog(null); - if (!form || !(form.api_key?.trim() || stored?.key_hint)) return; + const ready = form && (isOauth(form.protocol) ? stored?.oauth_connected : form.api_key?.trim() || stored?.key_hint); + if (!ready) return; const timer = setTimeout(loadModelCatalog, 500); return () => { clearTimeout(timer); catalogRequest.current++; }; - }, [target]); // eslint-disable-line react-hooks/exhaustive-deps + }, [target, stored?.oauth_connected]); // eslint-disable-line react-hooks/exhaustive-deps React.useEffect(() => { setCustomModel(""); }, [form?.id, form?.protocol]); + // "Open ChatGPT sign-in": the OAuth page in a new tab. Its redirect + // (localhost:1455) fails to load — the admin pastes that URL back into the + // form, and Connect completes the exchange server-side. + async function startChatGPTAuth() { + setError(""); + try { + const d = await apiJson(`${base}/chatgpt/start`, { method: "POST" }); + setForm((f) => (f ? { ...f, oauthState: d.state } : f)); + window.open(d.auth_url, "_blank", "noopener"); + } catch (err) { + setError(err.message); + } + } + async function submit() { if (!form) return; - if (!form.id && !form.api_key.trim()) { setError(t("An API key is required.")); return; } + const oauth = isOauth(form.protocol); + const callback = oauth ? (form.oauthCallback || "").trim() : ""; + if (oauth && !callback && !form.id) { setError(t("Sign in with ChatGPT and paste the callback URL to connect.")); return; } + if (callback && !form.oauthState) { setError(t("Hit “Open ChatGPT sign-in” first, then paste the URL it ends on.")); return; } + if (!oauth && !form.id && !form.api_key.trim()) { setError(t("An API key is required.")); return; } setBusy(true); setError(""); try { + if (callback) { + // Connect (or reconnect): the form stays open on the entry so its + // models can be picked from the account's live list. + const next = await apiJson(`${base}/chatgpt/complete`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: form.oauthState, callback, provider_id: form.id || "", + name: form.name.trim(), models: form.models.trim() }), + }); + setInfo(next); + const connected = next.providers.find((p) => form.id ? p.id === form.id + : !(info?.providers || []).some((old) => old.id === p.id)); + setForm((current) => current?.oauthState === form.oauthState && connected + ? { ...current, id: connected.id, models: connected.models || "", oauthState: "", oauthCallback: "" } : current); + onSaved?.(); + return; + } setInfo(await apiJson(`${base}${form.id ? `/${encodeURIComponent(form.id)}` : ""}`, { method: form.id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ protocol: form.protocol, name: form.name.trim(), base_url: form.base_url.trim(), @@ -198,14 +238,14 @@ function useProviderEditor({ info, setInfo, base, onSaved }) { aiKeysError: error, setAiKeysError: setError, aiModelCatalog: catalog, - formOauthPending: false, + formOauthPending: oauthPending, formModels, availModels: (catalog?.models || []).filter((m) => !formModels.includes(m)), customModel, setCustomModel, aiProtocolOf: protocolOf, isOauthProto: isOauth, - startChatGPTAuth: () => {}, + startChatGPTAuth, loadModelCatalog, addCatalogModel: (m) => m && setForm((f) => { if (!f) return f; @@ -222,7 +262,8 @@ function useProviderEditor({ info, setInfo, base, onSaved }) { // Settings → Server → Shared AI provider (admins): connections every // account on the server may use next to its own (backend -// gamma/ai_settings.py). API keys only, write-only like an account's; +// gamma/ai_settings.py). An API key, write-only like an account's, or a +// ChatGPT subscription signed in here (its e-mail shown to admins only); // guests get them only while the switch is on. The allowance rows meter // them per account per day. export function SharedAiProviderSettings({ setStatus, confirm }) { @@ -230,6 +271,7 @@ export function SharedAiProviderSettings({ setStatus, confirm }) { const [info, setInfo] = React.useState(null); const [loadError, setLoadError] = React.useState(""); const [tests, setTests] = React.useState({}); + const [usage, setUsage] = React.useState({}); React.useEffect(() => { let active = true; apiJson(base).then((v) => { if (active) setInfo(v); }).catch((err) => { if (active) setLoadError(err.message); }); @@ -246,6 +288,17 @@ export function SharedAiProviderSettings({ setStatus, confirm }) { } setTests((prev) => ({ ...prev, [p.id]: result })); } + // A shared sign-in's subscription windows (the account list's Usage). + async function queryUsage(p) { + setUsage((prev) => ({ ...prev, [p.id]: { busy: true } })); + let result; + try { + result = await apiJson(`${API}/ai/providers/${encodeURIComponent(p.id)}/usage`, { method: "POST" }); + } catch (err) { + result = { available: false, reason: err.message }; + } + setUsage((prev) => ({ ...prev, [p.id]: result })); + } const run = async (call) => { try { setInfo(await call()); } catch (err) { setLoadError(err.message); } }; @@ -283,12 +336,19 @@ export function SharedAiProviderSettings({ setStatus, confirm }) { {info && !providers.length ? <Empty icon={KeyIcon}>{t("No shared connection. Each account uses its own keys.")}</Empty> : null} {providers.map((provider) => { const el = tests[provider.id]; + const oauth = editor.isOauthProto(provider.protocol); return ( - <ProviderRow key={provider.id} provider={{ ...provider, shared: false }} - protocol={editor.aiProtocolOf(provider.protocol)} test={el} onFix={() => editor.startEdit(provider)}> + <ProviderRow key={provider.id} provider={{ ...provider, shared: false }} oauth={oauth} + protocol={editor.aiProtocolOf(provider.protocol)} test={el} usage={usage[provider.id]} + onFix={() => editor.startEdit(provider)}> <button className="uiBtn sm" disabled={el?.busy} - title={t("Send a tiny AI request through this key to check it still works; the tokens count on your account")} + title={t("Send a tiny AI request through this connection to check it still works; the tokens count on your account")} onClick={() => test(provider)}>{t("Test")}</button> + {oauth ? ( + <button className="uiBtn sm" disabled={usage[provider.id]?.busy} + title={t("What is left of the ChatGPT subscription's usage windows")} + onClick={() => queryUsage(provider)}>{t("Usage")}</button> + ) : null} <button className="uiBtn sm" title={t("Edit connection and available models")} onClick={() => editor.startEdit(provider)}>{t("Manage")}</button> <button className="uiBtn sm iconSq danger" title={t("Remove this shared key")} aria-label={t("Remove shared key")} @@ -301,7 +361,7 @@ export function SharedAiProviderSettings({ setStatus, confirm }) { {info ? ( <Toggle icon={UserIcon} label={t("Guests may use it")} checked={!!info.guests} onChange={setGuests} hint={t("Off keeps guests without AI")} - title={t("Guest accounts are open to anyone who can reach this server; with this on, they spend the shared keys too.")} /> + title={t("Guest accounts are open to anyone who can reach this server; with this on, they spend the shared connections too, a shared ChatGPT subscription included.")} /> ) : null} {info && providers.length ? <> <Row icon={ActivityIcon} label={t("Allowance per account")} hint={t("Tokens a day on the shared keys; 0 = unlimited")} diff --git a/frontend/src/shared/i18n/locales/zh.json b/frontend/src/shared/i18n/locales/zh.json index 4fa6903d..da4500df 100644 --- a/frontend/src/shared/i18n/locales/zh.json +++ b/frontend/src/shared/i18n/locales/zh.json @@ -59,7 +59,9 @@ "A complete copy{folder}: pages, highlights, notes, metadata, AI chats and files. Ready to import into another Gamma library.": "完整副本{folder}:页面、高亮、笔记、元数据、AI 聊天和文件。可直接导入另一个 Gamma 文库。", "A connection worth coming back to.": "一个值得回头再看的联系。", "A graph with native PDF highlights": "带原生 PDF 高亮的图谱", + "A link lets people open every page filed in this folder, including pages you file here later — read-only or editable, for anyone or only for accounts you name.": "一个链接即可让他人打开归入此文件夹的所有页面,包括你之后归入的页面——只读或可编辑,对所有人开放或仅限你指定的账户。", "A link lets people open this page — read-only or editable, for anyone or only for accounts you name.": "链接让他人打开此页面——只读或可编辑,对任何人或仅对你指定的账户开放。", + "A link that opens every page filed in this folder, now and later": "一个链接即可打开归入此文件夹的所有页面,包括之后归入的", "A new PDF of {page} — title, {quotes}{typeset}.": "{page}的新 PDF——标题、{quotes}{typeset}。", "A new PDF with the title and metadata only — both switches are off.": "只含标题和元数据的新 PDF——两个开关都已关闭。", "A new workspace": "新工作区", @@ -249,6 +251,7 @@ "Automatic sync": "自动同步", "Available models": "可用模型", "Back": "返回", + "Back to the shared folder": "返回分享的文件夹", "Back to where you were": "回到之前的位置", "Back to where you were{steps} — Alt+← · right-click to clear": "回到之前的位置{steps}——Alt+← · 右键清除", "Back to workspaces": "返回工作区", @@ -305,7 +308,9 @@ "Callback URL": "回调 URL", "Callout": "标注框", "Can edit": "可编辑", + "Can edit the notes and highlights of every page in this folder, now and later — never other pages or any page's settings": "可以编辑此文件夹中所有页面(包括之后归入的)的笔记和高亮——但不能编辑其他页面或任何页面的设置", "Can edit this page's notes and highlights — never other pages or the page's settings": "可以编辑此页面的笔记和高亮——但不能编辑其他页面或此页面的设置", + "Can read every page in the folder": "可以阅读文件夹中的所有页面", "Can read the page": "可以阅读此页面", "Can view": "可查看", "Can't attach \"{name}\" — only images and PDFs are supported.": "无法附加“{name}”——仅支持图片和 PDF。", @@ -852,7 +857,7 @@ "Gray": "灰色", "Grid view": "网格视图", "Guest": "访客", - "Guest accounts are open to anyone who can reach this server; with this on, they spend the shared keys too.": "任何能访问此服务器的人都可以使用访客账户;开启后,访客也会消耗共享密钥。", + "Guest accounts are open to anyone who can reach this server; with this on, they spend the shared connections too, a shared ChatGPT subscription included.": "任何能访问此服务器的人都可以使用访客账户;开启后,访客也会消耗共享连接,包括共享的 ChatGPT 订阅。", "Guest accounts cannot store API keys. Ask the admin for an account.": "访客账户不能存储 API 密钥。请向管理员申请账户。", "Guest login failed": "访客登录失败", "Guest workspaces last": "访客工作区保留", @@ -1065,6 +1070,7 @@ "List pages": "列出页面", "List view": "列表视图", "Live": "实时", + "Loaded shared folder.": "已加载分享的文件夹。", "Loaded shared page.": "已加载分享的页面。", "Loading Gamma…": "正在加载 Gamma…", "Loading accounts…": "正在加载账户…", @@ -1253,6 +1259,7 @@ "No new items were added.": "没有新增条目。", "No other accounts": "没有其他账户", "No other pages in your library yet.": "你的文库中还没有其他页面。", + "No pages are filed in this folder yet.": "此文件夹中还没有页面。", "No pages carry {which}.": "没有页面带有{which}。", "No pages match “{docPickerQuery}”.": "没有页面匹配“{docPickerQuery}”。", "No pages yet — create one first.": "还没有页面——请先创建一个。", @@ -1772,8 +1779,8 @@ "Selecting text in a PDF shows the highlight colors; this adds a translate button there. The translation opens under the colors, in the language above, with the same model or service as the page translation.": "在 PDF 中选中文字会显示高亮颜色;此项在那里加一个翻译按钮。译文显示在颜色下方,使用上面的语言,以及与页面翻译相同的模型或服务。", "Selection": "选区", "Send": "发送", + "Send a tiny AI request through this connection to check it still works; the tokens count on your account": "通过此连接发送一个极小的 AI 请求,检查它是否仍然可用;token 计入你的账户", "Send a tiny AI request through this credential to check it still works": "通过此凭据发送一个极小的 AI 请求,检查它是否仍然可用", - "Send a tiny AI request through this key to check it still works; the tokens count on your account": "通过此密钥发送一个极小的 AI 请求,检查它是否仍然可用;token 计入你的账户", "Send local edits and bring back edits made through the cloud links": "发送本地编辑,并取回通过云端链接所做的编辑", "Send the full PDF file with your messages so the model sees figures & tables (uses more tokens). Click to enable.": "随消息发送完整的 PDF 文件,让模型看到图和表(消耗更多 token)。点击启用。", "Sepia": "褐色", @@ -1805,6 +1812,7 @@ "Share failed: {message}": "分享失败:{message}", "Share link": "分享链接", "Share open failed: {message}": "打开分享失败:{message}", + "Share this folder": "分享此文件夹", "Share this page": "分享此页面", "Share this page…": "分享此页面…", "Shared": "共享", @@ -1817,6 +1825,7 @@ "Shared evenly by every selected paper": "由每篇所选论文平均分配", "Shared workspace": "共享工作区", "Shared workspaces": "共享工作区", + "Share…": "分享…", "Sharing": "分享", "Sharing a page": "分享页面", "Sharing stopped — the old link no longer opens.": "已停止分享——旧链接不再可用。", @@ -2030,6 +2039,7 @@ "This folder is empty — start a page here or drag pages onto it from the library.": "此文件夹为空——在此新建页面,或从文库拖入页面。", "This handwriting note is full. Start a new note before duplicating.": "此手写笔记已满。请先新建笔记再复制。", "This highlight has a note": "此高亮带有笔记", + "This is your folder — open it in your library instead of the shared view": "这是你的文件夹——请在你的文库中打开,而不是分享视图", "This is your page — open it in your library instead of the shared view": "这是你的页面——请在你的文库中打开,而不是分享视图", "This link doesn't work": "此链接无效", "This page is shared with specific people only{them}. Ask the owner to add your username.": "此页面仅分享给特定的人{them}。请让所有者添加你的用户名。", @@ -2200,6 +2210,7 @@ "What a cloud account that is not linked to an account here may do": "未关联本地账户的云端账户可以做什么", "What each side changed, and what the merge kept": "双方各自改了什么,合并保留了什么", "What happened": "发生了什么", + "What is left of the ChatGPT subscription's usage windows": "ChatGPT 订阅各用量窗口的剩余额度", "What to back up": "备份内容", "What translates page text. A chat model keeps formulas and citations intact and follows the paper's register; a translation service (Microsoft for free, or Google and Youdao with a key) is faster and cheaper per page and needs no AI connection. Translation is a bulk job — a fast, cheap model usually reads fine.": "用什么翻译页面文字。聊天模型会保留公式和引用,并贴合论文的语体;翻译服务(免费的 Microsoft,或需要密钥的 Google 和有道)每页更快、更便宜,而且不需要 AI 连接。翻译是批量任务——快速、便宜的模型通常就够用。", "When a publisher PDF is paywalled or refuses to download, load a legal open-access copy instead — usually the arXiv version. A note tells you when the substitute isn't the published version.": "当出版商的 PDF 需要付费或拒绝下载时,改为加载合法的开放获取副本——通常是 arXiv 版本。替代版本与发表版本不同时会有提示。", @@ -2525,6 +2536,7 @@ "text layer": "文本层", "that link is shared with specific people only": "该链接仅分享给特定的人", "that link isn't open to anyone — only public share links can be imported from another Gamma": "该链接不对任何人开放——只有公开的分享链接才能从另一个 Gamma 导入", + "that link shares a folder — open one of its pages to add it": "该链接分享的是一个文件夹——请打开其中的某个页面再添加", "the PDF": "PDF", "the diagnostics were trimmed to fit — paste the copied report if anything is missing": "诊断信息已截断——如有缺失请粘贴已复制的报告", "the full address the sign-in ended on": "登录结束时的完整地址", @@ -2588,6 +2600,7 @@ "{chosenCount} selected items · Please keep this dialog open.": "{chosenCount} 个所选条目 · 请保持此对话框打开。", "{chosen} of {total} items selected · {shown} shown": "已选 {chosen}/{total} 个条目 · 显示 {shown} 个", "{connected} · ChatGPT subscription": "{connected} · ChatGPT 订阅", + "{content}\nClick to open": "{content}\n单击打开", "{content}\nClick to select · double-click to open": "{content}\n单击选择 · 双击打开", "{count} {word}{_s}": "{count} {word}", "{ctxText} — the last reply's prompt and answer in {model}'s context window": "{ctxText} — 上一条回复的提示与回答占 {model} 上下文窗口的比例", @@ -2646,6 +2659,7 @@ "{used}% used · {left}% left{reset}": "已用 {used}% · 剩余 {left}%{reset}", "{username} can now {verb} {name}.": "{username} 现在可以{verb} {name}。", "{where}. {title}": "{where}。{title}", + "{who} can {verb} every page in this folder{access}.": "{who}可以{verb}此文件夹中的所有页面{access}。", "{who} can {verb} this page{access}.": "{who}可以{verb}此页面{access}。", "{workspace_bytes} here · counts against {s} storage": "此处 {workspace_bytes} · 计入{s}存储", "· origin": "· 源", diff --git a/frontend/src/shared/styles/app.css b/frontend/src/shared/styles/app.css index d84cfa52..7f3ff977 100644 --- a/frontend/src/shared/styles/app.css +++ b/frontend/src/shared/styles/app.css @@ -5497,3 +5497,4 @@ html.appFocusFullscreen, html.appFocusFullscreen body { overflow: hidden; oversc border-bottom-width: 2px; border-radius: 4px; } +.sharePopover .sharePopoverTarget { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/frontend/src/sharing/SharePopover.jsx b/frontend/src/sharing/SharePopover.jsx index 11ee6ab5..de4d8ce6 100644 --- a/frontend/src/sharing/SharePopover.jsx +++ b/frontend/src/sharing/SharePopover.jsx @@ -1,10 +1,13 @@ -// Share this page — the popover under the page header's link button, like -// the account menu: it hangs off its button (App wraps it in a -// `data-popover="share"` anchor, so the topbar's outside-click / Escape -// rules close it) and is built from the settings kit like the workspace -// Manage dialog: Link (Copy link, Stop sharing), Access, People, Citation. -// State is the server's share settings (docs/dev/api.md "Shares"): every -// change saves at once; the link itself only changes on Stop. +// Share this page — or this folder — the popover under the page header's +// link button (the folder view's, for a folder), like the account menu: it +// hangs off its button (App wraps it in a `data-popover="share"` anchor, so +// the topbar's outside-click / Escape rules close it) and is built from the +// settings kit like the workspace Manage dialog: Link (Copy link, Stop +// sharing), Access, People, Citation. State is the server's share settings +// (docs/dev/api.md "Shares"): every change saves at once; the link itself +// only changes on Stop. `target` says what is shared — {kind: "page"} or +// {kind: "folder", name} — and only the words differ: a folder share reaches +// every page filed in the folder, now and later, so its edit wording says so. // // Access is pictured, not described: three tiles say who may open the link // (Anyone / Signed in / Invited only, the same glyphs the read-only view's @@ -31,15 +34,21 @@ import { useAccounts } from "../settings/SettingsWorkspace"; import { mirrorState } from "../collaboration/MirrorPopover"; import { T, t } from "../shared/i18n/i18n.js"; import { - AlertCircleIcon, CheckIcon, CloudIcon, CloudOffIcon, CloudUploadIcon, CopyIcon, ExternalLinkIcon, EyeIcon, GlobeIcon, LinkIcon, - PenIcon, PlusIcon, RefreshIcon, ShieldIcon, Trash2Icon, UserIcon, UsersIcon, + AlertCircleIcon, CheckIcon, CloudIcon, CloudOffIcon, CloudUploadIcon, CopyIcon, ExternalLinkIcon, EyeIcon, FolderIcon, GlobeIcon, + LinkIcon, PenIcon, PlusIcon, RefreshIcon, ShieldIcon, Trash2Icon, UserIcon, UsersIcon, } from "../shared/ui/Icons"; const SHARE_ROLE_OPTIONS = [["view", t("Can view")], ["edit", t("Can edit")]]; -const ROLE_SEGMENTS = [ - ["view", t("View"), EyeIcon, t("Can read the page")], - ["edit", t("Edit"), PenIcon, t("Can edit this page's notes and highlights — never other pages or the page's settings")], -]; +const ROLE_SEGMENTS = { + page: [ + ["view", t("View"), EyeIcon, t("Can read the page")], + ["edit", t("Edit"), PenIcon, t("Can edit this page's notes and highlights — never other pages or the page's settings")], + ], + folder: [ + ["view", t("View"), EyeIcon, t("Can read every page in the folder")], + ["edit", t("Edit"), PenIcon, t("Can edit the notes and highlights of every page in this folder, now and later — never other pages or any page's settings")], + ], +}; const AUDIENCE_TILES = [ { value: "anyone", label: T("Anyone"), hint: T("with the link"), Icon: GlobeIcon }, @@ -59,11 +68,14 @@ const CLOUD_AUDIENCE_TILES = [ export const PUBLISH_SIGN_IN = T("Sign in with Gamma Cloud to publish."); // The one sentence that says what the tiles + toggle add up to. -function accessSummary(settings, invited) { +function accessSummary(settings, invited, kind = "page") { const who = settings.audience === "anyone" ? t("Anyone with the link") : settings.audience === "users" ? t("Anyone signed in") : null; if (!who) return invited ? t("Only the people below can open it.") : t("Nobody can open it until you invite someone."); const verb = settings.role === "edit" ? t("edit") : t("read"); - return t("{who} can {verb} this page{access}.", { who, verb, access: invited ? t("; invited people keep their own access") : "" }); + const access = invited ? t("; invited people keep their own access") : ""; + return kind === "folder" + ? t("{who} can {verb} every page in this folder{access}.", { who, verb, access }) + : t("{who} can {verb} this page{access}.", { who, verb, access }); } // Invite, inline under the people list (a popover can't host a modal): an @@ -187,7 +199,7 @@ function PublishSection({ state, busy, error, copied, onCopy, canEdit, onPublish title={t("Gamma Cloud")} action={share && share.audience !== "list" && canEdit ? ( <Segmented - value={share.role} options={ROLE_SEGMENTS} disabled={!!busy} + value={share.role} options={ROLE_SEGMENTS.page} disabled={!!busy} onChange={(role) => { if (role !== share.role) onPublish({ role }); }} /> ) : null} @@ -265,20 +277,24 @@ function PublishSection({ state, busy, error, copied, onCopy, canEdit, onPublish ); } -// Props: settings (null while loading; {token: null} when unshared), error -// (the last failed save, e.g. an unknown username), me / meIsGuest (the -// owner's account), shareUrl, copied / onCopy, and one callback per action. -// `citation` is the page's citation section (App owns it), shown when the -// page has metadata. `publish` is the Gamma Cloud section's props -// (PublishSection), or null where the server offers no publishing. +// Props: target ({kind: "page"} or {kind: "folder", name}; a page when +// omitted), settings (null while loading; {token: null} when unshared), +// error (the last failed save, e.g. an unknown username), me / meIsGuest +// (the owner's account), shareUrl, copied / onCopy, and one callback per +// action. `citation` is the page's citation section (App owns it), shown +// when the page has metadata. `publish` is the Gamma Cloud section's props +// (PublishSection), or null where the server offers no publishing — App +// passes neither for a folder. export function SharePopover({ - settings, error, me, meIsGuest, shareUrl, copied, onCopy, + target, settings, error, me, meIsGuest, shareUrl, copied, onCopy, onCreate, onUpdate, onInvite, onSetRole, onRemove, onStop, onClose, citation, publish, }) { const [inviting, setInviting] = React.useState(false); const users = settings?.users || []; const shared = !!settings?.token; const openEdit = shared && settings.audience === "anyone" && settings.role === "edit"; + const kind = target?.kind === "folder" ? "folder" : "page"; + const title = kind === "folder" ? t("Share this folder") : t("Share this page"); async function invite(name, role) { const ok = await onInvite(name, role); @@ -286,9 +302,10 @@ export function SharePopover({ } return ( - <div className="popover sharePopover" role="dialog" aria-label={t("Share this page")}> + <div className="popover sharePopover" role="dialog" aria-label={title}> <div className="sharePopoverHead"> - <span className="popoverTitle">{t("Share this page")}</span> + <span className="popoverTitle">{title}</span> + {kind === "folder" ? <span className="uiTag sharePopoverTarget" title={target.name}><FolderIcon size={11} />{target.name}</span> : null} <button type="button" className="uiClose" onClick={onClose} aria-label={t("Close")} title={t("Close")}>×</button> </div> <div className="settingsForm"> @@ -296,7 +313,9 @@ export function SharePopover({ {settings && !shared ? ( <Section title={t("Link")} guide="share.link"> <Row icon={LinkIcon} label={t("Share link")} hint={t("not shared yet")} - title={t("A link lets people open this page — read-only or editable, for anyone or only for accounts you name.")}> + title={kind === "folder" + ? t("A link lets people open every page filed in this folder, including pages you file here later — read-only or editable, for anyone or only for accounts you name.") + : t("A link lets people open this page — read-only or editable, for anyone or only for accounts you name.")}> <button type="button" className="uiBtn sm primary" onClick={onCreate}> <LinkIcon size={13} />{t("Create link")} </button> @@ -325,7 +344,7 @@ export function SharePopover({ guide="share.access" action={settings.audience !== "list" ? ( <Segmented - value={settings.role} options={ROLE_SEGMENTS} + value={settings.role} options={ROLE_SEGMENTS[kind]} onChange={(role) => { if (role !== settings.role) onUpdate({ role }); }} /> ) : null} @@ -341,7 +360,7 @@ export function SharePopover({ <div className={`settingsPaneHint shareSummary ${openEdit ? "shareWarn" : ""}`}> {openEdit ? <AlertCircleIcon size={13} /> : null} <span> - {accessSummary(settings, users.length > 0)} + {accessSummary(settings, users.length > 0, kind)} {openEdit ? t(" No sign-in needed; edits are recorded under a name they choose.") : ""} </span> </div> diff --git a/frontend/src/sharing/SharedFolder.jsx b/frontend/src/sharing/SharedFolder.jsx new file mode 100644 index 00000000..26a8c7a0 --- /dev/null +++ b/frontend/src/sharing/SharedFolder.jsx @@ -0,0 +1,50 @@ +// The share view of a folder share (/?share=<token> naming a folder): the +// pages the link reaches, as the home library's cards, newest edit first — +// what GET /api/share/{token} listed (`pages`). A card opens its page in +// the same share view (App puts `page=<id>` beside the token in the URL and +// loads the page through the token, like a page share); the topbar's home +// button comes back here. Presentational: App owns the data and navigation. +import React from "react"; +import { PageCard } from "../library/FileBrowser"; +import { formatRelativeTime, pageKindLabel } from "../library/libraryUtils"; +import { FileGlyph, FolderOpenIcon } from "../shared/ui/Icons"; +import { t } from "../shared/i18n/i18n.js"; + +export function SharedFolder({ folder, pages, labelMode, onOpen }) { + return ( + <div className="sharedFolder" data-shared-folder={folder}> + <div className="folderBrowser"> + <div className="folderCurrent"> + <FolderOpenIcon size={15} /> + <span className="sharedFolderName">{folder}</span> + <span className="folderHint">{t("{n} page{_s}", { n: pages.length, _s: pages.length === 1 ? "" : "s" })}</span> + </div> + </div> + {pages.length === 0 ? ( + <div className="empty">{t("No pages are filed in this folder yet.")}</div> + ) : ( + <div className="fileGrid"> + {pages.map((p) => { + const attachment = p.doc_id ? { id: p.doc_id } : null; + return ( + <PageCard + key={p.id} + glyph={<FileGlyph isPdf={!!attachment} />} + title={p.title} + tip={t("{content}\nClick to open", { content: p.title })} + kind={pageKindLabel(attachment)} + time={formatRelativeTime(p.updated_at)} + folders={p.folders} labels={p.labels} labelMode={labelMode} + role="link" + tabIndex={0} + onClick={() => onOpen(p.id)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(p.id); } }} + /> + ); + })} + </div> + )} + </div> + ); +} + diff --git a/frontend/tests/e2e/scenarios/settings.mjs b/frontend/tests/e2e/scenarios/settings.mjs index 13242f6d..c927d761 100644 --- a/frontend/tests/e2e/scenarios/settings.mjs +++ b/frontend/tests/e2e/scenarios/settings.mjs @@ -944,9 +944,9 @@ export async function settingsScenarios(env) { await nav(page, "Server").click(); await row(page, "Shared AI provider").getByRole("button", { name: "+ Add provider", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Add shared key", exact: true }); - // API keys only: the ChatGPT sign-in is not offered for a shared entry. + // A key or a ChatGPT sign-in (the next step connects one). await dialog.getByRole("button", { name: "AI service", exact: true }).click(); - assertEq(await page.getByText("ChatGPT subscription", { exact: true }).count(), 0); + await page.locator(".uiSelectMenu").getByRole("button", { name: "ChatGPT subscription", exact: true }).waitFor(); await page.locator(".uiSelectMenu").getByRole("button", { name: "OpenAI API", exact: true }).click(); await dialog.locator('input[autocomplete="new-password"]').fill("sk-shared-e2e-key-7777"); await dialog.getByRole("button", { name: "2 usable" }).waitFor(); @@ -995,6 +995,48 @@ export async function settingsScenarios(env) { } }); + await step("settings: a ChatGPT subscription can be the server's shared connection", async () => { + // The admin signs in from Settings → Server; the code exchange with + // OpenAI is the one thing faked (the backend's side is + // tests/test_shared_chatgpt.py). + const { ctx, page } = await setup(); + try { + await page.evaluate(() => { + window.open = (url) => { window.testSignInUrl = url; return null; }; + }); + let completed = null; + await page.route("**/api/admin/ai-providers/chatgpt/complete", async (route) => { + completed = route.request().postDataJSON(); + const info = await user.api("/api/admin/ai-providers"); + info.providers.push({ id: "server:oauth-e2e", protocol: "chatgpt", name: "Lab ChatGPT", label: "Lab ChatGPT", + models: "gpt-lab", oauth_connected: true, account: "lab@example.com", shared: true }); + await route.fulfill({ json: info }); + }); + await page.route("**/api/ai/model-catalog", (route) => route.fulfill({ json: { models: ["gpt-lab", "gpt-lab-mini"] } })); + await openSettings(page); + await nav(page, "Server").click(); + await row(page, "Shared AI provider").getByRole("button", { name: "+ Add provider", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Add shared key", exact: true }); + await dialog.getByRole("button", { name: "AI service", exact: true }).click(); + await page.locator(".uiSelectMenu").getByRole("button", { name: "ChatGPT subscription", exact: true }).click(); + await dialog.getByRole("button", { name: "Open ChatGPT sign-in", exact: true }).click(); + await until(() => page.evaluate(() => !!window.testSignInUrl), { what: "the sign-in page opened" }); + const state = await page.evaluate(() => new URL(window.testSignInUrl).searchParams.get("state")); + await dialog.getByRole("textbox", { name: /Callback URL/ }).fill(`http://localhost:1455/auth/callback?code=test&state=${state}`); + await dialog.getByRole("button", { name: "Connect", exact: true }).click(); + // Connected: the form stays open on the entry with the account's live list. + const edit = page.getByRole("dialog", { name: "Edit shared key", exact: true }); + await edit.getByRole("button", { name: "2 usable" }).waitFor(); + assertEq(completed?.state, state, "the shared flow's own state is redeemed"); + assertEq(completed?.provider_id, "", "a new entry, not a reconnect"); + await edit.getByRole("button", { name: "Cancel", exact: true }).click(); + const shared = page.locator(".settingsPane .aiProvRow").filter({ hasText: "signed in as lab@example.com" }); + await shared.waitFor(); + await shared.getByRole("button", { name: "Usage", exact: true }).waitFor(); + assertNoProblems(page); + } finally { await ctx.close(); } + }); + // The red dot (app/notices.js): the feed is faked so no real error or // release is needed; the acks go to the real server. await step("settings: a notice dots the account button and Settings… lands on its pane", async () => { diff --git a/frontend/tests/e2e/scenarios/share.mjs b/frontend/tests/e2e/scenarios/share.mjs index a66fab8f..26989a1e 100644 --- a/frontend/tests/e2e/scenarios/share.mjs +++ b/frontend/tests/e2e/scenarios/share.mjs @@ -1,6 +1,7 @@ // Share links: creating one from the dialog, the anonymous share view (title, // PDF, highlight overlay, an image served through the share token, no -// editing), and an edit share letting another account type into the page. +// editing), an edit share letting another account type into the page, and a +// folder share (the folder view's link button; the listing a visitor browses). import { tree, same, editRow, closeEditor, PNG_1PX } from "./notes.mjs"; import { waitForPdf } from "./pdf.mjs"; @@ -46,6 +47,59 @@ export async function shareScenarios({ server, browser, alice, bob, step, until, assertEq(JSON.stringify((await alice.api(`/api/chats/${shared.id}`)).messages), JSON.stringify(saved.messages), "owner's conversation is unchanged"); }); + await step("folder share: the folder view's link button shares every page filed there; visitors browse the listing", async () => { + const paperA = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Folder share paper A" } }); + await alice.api(`/api/blocks/${paperA.id}`, { method: "PUT", body: { properties: { folder: "sharedlab/sub" } } }); + const paperB = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Folder share paper B" } }); + await alice.api(`/api/blocks/${paperB.id}`, { method: "PUT", body: { properties: { folder: "sharedlab" } } }); + await alice.api("/api/blocks", { method: "POST", body: { parent_id: paperA.id, content: "a note inside the shared folder" } }); + const outside = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Not in the shared folder" } }); + + // the owner: open the folder, share it from the browse bar's link button + const ctx = await alice.context(browser); + const page = await openPage(ctx, `${server.base}/?folder=sharedlab&ws=${alice.ws}`); + await page.click("button[aria-label='Share this folder']"); + await page.waitForSelector(".sharePopover"); + assert((await page.textContent(".sharePopover")).includes("Share this folder"), "the popover is about the folder"); + await page.locator(".sharePopover button", { hasText: "Create link" }).click(); + const copyBtn = page.locator(".sharePopover button", { hasText: /Copy link|Copied/ }).first(); + await copyBtn.waitFor({ timeout: 10000 }); + const folderToken = new URL(await copyBtn.getAttribute("title")).searchParams.get("share"); + assert(folderToken, "folder share token"); + await until(async () => (await page.textContent(".sharePopover")).includes("every page in this folder"), { what: "folder wording" }); + await page.keyboard.press("Escape"); + await page.locator(".sharePopover").waitFor({ state: "detached" }); + assertNoProblems(page); + await ctx.close(); + assertEq((await alice.api("/api/share-settings/folder?name=sharedlab")).token, folderToken, "the folder's share"); + + // an anonymous visitor: the listing, a page, and back — each a history entry + const vctx = await browser.newContext({ viewport: { width: 1280, height: 860 } }); + const v = await openPage(vctx, `${server.base}/?share=${folderToken}`); + await v.waitForSelector(".sharedFolder .pageCard", { timeout: 15000 }); + assertEq(await v.locator(".sharedFolder .pageCard").count(), 2, "both pages listed"); + assert((await v.textContent(".readOnlyTitle")).includes("sharedlab"), "the folder name in the topbar"); + await v.locator(".sharedFolder .pageCard", { hasText: "Folder share paper A" }).click(); + await v.locator(".blockRow", { hasText: "a note inside the shared folder" }).waitFor({ timeout: 15000 }); + assert(v.url().includes(`page=${paperA.id}`), "the open page rides in the URL"); + await v.click("button[aria-label='Back to the shared folder']"); + await v.waitForSelector(".sharedFolder .pageCard"); + await v.goBack(); + await v.locator(".blockRow", { hasText: "a note inside the shared folder" }).waitFor({ timeout: 15000 }); + assertNoProblems(v); + await vctx.close(); + + // a deep link into the folder opens the page; the token never reaches other pages + const dctx = await browser.newContext({ viewport: { width: 1280, height: 860 } }); + const d = await openPage(dctx, `${server.base}/?share=${folderToken}&page=${paperB.id}`); + await until(async () => (await d.textContent(".readOnlyTitle")).includes("Folder share paper B"), { what: "deep-linked page" }); + assertNoProblems(d); + await dctx.close(); + const refused = await fetch(`${server.base}/api/blocks/${outside.id}?share=${folderToken}`); + assertEq(refused.status, 403, "a page outside the folder is refused"); + await alice.api("/api/share-settings/folder?name=sharedlab", { method: "DELETE" }); + }); + const account = alice2; let token; if (!pdfPageId) { console.log(" skip share: needs the pdf steps (drop --only)"); return; } diff --git a/sites/README.md b/sites/README.md index 40b6e2e8..5ca11f66 100644 --- a/sites/README.md +++ b/sites/README.md @@ -41,7 +41,10 @@ line with the app's privacy policy. The header's **Log in** link and the `/login`, `/account` and `/signup` short links go to the Gamma Cloud account server at `account.gammapdf.com` ([docs/dev/cloud_accounts.md](../docs/dev/cloud_accounts.md)); the site -itself has no accounts. +itself has no accounts. The hero's **Try the demo** button, the header's +**Demo** link and the `/demo` short link go to the public demo at +`demo.gammapdf.com`, a Gamma in demo mode where every visitor gets a +throwaway guest workspace ([docs/dev/guests.md](../docs/dev/guests.md)). `_redirects` gives the short links (`/download`, `/download/windows`, `/docs`, `/github`, …); its sources must be relative paths, so the `www.` to diff --git a/sites/site/_redirects b/sites/site/_redirects index 89478ed6..e32b0409 100644 --- a/sites/site/_redirects +++ b/sites/site/_redirects @@ -10,6 +10,7 @@ /releases https://github.com/tim4431/Gamma/releases 302 /docker https://github.com/tim4431/Gamma/pkgs/container/gamma 302 /store https://apps.microsoft.com/detail/9N8WGWR2J2MV 302 +/demo https://demo.gammapdf.com 302 # Gamma Cloud accounts (cloud/, docs/dev/cloud_accounts.md) /login https://account.gammapdf.com/login 302 /account https://account.gammapdf.com/ 302 diff --git a/sites/site/index.html b/sites/site/index.html index ca7c3459..5b560222 100644 --- a/sites/site/index.html +++ b/sites/site/index.html @@ -50,6 +50,7 @@ <h1>Read papers.<br>Keep what you learn.</h1> <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3v10m0 0 4-4m-4 4-4-4M3 15v2h14v-2"/></svg> <span data-download-label>Download</span> </a> + <a class="btn btn--ghost btn--lg" href="https://demo.gammapdf.com">Try the demo</a> <a class="btn btn--ghost btn--lg" href="#selfhost">Self-host with Docker</a> </div> <p class="hero__meta"> diff --git a/sites/templates/header.html b/sites/templates/header.html index 97672bc9..6ec207f6 100644 --- a/sites/templates/header.html +++ b/sites/templates/header.html @@ -12,6 +12,7 @@ <a href="/#features">Features</a> <a href="/#download">Download</a> <a href="/#selfhost">Self-host</a> + <a href="https://demo.gammapdf.com">Demo</a> <a href="https://github.com/tim4431/Gamma/blob/main/docs/user_guide.md">Docs</a> <a class="nav__github" href="https://github.com/tim4431/Gamma"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg> From 74f76573a93db470228462c930bcece323bc5ff0 Mon Sep 17 00:00:00 2001 From: Tim <zsa2056197@hotmail.com> Date: Fri, 25 Sep 2026 21:13:31 -0700 Subject: [PATCH 3/3] demo container separate --- .claude/skills/update-account-server/SKILL.md | 15 +- .claude/skills/update-demo-server/SKILL.md | 260 ++++++++---------- CLAUDE.md | 2 +- cloud/deploy/Caddyfile | 6 +- cloud/deploy/README.md | 86 ++---- cloud/deploy/compose.yml | 33 +-- cloud/deploy/demo/.env.example | 5 + cloud/deploy/demo/README.md | 91 ++++++ cloud/deploy/demo/compose.yml | 31 +++ cloud/deploy/{ => demo}/demo.env.example | 11 +- docs/dev/github_actions.md | 9 +- docs/dev/guests.md | 12 +- 12 files changed, 302 insertions(+), 259 deletions(-) create mode 100644 cloud/deploy/demo/.env.example create mode 100644 cloud/deploy/demo/README.md create mode 100644 cloud/deploy/demo/compose.yml rename cloud/deploy/{ => demo}/demo.env.example (74%) diff --git a/.claude/skills/update-account-server/SKILL.md b/.claude/skills/update-account-server/SKILL.md index 2db5aa6c..cccf13e2 100644 --- a/.claude/skills/update-account-server/SKILL.md +++ b/.claude/skills/update-account-server/SKILL.md @@ -56,12 +56,15 @@ ssh root@69.63.206.178 "cat /root/Container/gamma-account/Caddyfile" | diff - Show the user any difference and copy a file over (`git show <headSha>:cloud/deploy/<file> | ssh root@69.63.206.178 "cat > /root/Container/gamma-account/<file>"`) only once they agree. `.env` is never copied — new variables from `.env.example` (`git diff <old>..<headSha> -- cloud/deploy/.env.example`) are -named to the user to add by hand. The same `compose.yml` also pins the -`demo` service's image tag (demo.gammapdf.com, the `update-demo-server` -skill): a diff on that one line is the demo's own pin, not drift — keep the -host's tag when copying the file over (never let this skill move the demo -to an older image), and `up -d` restarts only the services whose image or -config changed. +named to the user to add by hand. + +The public demo (demo.gammapdf.com) is NOT in this project: it is its own +compose project in `/root/Container/gamma-demo/` (the `update-demo-server` +skill; never touch it from here). This project holds only its way in: the +Caddyfile's `@demo` handle and Caddy on the external network `gamma-edge`, +where the demo answers as `gamma-demo`. `compose.yml` refuses to start while +that network is missing; on a new host create it first +(`docker network inspect gamma-edge >/dev/null 2>&1 || docker network create gamma-edge`). ## Update diff --git a/.claude/skills/update-demo-server/SKILL.md b/.claude/skills/update-demo-server/SKILL.md index 02783e04..51f904b4 100644 --- a/.claude/skills/update-demo-server/SKILL.md +++ b/.claude/skills/update-demo-server/SKILL.md @@ -1,34 +1,40 @@ --- name: update-demo-server -description: Build the Gamma server image from a branch by dispatching docker.yml (only :sha-<short>, never :latest), then pin that tag for the `demo` service on the VPS behind demo.gammapdf.com and restart it. No merge to main is needed. +description: Build the Gamma server image from a branch by dispatching docker.yml (only :sha-<short>, never :latest), then pin that tag in the demo's own compose project on the VPS (/root/Container/gamma-demo, demo.gammapdf.com) and restart it. No merge to main is needed. --- # Updating the public demo on the VPS `demo.gammapdf.com` is a Gamma in demo mode ([docs/dev/guests.md](../../../docs/dev/guests.md) -"Demo mode"): the `demo` service of the compose project on the VPS -`root@69.63.206.178`, folder `/root/Container/gamma-account/`, next to -`account`, `share` and `caddy` (Cloudflare in front, Caddy's `*.gammapdf.com` -site proxying the name to `demo:9001`). It runs `ghcr.io/tim4431/gamma` -pinned by a `sha-<short>` tag. `.github/workflows/docker.yml` dispatched on a -branch without a version pushes exactly that tag and never moves `:latest`, -which the NAS pulls. This skill builds through it and pins the result in the -HOST's `compose.yml`. The host's `demo` image line is the one that counts: it -is usually ahead of the one in the repository. Setup and first deployment: -[cloud/deploy/README.md](../../../cloud/deploy/README.md) "The demo server". +"Demo mode"). It is its OWN compose project on the VPS `root@69.63.206.178`, +folder `/root/Container/gamma-demo/`: `compose.yml`, `.env` (one line, +`GAMMA_TAG=sha-<short>`, the image to run), `demo.env` (its settings) and +`data/` (its whole state). It runs `ghcr.io/tim4431/gamma:${GAMMA_TAG}` and +answers as `gamma-demo` on the external Docker network `gamma-edge`, where the +account project's Caddy (`/root/Container/gamma-account/`) routes the name to +it. The repository's copies are in `cloud/deploy/demo/`; setup and the layout: +[cloud/deploy/demo/README.md](../../../cloud/deploy/demo/README.md). + +`.github/workflows/docker.yml` dispatched on a branch without a version pushes +exactly the `sha-<short>` tag and never moves `:latest`, which the NAS pulls. +This skill builds through it and pins the result by writing the demo's `.env`. Workflows: [docs/dev/github_actions.md](../../../docs/dev/github_actions.md#dockeryml). -Never read out, copy off the host or overwrite `.env`, `share.env`, -`demo.env`, `data/` or `share-data/`. Restart only `demo` (plus a Caddy -reload when the Caddyfile changed). `account` and `share` belong to the -`update-account-server` skill. Never commit. Never pass `-f version` to -`docker.yml`: a version adds the release tags, and with them `:latest`. +Rules: -In every command below, `<sha>` is the full `headSha` and `<tag>` is -`sha-` + its first 7 characters (`echo sha-${sha:0:7}`). The metadata -action's `type=sha` truncates to 7; `git rev-parse --short` may print more -and must not be used for the tag. The pinned `share` tag (`sha-a0d31c6`) shows -the shape. +- Work only in `/root/Container/gamma-demo/`. Never run a compose command in + `gamma-account/` from this skill, never restart `account`, `share` or + `caddy`: they belong to the `update-account-server` skill. The demo's route + (the Caddyfile's `@demo` handle, Caddy on `gamma-edge`) lives there too; if + it is missing, say so and point to that skill. +- Never read out, copy off the host or overwrite `demo.env` or `data/`. +- Never commit. Never pass `-f version` to `docker.yml`: a version adds the + release tags, and with them `:latest`. + +In every command below, `<sha>` is the full `headSha` and `<tag>` is `sha-` +plus its first 7 characters (`echo sha-${sha:0:7}`). The metadata action's +`type=sha` truncates to 7; `git rev-parse --short` may print more and must not +be used for the tag. ## 1. What will ship @@ -48,18 +54,17 @@ git log --oneline origin/<branch>..<branch> ## 2. Build (or reuse a build) -A commit that already has a green `docker.yml` run has its tag in GHCR -(every run, including a push to `main`, pushes `sha-<short>`). Look before -building: +A commit that already has a green `docker.yml` run has its tag in GHCR (every +run, including a push to `main`, pushes `sha-<short>`). Look before building: ```bash git rev-parse origin/<branch> gh run list --workflow docker.yml --commit <that sha> --status success --limit 1 --json databaseId,headSha,event,url ``` -If a run is listed, skip the build and use its `headSha`. To redeploy an -older published commit without building (the user names it, or pick one -from `gh run list --workflow docker.yml --status success --limit 10 --json headSha,headBranch,event,createdAt`), +If a run is listed, skip the build and use its `headSha`. To redeploy an older +published commit without building (the user names it, or pick one from +`gh run list --workflow docker.yml --status success --limit 10 --json headSha,headBranch,event,createdAt`), use that `headSha` and go to step 3. Otherwise dispatch and find the run: @@ -69,180 +74,137 @@ gh workflow run docker.yml --ref <branch> gh run list --workflow docker.yml --branch <branch> --event workflow_dispatch --limit 1 --json databaseId,headSha,status,url ``` -The run may take a few seconds to appear. List it again rather than guess. -Its `headSha` must equal `git rev-parse origin/<branch>`. Then wait: +The run may take a few seconds to appear; list it again rather than guess. Its +`headSha` must equal `git rev-parse origin/<branch>`. Then wait (run it in the +background; a multi-arch build under QEMU takes about 10 minutes, longer +without a warm cache): ```bash gh run watch <run-id> --exit-status ``` -It is a multi-arch build (amd64 and arm64 under QEMU), about 10 minutes, -longer without a warm cache. If it is red, report -`gh run view <run-id> --log-failed` and stop: no tag was pushed and nothing -changes on the host. `:latest` is untouched either way (the tag rules are -explained in docker.yml's header). - -Note `<sha>` and `<tag>`. +Red → report `gh run view <run-id> --log-failed` and stop: no tag was pushed +and nothing changes on the host. ## 3. What runs now ```bash -ssh root@69.63.206.178 "cd /root/Container/gamma-account && grep -A3 '^ demo:' compose.yml | grep 'image:' ; docker inspect --format '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' \$(docker compose ps -q demo)" +ssh root@69.63.206.178 "cd /root/Container/gamma-demo && cat .env && docker inspect --format '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' \$(docker compose ps -q demo)" ``` -This prints the pinned image line (keep the old tag for rollback) and the -running commit. If the revision equals `<sha>`, the image needs no deploy: -say so, run step 4 only if `cloud/deploy/` changed since that commit, and -otherwise stop. - -- No `demo:` service in the host's `compose.yml`, or no `demo.env` - (`ssh root@69.63.206.178 "ls /root/Container/gamma-account"`), means this - is the FIRST deployment. Follow cloud/deploy/README.md "The demo server" - with the user. `demo.env` must exist before the new `compose.yml` is - copied over: while a file named by `env_file` is missing, every - `docker compose` command in that folder fails, the account server's - included. Create it from the example only with the user's agreement and - only if it is absent: - `git show <sha>:cloud/deploy/demo.env.example | ssh root@69.63.206.178 "cd /root/Container/gamma-account && test ! -e demo.env && cat > demo.env && chmod 600 demo.env"`. - The user sets `GAMMA_ADMIN_PASSWORD` or reads the one-time random password - from the log themselves (step 6). Do not paste it into the chat. +This prints the pinned tag (keep it for rollback) and the running commit. If +the revision equals `<sha>`, the image needs no deploy: say so, do step 4 only +if `cloud/deploy/demo/` changed since that commit, and otherwise stop. + +No `/root/Container/gamma-demo/` at all means a first deployment: follow +[cloud/deploy/demo/README.md](../../../cloud/deploy/demo/README.md) "First +deployment" with the user. ## 4. Deploy files changed? -The host keeps its own `compose.yml` and `Caddyfile`. Compare them with the -commit being deployed: +Compare the host's `compose.yml` with the commit being deployed: + +```bash +ssh root@69.63.206.178 "cat /root/Container/gamma-demo/compose.yml" | diff --strip-trailing-cr - <(git show <sha>:cloud/deploy/demo/compose.yml) +``` + +The file carries no pin, so any difference is a real change. Show it to the +user and copy it only once they agree (a backup stays next to it): + +```bash +git show <sha>:cloud/deploy/demo/compose.yml | ssh root@69.63.206.178 "cd /root/Container/gamma-demo && cp compose.yml compose.yml.bak && cat > compose.yml && docker compose config -q" +``` + +`demo.env` is never copied or edited. List the variables the example gained or +lost since the running commit, and the names (names only, never values) the +host's file sets: ```bash -ssh root@69.63.206.178 "cat /root/Container/gamma-account/compose.yml" | diff - <(git show <sha>:cloud/deploy/compose.yml) -ssh root@69.63.206.178 "cat /root/Container/gamma-account/Caddyfile" | diff - <(git show <sha>:cloud/deploy/Caddyfile) +git diff <running revision>..<sha> -- cloud/deploy/demo/demo.env.example +ssh root@69.63.206.178 "grep -o '^[A-Z_]*=' /root/Container/gamma-demo/demo.env" ``` -- The `demo` image line normally differs, because the host is ahead. That - difference alone is no reason to copy anything. -- For any other difference, show it to the user and copy the file only once - they agree. A copied `compose.yml` resets the `demo` line to the - repository's tag, and step 5 then re-pins it. Changes it makes to - `account` or `share` (e.g. a new `share` tag) take effect only at their - next `docker compose up -d`. Tell the user; this skill does not restart - them. - - ```bash - git show <sha>:cloud/deploy/compose.yml | ssh root@69.63.206.178 "cd /root/Container/gamma-account && cp compose.yml compose.yml.bak && cat > compose.yml" - ``` - -- The Caddyfile is bind-mounted as a single file, so write it IN PLACE - (`cat >`, which keeps the inode). Never use `sed -i`, `mv` or `scp`, which - replace the file and leave the container reading the old one. Then reload, - and restore the old copy if the reload refuses the new one (the running - config stays the old one when a reload fails): - - ```bash - git show <sha>:cloud/deploy/Caddyfile | ssh root@69.63.206.178 "cd /root/Container/gamma-account && cp Caddyfile Caddyfile.bak && cat > Caddyfile && (docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || { cat Caddyfile.bak > Caddyfile; echo 'reload refused: Caddyfile restored'; exit 1; })" - ``` - -- `demo.env` is never copied or edited. List the variables the example - gained or lost since the running commit, and the names (names only, - never values) the host's file sets: - - ```bash - git diff <running revision>..<sha> -- cloud/deploy/demo.env.example - ssh root@69.63.206.178 "grep -o '^[A-Z_]*=' /root/Container/gamma-account/demo.env" - ``` - - Name any new variable to the user to add by hand (then `up -d demo` - applies it). +Name any new variable to the user to add by hand; step 5's `up -d` applies it. ## 5. Pin the tag and restart -One script on the host. It checks the tag exists before touching the file, -rewrites ONLY the image line inside the `demo:` service (the range runs from -` demo:` to the next line indented two spaces or less; `share` uses the same -image name with another tag and stays as it is), refuses and restores if -anything else changed, then pulls and recreates `demo` alone: +One script on the host. It checks the tag format and that the image exists +before touching anything, writes `.env` (the old one kept as `.env.bak`), +checks the resolved image, then pulls and recreates the demo: ```bash ssh root@69.63.206.178 bash -s -- <tag> <<'EOF' set -eu TAG="$1" -IMG=ghcr.io/tim4431/gamma echo "$TAG" | grep -Eq '^sha-[0-9a-f]{7}$' || { echo "not a sha-<7 hex> tag: $TAG"; exit 1; } -cd /root/Container/gamma-account -test -f demo.env || { echo "demo.env missing: first deployment, see cloud/deploy/README.md"; exit 1; } -grep -q '^ demo:' compose.yml || { echo "no demo service in compose.yml: first deployment, see cloud/deploy/README.md"; exit 1; } -docker pull -q "$IMG:$TAG" -cp compose.yml compose.yml.pre-demo-pin -sed -i "/^ demo:[[:space:]]*\$/,/^ \{0,2\}[a-z]/ s#^\( image: ghcr\.io/tim4431/gamma:\)sha-[0-9a-f]*[[:space:]]*\$#\1$TAG#" compose.yml -diff compose.yml.pre-demo-pin compose.yml || true -if ! docker compose config demo | grep -q "image: $IMG:$TAG\$"; then - cp compose.yml.pre-demo-pin compose.yml; echo "the demo image line was not rewritten; compose.yml restored"; exit 1 -fi -if [ "$(diff compose.yml.pre-demo-pin compose.yml | grep -c '^>')" -gt 1 ]; then - cp compose.yml.pre-demo-pin compose.yml; echo "more than one line changed; compose.yml restored"; exit 1 +cd /root/Container/gamma-demo +docker pull -q "ghcr.io/tim4431/gamma:$TAG" +cp .env .env.bak +printf 'GAMMA_TAG=%s\n' "$TAG" > .env +if ! docker compose config demo | grep -q "image: ghcr.io/tim4431/gamma:$TAG\$"; then + cp .env.bak .env; echo "compose does not resolve the new tag; .env restored"; exit 1 fi -docker compose pull demo -docker compose up -d demo +docker compose up -d EOF ``` -The printed `diff` shows the one changed line (none if the tag was already -pinned). `up -d demo` recreates only `demo`: guests lose their session for -the seconds of the restart and keep their workspaces. At start the image -upgrades the data directory itself (`manage.py migrate`, snapshot first into -`demo-data/backups/`). It refuses a directory written by a newer build, so a -`demo` that keeps restarting needs its log read before anything else. +`up -d` recreates the demo alone: guests lose their connection for the seconds +of the restart and keep their workspaces. At start the image upgrades the data +directory itself (`manage.py migrate`, snapshot first into `data/backups/`). It +refuses a directory written by a newer build, so a `demo` that keeps +restarting needs its log read before anything else. -Offer to set the same tag on the `demo` image line of the repository's -`cloud/deploy/compose.yml` (a working-tree edit left for the user to commit, -never committed here). Then a later copy of the file, by this skill or by -`update-account-server`, does not roll the demo back. +Offer to set the same tag in the repository's `cloud/deploy/demo/.env.example` +(a working-tree edit left for the user to commit, never committed here), so a +first deployment from the repository starts on a current build. ## 6. Verify ```bash -ssh root@69.63.206.178 "cd /root/Container/gamma-account && docker compose ps && docker compose logs --tail 20 demo" -ssh root@69.63.206.178 "cd /root/Container/gamma-account && docker inspect --format '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' \$(docker compose ps -q demo)" +ssh root@69.63.206.178 "cd /root/Container/gamma-demo && docker compose ps && docker compose logs --tail 20 demo" +ssh root@69.63.206.178 "cd /root/Container/gamma-demo && docker inspect --format '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' \$(docker compose ps -q demo)" curl -s https://demo.gammapdf.com/api/server-config | grep -o '"demo": *true' # "demo":true curl -s -o /dev/null -w "%{http_code}\n" https://demo.gammapdf.com/ # 200 ``` -- `demo` is `Up … (healthy)`. The image's healthcheck needs up to ~30 s +- `demo` is `Up … (healthy)`; the image's healthcheck needs up to about 30 s after start. Its revision label equals `<sha>`. - `server-config` reports `"demo":true`. If it is missing, `GAMMA_DEMO=1` is - not in `demo.env` (or the build predates demo mode). A Cloudflare 521/502 - means Caddy or the container is not reachable: check - `docker compose logs caddy`. A 404 from the demo name means the host's - Caddyfile lacks the `@demo` handle (step 4). -- On a first deployment the admin's one-time password is in the log. Give - the user the command (`docker compose logs demo | grep -A2 "created the admin account"`) - rather than its output. The shared AI key, "Guests may use it" and the - allowance are the admin's to set in the GUI (Settings → Server → Shared AI - provider). AI keys never go into `demo.env`. - -Report the old → new tag and commit (short sha + subject), the run link if -one was built, and anything unusual in the log (a migration step, errors). + not in `demo.env` (or the build predates demo mode). +- A 502 from the demo's name means Caddy cannot reach `gamma-demo`: check + `docker network inspect gamma-edge` lists both the demo and Caddy. A 404 + means the account project's Caddyfile lacks the `@demo` handle. Both are + the `update-account-server` skill's to fix; tell the user. +- On a first deployment the admin's one-time password is in the log. Give the + user the command (`docker compose logs demo | grep -A2 "created the admin account"`) + rather than its output. The shared AI connection, "Guests may use it" and + the allowance are the admin's to set in the GUI (Settings → Server → Shared + AI provider). AI keys never go into `demo.env`. + +Report the old → new tag and commit (short sha + subject), the run link if one +was built, and anything unusual in the log (a migration step, errors). ## Resetting the demo (only when the user asks) -`demo-data/` is the demo's whole state (guest accounts and workspaces, the -admin account, the shared AI key, the settings) and it is disposable, but +`data/` is the demo's whole state (guest accounts and workspaces, the admin +account, the shared AI connection, the settings) and it is disposable, but wipe it only on the user's explicit request: ```bash -ssh root@69.63.206.178 "cd /root/Container/gamma-account && docker compose stop demo && rm -rf demo-data && docker compose up -d demo" +ssh root@69.63.206.178 "cd /root/Container/gamma-demo && docker compose down && rm -rf data && docker compose up -d" ``` -The instance starts fresh. The admin is seeded again, with a new random -password in the log unless `demo.env` sets one, and the shared AI key, +The instance starts fresh: the admin is seeded again, with a new random +password in the log unless `demo.env` sets one, and the shared AI connection, "Guests may use it" and the allowance must be entered again. A -`GAMMA_GUEST_SEED` zip kept in `demo-data/` is gone too. Move it aside first -(`mv demo-data/guest-seed.zip .` before the `rm`, then -`mkdir -p demo-data && mv guest-seed.zip demo-data/` before the `up`) if one -is in use. +`GAMMA_GUEST_SEED` zip kept in `data/` goes too. Move it aside first +(`mv data/guest-seed.zip .` before the `rm`, then +`mkdir -p data && mv guest-seed.zip data/` before the `up`) if one is in use. ## Rollback -Run step 5 again with the previous tag from step 3. If the newer build -already upgraded the data directory, the older one refuses it and keeps -restarting. Then either restore the snapshot the upgrade took in -`demo-data/backups/<time>-v<N>/` ([docs/dev/migrations.md](../../../docs/dev/migrations.md) +Run step 5 again with the previous tag from step 3 (or `.env.bak`). If the +newer build already upgraded the data directory, the older one refuses it and +keeps restarting. Then either restore the snapshot the upgrade took in +`data/backups/<time>-v<N>/` ([docs/dev/migrations.md](../../../docs/dev/migrations.md) "Running it"), or reset the demo as above. Both need the user's agreement. diff --git a/CLAUDE.md b/CLAUDE.md index aea6f745..1d96fa27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Topic docs live in `docs/dev/` — **read the relevant one before working in tha - [docs/dev/collab.md](docs/dev/collab.md) — real-time collaboration: the block-op write path and per-page op log (`gamma/ops.py`), the workspace change feed + page tombstones (`gamma/routers/sync.py`, `deleted_pages`), rooms + the page websocket (`gamma/collab.py`, presence), the client session (`src/collaboration/collabSession.js` + `src/collaboration/usePageCollab.js`, `src/shared/model/blockOps.js`), same-block reconciliation, undo rebasing. Read before touching any block writer or the tree's save path. - [docs/dev/mirror.md](docs/dev/mirror.md) — offline copies: a workspace that mirrors a workspace on another Gamma server (`gamma/sync_engine.py`, `gamma/sync_tree.py`, `routers/sync.py` + `routers/mirrors.py`): the change feed and `whoami`, the per-page three-way reconciliation from a saved base tree, edit-beats-delete, files by hash, the conflict list, write-scope integration tokens as bearer credentials on the HTTP API, Settings → Workspaces → Clones, the desktop switcher's *clone* chip. Read before touching sync, tokens on `/api/*`, or anything a mirror pushes. - [docs/dev/workspaces.md](docs/dev/workspaces.md) — workspaces: accounts vs libraries, the two kinds (personal: several per account, just you, metered; shared: admin-made, roles owner/editor/viewer, access private / public), the default workspace, what admins may do without membership, storage (personal only + per-workspace quotas), how a request names its workspace (`?ws=` / `X-Gamma-Workspace` / the personal default), shares inside workspaces, backups (the one zip format behind export/import, server-kept per-workspace snapshots, `gamma/ws_backup.py`), the API, the switcher + the Workspaces, Backups and Server panes. Read before touching any auth helper or data-path helper. -- [docs/dev/guests.md](docs/dev/guests.md) — guests keep nothing: every guest login mints a throwaway `guest-<random>` account with its own workspace, expired after `guest_ttl_hours` by the middleware and the sweeper (`gamma/guests.py`, `workspaces.delete_account`), the optional `GAMMA_GUEST_SEED` starter zip; the shared AI allowance (tokens per account per 24 h on the admin's shared entries, `ai_settings.ai_runtime` + one choke point in the transport); demo mode (`demo_mode` / `GAMMA_DEMO`: the login page's "Try the demo", the first tour offered on arrival, sessionStorage guide progress) and demo.gammapdf.com's deployment (`cloud/deploy/`, the `update-demo-server` skill). Read before touching guest handling, the shared providers or the login page. +- [docs/dev/guests.md](docs/dev/guests.md) — guests keep nothing: every guest login mints a throwaway `guest-<random>` account with its own workspace, expired after `guest_ttl_hours` by the middleware and the sweeper (`gamma/guests.py`, `workspaces.delete_account`), the optional `GAMMA_GUEST_SEED` starter zip; the shared AI allowance (tokens per account per 24 h on the admin's shared entries, `ai_settings.ai_runtime` + one choke point in the transport); demo mode (`demo_mode` / `GAMMA_DEMO`: the login page's "Try the demo", the first tour offered on arrival, sessionStorage guide progress) and demo.gammapdf.com's deployment (its own compose project, `cloud/deploy/demo/`, the `update-demo-server` skill). Read before touching guest handling, the shared providers or the login page. - [docs/dev/migrations.md](docs/dev/migrations.md) — the versioned data-directory upgrade: `SCHEMA_VERSION`, numbered steps, snapshot-then-stamp, refusal of newer directories, the no-pile-up rules, how to write a step. Read before changing any stored shape. - [docs/dev/user_db.md](docs/dev/user_db.md) — the data directory (`users.db` + per-workspace DBs), auth middleware, seeding/first-run admin, `manage.py` CLI, user-management GUI, storage limits, the server log. - [docs/dev/ai.md](docs/dev/ai.md) — the AI stack: provider entries and protocols (incl. ChatGPT OAuth), the `/api/ai/chat` request/stream shape, the library agent (scopes, permissions, tool loop, replay, privacy), chat-history buckets. diff --git a/cloud/deploy/Caddyfile b/cloud/deploy/Caddyfile index ef7978d4..429e5c79 100644 --- a/cloud/deploy/Caddyfile +++ b/cloud/deploy/Caddyfile @@ -22,6 +22,10 @@ # challenge is needed (Cloudflare "Full"). Anything else under the wildcard # is a 404. # +# The demo is its own compose project (cloud/deploy/demo/): Caddy reaches +# it as `gamma-demo` on the shared network `gamma-edge`, resolved per +# request, so a stopped demo is a 502 for its name and nothing else. +# # The demo's guest logins are rate limited per client address, which Gamma # reads from the first X-Forwarded-For hop. Caddy does not trust the # X-Forwarded-For Cloudflare sends and would put Cloudflare's edge address @@ -38,7 +42,7 @@ reverse_proxy share:9001 } handle @demo { - reverse_proxy demo:9001 { + reverse_proxy gamma-demo:9001 { header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} } } diff --git a/cloud/deploy/README.md b/cloud/deploy/README.md index 1a0f57b0..5395e22e 100644 --- a/cloud/deploy/README.md +++ b/cloud/deploy/README.md @@ -8,14 +8,14 @@ NAS). The service itself is described in ``` deploy/ - compose.yml account, share, demo + caddy (a VPS with a public address) - Caddyfile TLS for CADDY_HOST → account:9002; *.gammapdf.com → share / demo + compose.yml account, share + caddy (a VPS with a public address); caddy also on gamma-edge + Caddyfile TLS for CADDY_HOST → account:9002; *.gammapdf.com → share / gamma-demo compose.tunnel.yml layered on compose.yml: cloudflared instead of caddy compose.build.yml layered on compose.yml: build from ./src instead of pulling Dockerfile.local the image built from a copy of cloud/ (compose.build.yml) .env.example → .env: public URL, registration mode, SMTP, Turnstile, Google/GitHub, hostname share.env.example → share.env: the share host's cloud client and page hosts - demo.env.example → demo.env: the public demo (demo mode, guest expiry and cap, first admin) + demo/ the public demo, its own compose project (demo/README.md) ``` The whole state of the service is the `data/` folder next to the compose @@ -27,8 +27,12 @@ signing keys and every token hash. Moving to another host is copying `root@69.63.206.178`, folder `/root/Container/gamma-account/`, running `compose.yml` with the GHCR image. Updates go through the -`update-account-server` skill (`.claude/skills/`); the demo next to it -through `update-demo-server`. +`update-account-server` skill (`.claude/skills/`). The public demo is its +own compose project in `/root/Container/gamma-demo/` +([demo/README.md](demo/README.md), the `update-demo-server` skill); this +project's Caddy only routes its name to it over the external network +`gamma-edge`, which must exist before this file starts +(`docker network create gamma-edge`, once per host). ## First deployment on a VPS @@ -51,6 +55,8 @@ through `update-demo-server`. curl -o .env https://raw.githubusercontent.com/tim4431/Gamma/main/cloud/deploy/.env.example # fill in .env: SMTP, Turnstile; CADDY_HOST is the hostname above chmod 600 .env + # the network Caddy shares with the demo (demo/README.md), once per host + docker network inspect gamma-edge >/dev/null 2>&1 || docker network create gamma-edge docker compose up -d ``` @@ -211,70 +217,12 @@ state (published pages and files) — back it up like `data/`. ## The demo server -The compose file also runs `demo`: a Gamma (`ghcr.io/tim4431/gamma`) in -demo mode at `https://demo.gammapdf.com`, where anyone can try Gamma -without an account. **Try the demo** on its login page makes a throwaway -guest account that is deleted with its workspace after -`GAMMA_GUEST_TTL_HOURS`; the admin's shared AI key is metered per guest. -What demo mode changes and how guests work: -[docs/dev/guests.md](../../docs/dev/guests.md). The image is pinned to the -`sha-<short>` tag of a branch build (`docker.yml` dispatched on the branch, -which never moves `:latest`), and the `update-demo-server` skill -(`.claude/skills/`) builds, pins and restarts it. That skill rewrites the -`demo` image line in the host's `compose.yml`, so the host's line is the -one that counts. - -Setting it up once: - -1. **DNS.** At Cloudflare, `A demo → <the VPS address>`, proxied. The `*` - record of the share host already routes the name here; the named record - keeps the demo up if the wildcard ever changes. The Caddyfile's - `*.gammapdf.com` site answers it with the internal certificate (SSL mode - "Full") and proxies it to `demo:9001`. -2. **`demo.env` first.** Every `docker compose` command in the folder - refuses to run while a file named by `env_file` is missing, the account - server's updates included, so create it before the new `compose.yml` - arrives: - - ```bash - cd ~/Container/gamma-account - curl -o demo.env https://raw.githubusercontent.com/tim4431/Gamma/main/cloud/deploy/demo.env.example - chmod 600 demo.env - # optional: GAMMA_ADMIN_PASSWORD, GAMMA_GUEST_MAX, GAMMA_GUEST_SEED - ``` - -3. **The files.** Copy the new `compose.yml` and `Caddyfile` over the - host's (from the branch that has them, as the `update-demo-server` skill - does), then start the demo and load the new site: - - ```bash - docker compose up -d demo - docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile - ``` - - Write the Caddyfile in place (`cat > Caddyfile`), never with `sed -i` or - an editor that replaces the file: it is bind-mounted as a single file, - and a new inode stays invisible to the running container. -4. **Check.** `curl -s https://demo.gammapdf.com/api/server-config` contains - `"demo":true`, and the page opens on **Try the demo**. -5. **The admin, once.** With `GAMMA_ADMIN_PASSWORD` empty the container - prints a random password for `admin` once: - `docker compose logs demo | grep -A2 "created the admin account"`. Sign - in through **Admin sign-in** on the login page, change the password - (Settings → Users), then under Settings → Server → Shared AI provider - add the shared key, turn **Guests may use it** on and set the per-guest - allowance. AI keys are never environment variables. -6. **Optional sample library.** Prepare a workspace (the admin's own here - will do), export it (Settings → Workspaces → Export, or download a - snapshot from Settings → Backups), copy the zip to - `demo-data/guest-seed.zip`, uncomment `GAMMA_GUEST_SEED` in `demo.env` - and `docker compose up -d demo`. Every new guest starts from a copy. - -`demo-data/` is the demo's whole state and it is disposable: no backup. -Wiping it (`docker compose stop demo && rm -rf demo-data && docker compose -up -d demo`) resets the demo to a fresh instance: a new admin password in -the log, the shared key and allowance to enter again, and the seed zip to -copy back if one was used. +`demo.gammapdf.com` is its own compose project in +`/root/Container/gamma-demo/`, with its own image pin, settings and data: +[demo/README.md](demo/README.md). This project holds only its way in: the +Caddyfile's `@demo` handle sends the name to `gamma-demo:9001`, and Caddy +joins the external network `gamma-edge` where the demo answers under that +alias. A stopped demo is a 502 for its name and nothing else here. ## Updating diff --git a/cloud/deploy/compose.yml b/cloud/deploy/compose.yml index 2e86f2dc..d1874ddd 100644 --- a/cloud/deploy/compose.yml +++ b/cloud/deploy/compose.yml @@ -10,18 +10,15 @@ # ./share-data and its settings in share.env (share.env.example). # Reached only through caddy (no host port). Pinned to an image # tag: bump it with the desktop release. -# demo demo.gammapdf.com: a Gamma in demo mode (docs/dev/guests.md), -# every visitor a throwaway guest account; its data on -# ./demo-data (disposable) and its settings in demo.env -# (demo.env.example). Reached only through caddy (no host port). -# Pinned to the `sha-<short>` tag of a branch build (docker.yml -# dispatched on the branch): the update-demo-server skill rewrites -# the image line in the HOST's copy of this file, so there that -# line is the truth and may differ from the one below. # caddy TLS on the host's own 80/443 with its internal certificate # (`tls internal`; Cloudflare holds the public one), for a host # with a public address (a VPS). The DNS record for the hostname -# in .env (CADDY_HOST) must point at the host. +# in .env (CADDY_HOST) must point at the host. Caddy also joins +# the external network `gamma-edge`, where the public demo — its +# own compose project, cloud/deploy/demo/ — answers as +# `gamma-demo`. Create that network once per host before the +# first `up` (`docker network create gamma-edge`): this file +# refuses to start without it. # # Variants layered with -f: compose.tunnel.yml replaces caddy with a # Cloudflare Tunnel (no open port; a NAS); compose.build.yml builds the @@ -46,15 +43,6 @@ services: depends_on: - account - demo: - # sha-<short> of the build to run. update-demo-server rewrites this line - # on the host; the value here is only the first deployment's. - image: ghcr.io/tim4431/gamma:sha-30586ef - restart: unless-stopped - env_file: demo.env - volumes: - - ./demo-data:/data - caddy: image: caddy:2-alpine restart: unless-stopped @@ -67,11 +55,18 @@ services: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config + networks: + - default + - edge depends_on: - account - share - - demo volumes: caddy-data: caddy-config: + +networks: + edge: + name: gamma-edge + external: true diff --git a/cloud/deploy/demo/.env.example b/cloud/deploy/demo/.env.example new file mode 100644 index 00000000..f44f2d35 --- /dev/null +++ b/cloud/deploy/demo/.env.example @@ -0,0 +1,5 @@ +# Copy to .env next to compose.yml. Compose reads it to fill in the image +# tag; the container never sees it (its settings are demo.env). The +# update-demo-server skill rewrites this line to the build it deploys: a +# `sha-<7 hex>` tag that docker.yml pushed (a branch build, never :latest). +GAMMA_TAG=sha-30586ef diff --git a/cloud/deploy/demo/README.md b/cloud/deploy/demo/README.md new file mode 100644 index 00000000..ff6365be --- /dev/null +++ b/cloud/deploy/demo/README.md @@ -0,0 +1,91 @@ +# Deploying the public demo + +How `demo.gammapdf.com` runs: a Gamma (`ghcr.io/tim4431/gamma`) in demo +mode, where anyone can try Gamma without an account. **Try the demo** on +its login page makes a throwaway guest account that is deleted with its +workspace after `GAMMA_GUEST_TTL_HOURS`; the admin's shared AI connection +is metered per guest. What demo mode changes and how guests work: +[docs/dev/guests.md](../../../docs/dev/guests.md). + +``` +deploy/demo/ + compose.yml the demo alone: the image by GAMMA_TAG, ./data, the gamma-edge network + .env.example → .env: GAMMA_TAG, the sha-<short> tag to run (the skill writes it) + demo.env.example → demo.env: demo mode, guest lifetime and cap, first admin, seed library +``` + +## Where it runs + +`root@69.63.206.178`, folder `/root/Container/gamma-demo/`: its own compose +project, next to the account server's `/root/Container/gamma-account/` +([../README.md](../README.md)) and independent of it. Starting, stopping, +updating or wiping the demo never touches the account server or the share +host, and their updates never touch the demo. + +The one thing the two share is the way in. The account project's Caddy +owns the host's ports 80/443 and TLS, and its Caddyfile's `*.gammapdf.com` +site sends `demo.gammapdf.com` to `gamma-demo:9001`. That name is the demo +container's alias on `gamma-edge`, an external Docker network both projects +join. Caddy resolves the name per request, so the demo can restart or be +absent (a 502 for its name only) without Caddy noticing anything else. + +The image is pinned to the `sha-<short>` tag of a branch build (`docker.yml` +dispatched on the branch, which never moves `:latest`), named by +`GAMMA_TAG` in the folder's `.env`. The `update-demo-server` skill +(`.claude/skills/`) builds, writes that line and restarts the demo. + +## First deployment + +1. **DNS.** At Cloudflare, `A demo → <the VPS address>`, proxied, SSL mode + "Full". The share host's `*` record already routes the name to the host; + the named record keeps the demo up if the wildcard ever changes. +2. **The network, once per host.** The account project's `compose.yml` + refuses to start without it too: + + ```bash + docker network inspect gamma-edge >/dev/null 2>&1 || docker network create gamma-edge + ``` + +3. **The folder.** Copy this folder's files and fill in the settings: + + ```bash + mkdir -p ~/Container/gamma-demo && cd ~/Container/gamma-demo + B=https://raw.githubusercontent.com/tim4431/Gamma/main/cloud/deploy/demo + curl -o compose.yml $B/compose.yml + curl -o .env $B/.env.example # then set GAMMA_TAG to the build to run + curl -o demo.env $B/demo.env.example + chmod 600 demo.env + # optional in demo.env: GAMMA_ADMIN_PASSWORD, GAMMA_GUEST_MAX, GAMMA_GUEST_SEED + docker compose up -d + ``` + +4. **The route.** The account project's `compose.yml` puts Caddy on + `gamma-edge` and its `Caddyfile` has the `@demo` handle; both come from + `cloud/deploy/` through the `update-account-server` skill. +5. **Check.** `curl -s https://demo.gammapdf.com/api/server-config` contains + `"demo":true`, and the page opens on **Try the demo**. +6. **The admin, once.** With `GAMMA_ADMIN_PASSWORD` empty the container + prints a random password for `admin` once: + `docker compose logs demo | grep -A2 "created the admin account"`. Sign + in through **Admin sign-in** on the login page and change the password + (Settings → Users). Then, under Settings → Server → Shared AI provider, + add the shared connection (an API key or a ChatGPT subscription), turn + **Guests may use it** on and set the per-guest allowance. AI keys are + never environment variables. +7. **Optional sample library.** Prepare a workspace (the admin's own here + will do) and export it (Settings → Workspaces → Export, or download a + snapshot from Settings → Backups). Copy the zip to + `data/guest-seed.zip`, uncomment `GAMMA_GUEST_SEED` in `demo.env` and + run `docker compose up -d`. Every new guest starts from a copy. + +## Resetting + +`data/` is the demo's whole state, and it is disposable: no backup. Wiping +it resets the demo to a fresh instance: + +```bash +cd ~/Container/gamma-demo && docker compose down && rm -rf data && docker compose up -d +``` + +That means a new admin password in the log, the shared connection and the +allowance to enter again, and the seed zip to copy back if one was used. diff --git a/cloud/deploy/demo/compose.yml b/cloud/deploy/demo/compose.yml new file mode 100644 index 00000000..66cbf087 --- /dev/null +++ b/cloud/deploy/demo/compose.yml @@ -0,0 +1,31 @@ +# The public demo, demo.gammapdf.com (cloud/deploy/demo/README.md): a Gamma +# (ghcr.io/tim4431/gamma) in demo mode, where every visitor gets a throwaway +# guest account (docs/dev/guests.md). Its own compose project in its own +# folder on the VPS (/root/Container/gamma-demo/), next to the account +# server's but independent of it: +# +# - the image is the `sha-<short>` tag of a branch build (docker.yml +# dispatched on the branch), named by GAMMA_TAG in the folder's .env +# (.env.example); the update-demo-server skill writes that one line; +# - its settings are demo.env (demo.env.example), its whole state ./data +# (disposable); +# - no host port: the account project's Caddy, which owns 80/443, reaches +# it as `gamma-demo:9001` over the shared external network `gamma-edge` +# (`docker network create gamma-edge`, once per host). + +services: + demo: + image: ghcr.io/tim4431/gamma:${GAMMA_TAG:?set GAMMA_TAG=sha-<short> in .env} + restart: unless-stopped + env_file: demo.env + volumes: + - ./data:/data + networks: + edge: + aliases: + - gamma-demo + +networks: + edge: + name: gamma-edge + external: true diff --git a/cloud/deploy/demo.env.example b/cloud/deploy/demo/demo.env.example similarity index 74% rename from cloud/deploy/demo.env.example rename to cloud/deploy/demo/demo.env.example index e2c87441..eb5c8bca 100644 --- a/cloud/deploy/demo.env.example +++ b/cloud/deploy/demo/demo.env.example @@ -1,7 +1,8 @@ -# Copy to demo.env next to compose.yml: the public demo's settings, a Gamma -# in demo mode (docs/dev/guests.md "Demo mode"). Every visitor gets a -# throwaway guest account that is deleted after GAMMA_GUEST_TTL_HOURS. The -# variables are read at start: `docker compose up -d demo` after an edit. +# Copy to demo.env next to compose.yml (this folder, cloud/deploy/demo/): +# the public demo's settings, a Gamma in demo mode (docs/dev/guests.md "Demo +# mode"). Every visitor gets a throwaway guest account that is deleted after +# GAMMA_GUEST_TTL_HOURS. The variables are read at start: `docker compose +# up -d` after an edit. The image tag is not here but in .env. GAMMA_PUBLIC_URL=https://demo.gammapdf.com GAMMA_DEMO=1 @@ -21,7 +22,7 @@ GAMMA_ADMIN_PASSWORD= # new guest's workspace. Make it from a workspace prepared on any Gamma # (the admin's own on this server will do): Settings → Workspaces → Export, # or a snapshot downloaded from Settings → Backups. Copy that zip to -# ./demo-data/guest-seed.zip on the host, uncomment the line, and restart. +# ./data/guest-seed.zip on the host, uncomment the line, and restart. # Keep it small: every guest gets its own copy. # GAMMA_GUEST_SEED=/data/guest-seed.zip diff --git a/docs/dev/github_actions.md b/docs/dev/github_actions.md index 86a54439..5f180a52 100644 --- a/docs/dev/github_actions.md +++ b/docs/dev/github_actions.md @@ -36,7 +36,7 @@ build-site ──▶ site.yml --ref <branch>: check → gammapdf.com ← update-account-server ──▶ cloud.yml --ref <branch>: test → ghcr gamma-cloud :latest :sha-<short> then pull + restart on the VPS ← no merge needed update-demo-server ──▶ docker.yml --ref <branch>: ghcr gamma :sha-<short> (never :latest) - then pin that tag for `demo` on the VPS ← no merge needed + then pin that tag in the demo's project ← no merge needed release skill ─┬──▶ desktop.yml meta: version = max(package.json, newest v* tag + patch) (gh workflow │ build Win/mac/Linux with that version pinned, smoke on all three run) │ publish: Release v<version> (notes = commits since previous tag) @@ -178,9 +178,10 @@ Dispatched on a branch without a version `sha-<short>`: `latest` is enabled on the default branch alone (`{{is_default_branch}}`) and the semver tags only with a version. That is how the public demo gets a build of `dev` without a merge; the -`update-demo-server` skill dispatches it and pins the tag in the VPS's -compose file ([cloud/deploy/README.md](../../cloud/deploy/README.md) "The -demo server", [guests.md](guests.md)). Never pass `-f version` for a demo +`update-demo-server` skill dispatches it and pins the tag in the demo's +own compose project on the VPS, as `GAMMA_TAG` in its `.env` +([cloud/deploy/demo/README.md](../../cloud/deploy/demo/README.md), +[guests.md](guests.md)). Never pass `-f version` for a demo build: that adds the release tags, and the semver rule adds `latest` with them. Setup notes: [docs/dev/debugging.md](debugging.md) and the memory note on GHCR. diff --git a/docs/dev/guests.md b/docs/dev/guests.md index 89fd8e58..c0d6e017 100644 --- a/docs/dev/guests.md +++ b/docs/dev/guests.md @@ -141,9 +141,11 @@ allowance work the same on every server. gone in 5 hours", from `guest_expires_at`, `auth/guestExpiry.js`); that line is every guest's, demo server or not. -demo.gammapdf.com is the `demo` service next to the account server on the -VPS (`GAMMA_DEMO=1` in its `demo.env`), pinned to the `sha-<short>` tag of a -branch build (`docker.yml` dispatched on the branch, which never moves -`:latest`): setup in [cloud/deploy/README.md](../../cloud/deploy/README.md) -"The demo server", updates through the `update-demo-server` skill +demo.gammapdf.com is its own compose project on the VPS, in a folder next +to the account server's (`GAMMA_DEMO=1` in its `demo.env`), reached through +the account project's Caddy over the shared Docker network `gamma-edge`. It +runs the `sha-<short>` tag of a branch build (`docker.yml` dispatched on the +branch, which never moves `:latest`), named by `GAMMA_TAG` in the folder's +`.env`: setup in [cloud/deploy/demo/README.md](../../cloud/deploy/demo/README.md), +updates through the `update-demo-server` skill (`.claude/skills/update-demo-server/SKILL.md`).