Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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
Expand Down
5 changes: 5 additions & 0 deletions studio/deploy/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
11 changes: 9 additions & 2 deletions studio/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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


Expand Down
133 changes: 133 additions & 0 deletions studio/src/handoff.ts
Original file line number Diff line number Diff line change
@@ -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/<name> 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<Storage, "setItem"> | 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<Storage, "getItem" | "removeItem"> | 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<MessageEvent, "data" | "origin" | "source">,
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);
}
90 changes: 85 additions & 5 deletions studio/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -119,6 +120,10 @@ export class Studio {
private readonly matchModels = new Map<string, monaco.editor.ITextModel>();
private readonly outputModels = new Map<string, monaco.editor.ITextModel>();
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<string, string>();
private treeRequest = 0;
private runCount = 0;

Expand Down Expand Up @@ -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/<name> 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 {
Expand Down Expand Up @@ -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<void> {
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<void> {
this.say(`Opening ${item.title} from ${item.repo}…`);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1005,6 +1080,11 @@ async function main(): Promise<void> {
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" });
Expand Down
2 changes: 2 additions & 0 deletions studio/src/run/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).";
Expand Down
Loading
Loading