diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2fcca92..4c7902f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -275,6 +275,30 @@ The editor is for invited people, and their analyzers live in GitHub repositorie refusal of anyone not invited, token refresh and reading in `server/test_github.py`, and opening and running an analyzer from a repository in the browser self test. +### Opened by another page, with a text (implemented) + +A page that shows real documents -- the TEG NLP console's Workbench, listing every +résumé the pipeline holds -- opens the studio on one of them, in the analyzer that reads +it, ready to run and edit (`src/handoff.ts`). + +- **The link names the analyzer:** `?repo=owner/name&ref=branch&folder=path&handoff=1` + opens it from GitHub as "Open from GitHub" would. Not signed in, the link waits in + `sessionStorage` across the sign-in round trip and is taken back once. +- **The text never travels in the link.** It can be a client's document, and a URL lands in + history, logs and referrers. With `handoff=1` the studio posts `nlp-studio:ready` to the + window that opened it; that window answers `{type: "nlp-studio:input", name, text}`. The + studio takes it only from `window.opener`, only from an origin listed in + `NLP_STUDIO_OPENERS` (reported by `/api/health`; none by default), only up to 2 MB, and + names it `input/.txt` with the name reduced to safe characters. +- **A handed-in text is never kept.** It is an input file while the page is open and is + what a run reads, but it is never a draft in the browser's storage, never among the + changed files, never committed and never in the Download zip. The file list says so + beside it. A reload -- signing in is one -- loses it, and the studio says `ready` again so + the opener can hand it over again. +- **Where the studio runs is where the text goes.** A run sends the input to the run + server that served the page. A page handing over client documents points at a studio its + own organization runs, with sign-in required. + ### Why the engine stays server-side The engine is a native C++ binary. `nlpplus` links it as a Node-API addon and runs calls diff --git a/studio/deploy/env.example b/studio/deploy/env.example index 720c778..feb8758 100644 --- a/studio/deploy/env.example +++ b/studio/deploy/env.example @@ -20,3 +20,8 @@ NLP_STUDIO_USERS= NLP_STUDIO_PUBLIC_URL=https://studio.visualtext.org/studio NLP_STUDIO_REQUIRE_SIGN_IN=1 + +# The pages allowed to open the studio and hand it a text (src/handoff.ts), as exact +# origins, comma-separated. Empty: no page may. A text handed over is a document someone +# chose to open -- often a client's -- so list only pages your own organization runs. +NLP_STUDIO_OPENERS= diff --git a/studio/server/app.py b/studio/server/app.py index 897df67..730b8ae 100644 --- a/studio/server/app.py +++ b/studio/server/app.py @@ -3,7 +3,8 @@ python server/app.py # the API on http://127.0.0.1:8765/api/ python server/app.py --dist dist # ...and the built site, from the same origin - GET /api/health {"ok": true, "engine": "2.2.38", "timeout": 10, "maxRuns": 2, "signIn": false, "github": false} + GET /api/health {"ok": true, "engine": "2.2.38", "timeout": 10, "maxRuns": 2, "signIn": false, "github": false, + "openers": []} <- NLP_STUDIO_OPENERS: the pages that may hand the studio a text POST /api/run {"files": {"spec/analyzer.seq": "...", ...}, "text": "...", "develop"?: true} -> 200 with nlp_run.run()'s result, whatever the run did; 400 for a request no analyzer could make, 401 not signed in, @@ -109,6 +110,10 @@ def option(name: str, default: str = "") -> str: self.dev_login: str | None = None self.sessions = Sessions() self.trees = TreeStore() + # The pages allowed to open the studio and hand it a text (src/handoff.ts): exact + # origins, comma-separated. None by default -- a page on the open web must not be + # able to put text into someone's editor. + self.openers = [o.strip().rstrip("/") for o in option("openers").split(",") if o.strip()] class Handler(SimpleHTTPRequestHandler): @@ -209,7 +214,8 @@ def do_GET(self): if path == "/api/health": opts = srv.options return self._json(200, {"ok": True, "engine": srv.engine, "timeout": opts.timeout, "maxRuns": opts.max_runs, - "signIn": srv.sign_in, "github": bool(srv.sign_in or srv.dev_token)}) + "signIn": srv.sign_in, "github": bool(srv.sign_in or srv.dev_token), + "openers": srv.openers}) if path == "/api/auth/me": return self._me() if path == "/api/auth/login": @@ -410,6 +416,7 @@ def options(argv=None) -> argparse.Namespace: opts.users = os.environ.get("NLP_STUDIO_USERS", "") opts.dev_token = os.environ.get("NLP_STUDIO_GITHUB_TOKEN", "") opts.require_sign_in = os.environ.get("NLP_STUDIO_REQUIRE_SIGN_IN", "") + opts.openers = os.environ.get("NLP_STUDIO_OPENERS", "") return opts diff --git a/studio/src/handoff.ts b/studio/src/handoff.ts new file mode 100644 index 0000000..5911575 --- /dev/null +++ b/studio/src/handoff.ts @@ -0,0 +1,133 @@ +// Opening the studio from another page: an analyzer named in the link, and a text +// handed over by the page that opened it. +// +// WHY. A page that shows texts -- a corpus of real documents, say -- wants a button +// that opens one of them in the studio, in the analyzer that reads it, ready to run +// and edit. The analyzer can be named in the link. The TEXT cannot: it may be a +// client's document, and a URL is written into browser history, server logs and +// referrers. So the text travels by postMessage from the window that opened the +// studio, and only from an origin the studio's server allows (NLP_STUDIO_OPENERS, +// reported by /api/health). +// +// THE LINK: ?repo=owner/name&ref=branch&folder=path/to/analyzer&handoff=1 +// repo, ref, folder open that analyzer from GitHub, as "Open from GitHub" would +// handoff=1 wait for a text from the opener +// +// THE EXCHANGE: +// studio -> opener {type: "nlp-studio:ready"} (target "*": +// it carries nothing, and says only that the studio is listening) +// opener -> studio {type: "nlp-studio:input", name: "x.txt", text: "..."} +// studio -> opener {type: "nlp-studio:received", name} (to its origin) +// +// A HANDED-IN TEXT IS NEVER KEPT. It becomes input/ for as long as the page is +// open: never a draft in this browser's storage, never in a commit, never in the +// Download zip. Closing the tab is the end of it. The studio says so beside the file. +// +// Signing in with GitHub leaves the page and comes back to its plain address, so the +// link -- the analyzer, never the text -- is kept in sessionStorage across it. + +export interface OpenLink { + repo: string; + ref: string; + folder: string; + handoff: boolean; +} + +export const READY = "nlp-studio:ready"; +export const INPUT = "nlp-studio:input"; +export const RECEIVED = "nlp-studio:received"; +// A text is a document, not an analyzer: 2 MB is far past any resume or form. +export const MAX_TEXT = 2 * 1024 * 1024; + +const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/; +const REF = /^[A-Za-z0-9._\/-]{1,200}$/; +const FOLDER = /^[A-Za-z0-9._\/-]{0,300}$/; +const KEPT = "nlp-studio:open-link"; + +// The analyzer a link names, or null if it names none or names one badly. +export function parseOpenLink(search: string): OpenLink | null { + const q = new URLSearchParams(search); + const repo = q.get("repo") ?? ""; + if (!REPO.test(repo)) return null; + const ref = q.get("ref") ?? ""; + const folder = (q.get("folder") ?? "").replace(/^\/+|\/+$/g, ""); + if (ref && !REF.test(ref)) return null; + if (!FOLDER.test(folder) || folder.split("/").includes("..")) return null; + return { repo, ref, folder, handoff: q.get("handoff") === "1" }; +} + +// Keep a link across the sign-in round trip; take it back once, afterwards. +export function keepLink(link: OpenLink, store: Pick | null = session()): void { + try { + store?.setItem(KEPT, JSON.stringify(link)); + } catch { + // Storage blocked: the person opens the analyzer by hand after signing in. + } +} + +export function takeLink(store: Pick | null = session()): OpenLink | null { + try { + const raw = store?.getItem(KEPT); + store?.removeItem(KEPT); + if (!raw) return null; + const link = JSON.parse(raw) as OpenLink; + return link && REPO.test(link.repo) ? link : null; + } catch { + return null; + } +} + +function session(): Storage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +// A handed-in text's file name: letters, digits, dot, dash and underscore, ending in +// .txt. Anything else is replaced, so a name can never reach outside input/. +export function inputName(name: unknown): string { + let n = typeof name === "string" ? name : ""; + n = n.split(/[\\/]/).pop() ?? ""; + n = n.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 80); + if (!n) n = "handed-in"; + if (!n.toLowerCase().endsWith(".txt")) n += ".txt"; + return n; +} + +export interface HandedText { + name: string; + text: string; +} + +// A message from the opener, as a text to take -- or null if it is not one, comes from +// anywhere but the opener at an allowed origin, or is too large. +export function acceptInput( + event: Pick, + opener: unknown, + allowed: readonly string[], +): HandedText | null { + if (!opener || event.source !== opener) return null; + if (!allowed.includes(event.origin)) return null; + const data = event.data as { type?: unknown; name?: unknown; text?: unknown } | null; + if (!data || data.type !== INPUT || typeof data.text !== "string") return null; + if (data.text.length > MAX_TEXT) return null; + return { name: inputName(data.name), text: data.text.replace(/\r\n?/g, "\n") }; +} + +// Tell the opener the studio is listening, and hand each text it sends to `take`. +// Returns a function that stops listening. +export function listenForHandoff(allowed: readonly string[], take: (t: HandedText) => void): () => void { + const opener = window.opener as Window | null; + if (!opener || !allowed.length) return () => undefined; + const onMessage = (event: MessageEvent) => { + const handed = acceptInput(event, opener, allowed); + if (!handed) return; + take(handed); + (event.source as Window).postMessage({ type: RECEIVED, name: handed.name }, event.origin); + }; + window.addEventListener("message", onMessage); + opener.postMessage({ type: READY }, "*"); + return () => window.removeEventListener("message", onMessage); +} diff --git a/studio/src/main.ts b/studio/src/main.ts index 869c58e..cfecb33 100644 --- a/studio/src/main.ts +++ b/studio/src/main.ts @@ -29,6 +29,7 @@ import { SIGN_IN_URL, account, analyzersIn, commitChanges as sendCommit, entryName, recent, remember, repositories, signOut, } from "./github/api"; import { DraftStore } from "./drafts"; +import { type OpenLink, keepLink, listenForHandoff, parseOpenLink, takeLink } from "./handoff"; import { safeFolder, zipAnalyzer } from "./zipfiles"; import { type RunResult, type RunTrees, type TreeFile, fetchTree, outputFiles, runAnalyzer, serverHealth, @@ -119,6 +120,10 @@ export class Studio { private readonly matchModels = new Map(); private readonly outputModels = new Map(); private lastPath: string | undefined; + // Texts handed over by the page that opened the studio (src/handoff.ts), by path: + // input files for as long as the page is open, and nothing more -- never a draft, + // never committed, never in the Download zip. + private readonly handed = new Map(); private treeRequest = 0; private runCount = 0; @@ -228,13 +233,52 @@ export class Studio { .map(([path, model]) => ({ uri: fileUri(entry.name, path), text: model.getValue() }))); this.passList = passes(this.models.get("spec/analyzer.seq")?.getValue() ?? "", entry.files); this.inputPath = entry.files.find((f) => f.startsWith("input/")); + this.installHanded(); + // A handed-in text is why the studio was opened: it is what a run reads. + const handedPath = [...this.handed.keys()].pop(); + if (handedPath) this.inputPath = handedPath; this.panel.clear(); this.renderFiles(); this.renderChoices(); this.showRunTarget(); this.showChanges(); const first = this.passList.find((p) => p.file && p.active)?.file; - this.openPath(first ?? "spec/analyzer.seq"); + this.openPath(handedPath ?? first ?? "spec/analyzer.seq"); + } + + // ---- Handed-in texts ---------------------------------------------------------------- + + // A text the opener handed over becomes input/ in whatever analyzer is open, now + // and after any other opens, and is what the next run reads. + addHandedInput(name: string, text: string): string { + const path = `input/${name}`; + this.handed.delete(path); + this.handed.set(path, text); + this.models.get(path)?.dispose(); + this.models.delete(path); + if (this.current) { + this.installHanded(); + this.renderFiles(); + this.showChanges(); + this.openPath(path); + } + return path; + } + + isHanded(path: string): boolean { + return this.handed.has(path); + } + + private installHanded(): void { + if (!this.current) return; + for (const [path, text] of this.handed) { + if (this.models.has(path)) continue; + const model = monaco.editor.createModel(text, languageFor(path), + monaco.Uri.parse(fileUri(this.current.name, path))); + model.setEOL(monaco.editor.EndOfLineSequence.LF); + this.originals.set(path, text); + this.models.set(path, model); + } } openPath(path: string, selection?: monaco.IRange | monaco.IPosition): void { @@ -529,6 +573,27 @@ export class Studio { } } + // Open the analyzer a link names, signing in first if need be, and -- when the link + // asks -- take a text from the page that opened the studio. + async openLink(link: OpenLink, openers: readonly string[]): Promise { + if (link.handoff) { + listenForHandoff(openers, ({ name, text }) => { + const path = this.addHandedInput(name, text); + this.say(`${path.slice(6)} handed in: not saved, committed or downloaded.`); + }); + if (!openers.length && window.opener) { + this.say("A page asked to hand this studio a text, and no page is allowed to (NLP_STUDIO_OPENERS)."); + } + } + if (this.account?.github && !this.account.signedIn) { + keepLink(link); + this.say(`Sign in with GitHub to open ${link.folder || link.repo}.`); + return; + } + await this.openRecent({ repo: link.repo, ref: link.ref || "", folder: link.folder, + title: link.folder.split("/").pop() || link.repo }); + } + // Reopen a remembered GitHub analyzer, at the branch's latest commit. async openRecent(item: RecentAnalyzer): Promise { this.say(`Opening ${item.title} from ${item.repo}…`); @@ -670,7 +735,9 @@ export class Studio { // The files whose text differs from what was opened. changedPaths(): string[] { - return [...this.models].filter(([path, model]) => model.getValue() !== this.originals.get(path)).map(([path]) => path); + return [...this.models] + .filter(([path, model]) => !this.handed.has(path) && model.getValue() !== this.originals.get(path)) + .map(([path]) => path); } // Save every draft still waiting on its pause. @@ -709,7 +776,9 @@ export class Studio { // The open analyzer, with any edits, as a zip of its folder. analyzerZip(): Uint8Array { const entry = this.current!; - return zipAnalyzer(entry.title, [...this.models].map(([path, model]) => [path, model.getValue()] as const)); + return zipAnalyzer(entry.title, [...this.models] + .filter(([path]) => !this.handed.has(path)) + .map(([path, model]) => [path, model.getValue()] as const)); } download(): void { @@ -726,6 +795,7 @@ export class Studio { } private scheduleSave(path: string): void { + if (this.handed.has(path)) return; clearTimeout(this.pendingSaves.get(path)); this.pendingSaves.set(path, setTimeout(() => this.saveDraft(path), SAVE_AFTER_MS)); this.showChanges(); @@ -922,9 +992,14 @@ export class Studio { li.append(row); list.append(li); }; - const input = entry.files.filter((f) => f.startsWith("input/")); - if (input.length) { + const input = entry.files.filter((f) => f.startsWith("input/") && !this.handed.has(f)); + if (input.length || this.handed.size) { const list = section("Input"); + for (const f of this.handed.keys()) { + item(list, `${f.slice(6)} (handed in)`, f, { icon: "file", + title: `${f}: handed in by the page that opened the studio. Not saved in this browser, ` + + "not committed and not downloaded -- closing the tab is the end of it." }); + } for (const f of input) item(list, f.slice(6), f, { icon: "file", title: f }); } // What the run wrote, and its parse trees, as the extension's OUTPUT FILES view lists @@ -1005,6 +1080,11 @@ async function main(): Promise { const kept = studio.drafts.available ? "" : "This browser blocks storage, so edits are not kept. "; studio.say(health ? kept.trim() : `${kept}Running needs the run server: see the README.`); + // Opened by a link: open the analyzer it names, and take a text from the opener. + // Signing in leaves the page, so the link is kept across it (never the text). + const link = parseOpenLink(location.search) ?? takeLink(); + if (link) await studio.openLink(link, health?.openers ?? []); + const params = new URLSearchParams(location.search); if (params.has("selftest")) { const result = await selfTest(studio, { run: params.get("selftest") === "run" }); diff --git a/studio/src/run/api.ts b/studio/src/run/api.ts index 552a909..5b46af2 100644 --- a/studio/src/run/api.ts +++ b/studio/src/run/api.ts @@ -70,6 +70,8 @@ export interface ServerHealth { engine: string | null; timeout: number; maxRuns: number; + // The origins allowed to open the studio and hand it a text (src/handoff.ts). + openers?: string[]; } const NO_SERVER = "No run server answered. Start one with: python server/app.py (see the README)."; diff --git a/studio/test/handoff.test.ts b/studio/test/handoff.test.ts new file mode 100644 index 0000000..0073d96 --- /dev/null +++ b/studio/test/handoff.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { INPUT, MAX_TEXT, acceptInput, inputName, keepLink, parseOpenLink, takeLink } from "../src/handoff"; + +describe("parseOpenLink", () => { + it("reads the analyzer a link names", () => { + expect(parseOpenLink("?repo=VisaLinkAI/teg-analyzers&ref=develop&folder=resume-analyzer&handoff=1")) + .toEqual({ repo: "VisaLinkAI/teg-analyzers", ref: "develop", folder: "resume-analyzer", handoff: true }); + expect(parseOpenLink("?repo=a/b")).toEqual({ repo: "a/b", ref: "", folder: "", handoff: false }); + }); + + it("names nothing when the repository is missing or malformed", () => { + expect(parseOpenLink("")).toBeNull(); + expect(parseOpenLink("?repo=notarepo")).toBeNull(); + expect(parseOpenLink("?repo=a/b&folder=../../etc")).toBeNull(); + expect(parseOpenLink("?repo=a/b&ref=bad ref")).toBeNull(); + }); +}); + +describe("keepLink / takeLink", () => { + it("keeps a link across the sign-in round trip, once", () => { + const items = new Map(); + const store = { + setItem: (k: string, v: string) => void items.set(k, v), + getItem: (k: string) => items.get(k) ?? null, + removeItem: (k: string) => void items.delete(k), + }; + keepLink({ repo: "a/b", ref: "main", folder: "x", handoff: true }, store); + expect(takeLink(store)).toEqual({ repo: "a/b", ref: "main", folder: "x", handoff: true }); + expect(takeLink(store)).toBeNull(); + }); +}); + +describe("inputName", () => { + it("keeps a plain name and gives it .txt", () => { + expect(inputName("resume__8f3a2c1e90.txt")).toBe("resume__8f3a2c1e90.txt"); + expect(inputName("case 12")).toBe("case_12.txt"); + }); + + it("can never reach outside input/", () => { + expect(inputName("../../spec/analyzer.seq")).toBe("analyzer.seq.txt"); + expect(inputName("..")).toBe("handed-in.txt"); + expect(inputName(undefined)).toBe("handed-in.txt"); + }); +}); + +describe("acceptInput", () => { + const opener = {}; + const allowed = ["https://admin.example.org"]; + const event = (over: Partial<{ data: unknown; origin: string; source: unknown }> = {}) => ({ + data: { type: INPUT, name: "r.txt", text: "Work Experience\r\nStaff Nurse" }, + origin: "https://admin.example.org", + source: opener, + ...over, + }) as unknown as MessageEvent; + + it("takes a text from the opener at an allowed origin", () => { + expect(acceptInput(event(), opener, allowed)).toEqual({ name: "r.txt", text: "Work Experience\nStaff Nurse" }); + }); + + it("refuses a text from another window, another origin, or no allowed origin at all", () => { + expect(acceptInput(event({ source: {} }), opener, allowed)).toBeNull(); + expect(acceptInput(event({ origin: "https://evil.example" }), opener, allowed)).toBeNull(); + expect(acceptInput(event(), opener, [])).toBeNull(); + expect(acceptInput(event(), null, allowed)).toBeNull(); + }); + + it("refuses what is not a text, and a text too large to be a document", () => { + expect(acceptInput(event({ data: { type: "other", text: "x" } }), opener, allowed)).toBeNull(); + expect(acceptInput(event({ data: { type: INPUT, text: 42 } }), opener, allowed)).toBeNull(); + expect(acceptInput(event({ data: { type: INPUT, text: "x".repeat(MAX_TEXT + 1) } }), opener, allowed)).toBeNull(); + }); +});