From d5ad03b0683db23db2dde78396a0f10b41d7c818 Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Sat, 14 Nov 2026 10:57:35 +0530 Subject: [PATCH 01/10] Shared sessions: a notice when the owner is not there, a timed link with End session, and fixes for the language and the terminal of a joining collaborator - A collaborator is told, in a popup and a bar, when the owner left, was never there or the session ended; Run and sharing of their own edits are off, and "Copy it to my own editor" makes a code link of what is on the page. - Share has a time (guest 30 minutes or 1 hour, signed in up to 7 days, admin limits), a countdown, Add time and End session; the link is the same through reloads; when it ends or runs out everything of the session is deleted from Firebase. - A collaborator joining or reloading no longer moves both pages to Assembly or restarts the owner's REPL, and sees what the owner's terminal already holds. - The home page opens in the language chosen last; a stale language record of the session no longer replaces it. - /config.js has no built-in Firebase project any more: without OPENREPL_FIREBASE_CONFIG it says so. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- docs/lld/01-server-and-routing.md | 6 +- docs/lld/06-frontend.md | 2 + e2e/tests/remember-language.spec.js | 51 +++ e2e/tests/shared-language.spec.js | 33 ++ e2e/tests/shared-link-time.spec.js | 107 +++++ e2e/tests/shared-owner-left.spec.js | 73 ++++ e2e/tests/shared-terminal.spec.js | 44 ++ src/js/src/firetty.ts | 7 + src/js/src/page/01-session.js | 20 + src/js/src/page/06-editor.js | 56 ++- src/js/src/page/30-owner-presence.js | 399 +++++++++++++++++++ src/js/src/page/31-share-session.js | 277 +++++++++++++ src/js/src/page/32-remember-language.js | 42 ++ src/js/src/page/index.js | 3 + src/resources/css/ui-refresh.css | 9 + src/resources/index.html | 3 +- src/resources/js/admin.js | 14 +- src/resources/knowledge/sharing-a-session.md | 2 +- src/server/admin_health.go | 4 +- src/server/firebase_test.go | 44 +- src/server/handlers.go | 23 +- src/server/knowledge_test.go | 2 + src/server/settings.go | 77 +++- src/server/settings_sharelink_test.go | 26 ++ src/utils/config.go | 4 +- src/utils/config_test.go | 10 +- 27 files changed, 1248 insertions(+), 94 deletions(-) create mode 100644 e2e/tests/remember-language.spec.js create mode 100644 e2e/tests/shared-language.spec.js create mode 100644 e2e/tests/shared-link-time.spec.js create mode 100644 e2e/tests/shared-owner-left.spec.js create mode 100644 e2e/tests/shared-terminal.spec.js create mode 100644 src/js/src/page/30-owner-presence.js create mode 100644 src/js/src/page/31-share-session.js create mode 100644 src/js/src/page/32-remember-language.js create mode 100644 src/server/settings_sharelink_test.go diff --git a/README.md b/README.md index 01c47e17..4f5091de 100755 --- a/README.md +++ b/README.md @@ -659,7 +659,7 @@ The server-side settings come from environment variables, and gotty can load the | `OPENREPL_LANDLOCK` | The filesystem sandbox of the REPLs (LLD 14): `auto` (the default) applies it where the kernel has Landlock and says in the log and on the admin health page where it does not, `off` switches it off, `require` refuses to start a REPL without it. | | | `OPENREPL_NET_ISOLATION` | The private network of a workspace for REPLs that have no kernel network namespace of their own (LLD 16): `auto` (the default) turns it on where a start-up probe passes and says in the log and on the admin health page where it does not, `off` switches it off, `require` refuses to start a REPL without it. | | | `OPENREPL_TMP_MAX_AGE` | A duration such as `1h`: the server deletes from `/tmp` what nobody has touched for that long. The Docker image sets `1h`. Not set: never. | | -| `OPENREPL_FIREBASE_CONFIG` | The Firebase project the page signs in with, as JSON or base64 of JSON. Default: the built-in production project. See below. | | +| `OPENREPL_FIREBASE_CONFIG` | The Firebase project the page signs in with, as JSON or base64 of JSON. Required for sign-in, shared sessions and the shared editor: there is no built-in project, and a server without it says so in its log and in the console of the page. See below. | | | `OPENREPL_ENV` | `dev` or `production` (the default). Dev shows the values of these settings in the start-up log; production only says which are set. | | The environment wins over the file. A variable that is empty counts as not set, so a half-filled `.env` does not switch the file off. The file is looked for in `/opt/gotty/.gitconfig`, then `~/.gitconfig`, then `/etc/.gitconfig`, and only the first one that exists is read. @@ -726,7 +726,7 @@ Sign-in goes through Firebase, so to test it without touching the production pro - **The snippet exactly as the console shows it** (`const firebaseConfig = { apiKey: "...", ... };`, with or without the first line). Nothing in it is run; only the `key: "value"` pairs are read. - **JSON on one line:** `{"apiKey":"AIza...","authDomain":"my-openrepl-dev.firebaseapp.com","projectId":"my-openrepl-dev","databaseURL":"https://my-openrepl-dev-default-rtdb.firebaseio.com"}`. - - **The base64 of either**, which has no quotes in it. The server's own built-in config is kept in this form, base64 of the snippet, so the same text works here. To make it, paste the snippet into a file and run `base64 < firebase-dev.txt | tr -d '\n'`. + - **The base64 of either**, which has no quotes in it. To make it, paste the snippet into a file and run `base64 < firebase-dev.txt | tr -d '\n'`. ```bash # ~/.env diff --git a/docs/lld/01-server-and-routing.md b/docs/lld/01-server-and-routing.md index 510ddc4f..9a71c9f3 100644 --- a/docs/lld/01-server-and-routing.md +++ b/docs/lld/01-server-and-routing.md @@ -112,7 +112,7 @@ All paths are relative to `pathPrefix`: `/`, or `//` with `--random-url` | `/ws_filebrowser` | GET, POST | `Server.handleFileBrowser` | cookie homedir | File tree, load, save, zip, workspace usage (`?q=usage`) and file ops (LLD 04). Despite the name, this is plain HTTP. | | `/upload_file` | POST (multipart) | `Server.handleFileUpload` | cookie homedir | Upload into the homedir (LLD 04). | | `/auth_token.js` | GET | `handleAuthToken` | public | `var gotty_auth_token = '<--credential>'`. | -| `/config.js` | GET | `handleConfig` | public | `gotty_term`, `firebaseconfig` (the built-in production project, or `OPENREPL_FIREBASE_CONFIG`; see section 5), `openai_access_token` (LLD 07). | +| `/config.js` | GET | `handleConfig` | public | `gotty_term`, `firebaseconfig` (the project `OPENREPL_FIREBASE_CONFIG` names; with none set it is `null` and the page console says so; see section 5), `openai_access_token` (LLD 07). | | `/settings.js` | GET | `handleSettingsJS` | public | `var site_settings = {"colorOfTheDay": }`, sent with `Cache-Control: no-store` (LLD 05, 06). | | `/admin` | GET | `handleAdminPage` behind `wrapAdmin` | admin | The admin dashboard, a static page (`resources/admin.html`) with a restrictive Content-Security-Policy. | | `/admin/...` | GET, POST | `adminAPI` | admin; changes need the `X-Requested-With: openrepl-admin` header | The dashboard's JSON API: settings, parameters, health, usage, audit log, log, feedback, shared code, users and, on a gateway, workers and sessions. Routes and rules in LLD 13. | @@ -149,10 +149,10 @@ Settings (`utils/config.go`). Each is read from the environment first and from t | Cloudflare Workers AI (optional) | `OPENREPL_CLOUDFLARE_API_TOKEN` and `OPENREPL_CLOUDFLARE_ACCOUNT_ID`, both needed (`utils.CloudflareEnabled`) | none | | reCAPTCHA secret key (optional; checks the contact form on the server) | `OPENREPL_RECAPTCHA_SECRET`, as it is (the secret of the site key on the home page) | none | | Allowed host | `OPENREPL_HOST` | `user.host` | -| Firebase web config | `OPENREPL_FIREBASE_CONFIG`: JSON, or base64 of JSON | built in (production project) | +| Firebase web config | `OPENREPL_FIREBASE_CONFIG`: JSON, or base64 of JSON | none: sign-in, shared sessions and the shared editor do not work until it is set | | Mode | `OPENREPL_ENV`: `dev`, `development` or `local`; anything else is production | | -`OPENREPL_FIREBASE_CONFIG` (`utils.FirebaseConfigFromEnv`) replaces the Firebase web config that `/config.js` gives the page, so that a development project can be used. `apiKey`, `authDomain` and `projectId` are required; only the fields of a web app config are kept, and each is checked against the shape it must have (a pattern per field), because the value ends up in a script every visitor runs. `utils.FirebaseConfigJS` writes the object with `encoding/json`. A wrong value is an error that names the field, not its value, and `Options.Validate` returns it, so the server does not start; there is no quiet fall back to the built-in project. The start-up log line shows `firebase: the built-in project`, or `custom project (from env)`, and in dev mode the config itself, or a reminder that the built-in project is the production one. +`OPENREPL_FIREBASE_CONFIG` (`utils.FirebaseConfigFromEnv`) is the Firebase web config that `/config.js` gives the page. There is no built-in project (the production one used to be): a server started without it serves `const firebaseconfig = null;` and a console error, logs `firebase: NOT SET`, and the admin health page fails the "Firebase" line. `apiKey`, `authDomain` and `projectId` are required; only the fields of a web app config are kept, and each is checked against the shape it must have (a pattern per field), because the value ends up in a script every visitor runs. `utils.FirebaseConfigJS` writes the object with `encoding/json`. A wrong value is an error that names the field, not its value, and `Options.Validate` returns it, so the server does not start; there is no quiet fall back to any project. The start-up log line shows `firebase: NOT SET (...)`, or `custom project (from env)`, and in dev mode the config itself. An empty variable counts as not set. `utils.GetGitConfig()` reads the first file that exists among `/opt/gotty/.gitconfig`, `~/.gitconfig` (the home directory is expanded with `os.UserHomeDir`; it was a literal `~/` before) and `/etc/.gitconfig`, and flattens it into `section.key` pairs. A file that does not exist is skipped silently. diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index 44d73ea8..e3c68c19 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -54,6 +54,8 @@ Since T20 the page script is split into feature files under `src/js/src/page/`, | `05-auth.js` | Sign-in (FirebaseUI, `/login`), account menu, `logout` | | `06-editor.js` | Ace set-up, themes, fonts, modes, Ctrl+S, editor sharing over Firebase | | `07-file-browser.js` | The Files panel (jstree), context menu and live file events | +| `30-owner-presence.js` | A shared session whose owner is not there: the owner's page writes `editor_values//owner` `{online, at}` (set on connect, `onDisconnect` writes offline, a heartbeat every 25 s); a collaborator's page watches it and, after 8 s offline or 150 s of silence, shows a popup (left / nobody hosting; an id with no content: ended, and a link to an ended session no longer re-creates it), a bar above the editor, no sharing of its own edits and no Run, and "Copy it to my own editor" (a code link, `POST /snippet`, in a new tab). When the owner is back the page loads again. A missing record (an owner page of an older version) shows nothing. Test: `e2e/tests/shared-owner-left.spec.js`. Designed in the "Shared session: owner left notice" canvas. | +| `31-share-session.js` | Share with a time. Opening the Share popover starts a session (`ShareSession.start`, the time chosen last, 1 hour at first): `{id, endsAt, mins}` is saved in the browser (`localStorage` `openrepl-share`) and written to `editor_values//share` `{endsAt, mins, state}`. `getExampleRef` (`01-session.js`) gives the saved id back after a reload (`savedShareId`), unless another tab holds it (`openrepl-share-tab`, a 5 s heartbeat cleared on `pagehide`), so the link stays the same until it ends. The popover has the time list (guests 30 minutes, 1 hour; signed-in 30 minutes, 1 hour, 4 hours, 24 hours, 7 days; both cut at the admin's "Longest a share link works" settings, `shareLinkGuestMinutes` / `shareLinkUserMinutes`, default 60 and 10080, served as `site_settings.shareMaxGuestMinutes` / `shareMaxUserMinutes`), a countdown, Add time (up to the longest) and End session (asked first). The clock is checked every 15 s with the Firebase server time offset; five minutes before the end the owner is told once. End or expiry (`end`): the record says `ended` or `expired` for the collaborators, the owner's presence is stopped (`OwnerAway.stopHosting`), and after 2.5 s `wipe` removes `editor_values/`, `file-browser/` and the terminal records, and the page loads again (a new link next time). A collaborator's page (`30-owner-presence.js`) shows "The owner ended this session" or "This link has expired" and wipes the same records 15 s later; the owner's page wipes the records of a link that ran out while it was closed when it loads. A link that is over shows no code (`beforeInit`). A link whose data is gone says "This shared session has ended". **Not a lock**: nothing on a server checks the time, and the data stays in Firebase until some page removes it. Test: `e2e/tests/shared-link-time.spec.js`. Designed in the "Shared session: owner left notice, and a link with a time" canvas (boards 5 to 8). | | `08-genie-nudge.js` to `17-share-code.js` | One file per Phase 1 and 2 feature: Genie nudge, hero chips, workspace controls, terminal state, landing, phone layout, accessibility, empty Files note, language pages, share-code links | | `18-genie-review.js`, `lib-diff.mjs` | Review of what Genie wants to put in the editor (below). `lib-diff.mjs` is the pure diff code; node tests in `src/js/test` (`npm test`) | diff --git a/e2e/tests/remember-language.spec.js b/e2e/tests/remember-language.spec.js new file mode 100644 index 00000000..0cfcc7a1 --- /dev/null +++ b/e2e/tests/remember-language.spec.js @@ -0,0 +1,51 @@ +// The language chosen last is chosen again when the home page opens (page/32-remember-language.js), for the picker, the +// editor's mode and the terminal; a share link does not take it (it follows the owner). +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +const state = (page) => page.evaluate(() => ({ picker: $("#optionlist").val(), mode: $("#select-lang").val() })); + +test("a reload opens in the language that was chosen last", async ({ page }) => { + test.setTimeout(120 * 1000); + await page.goto("/"); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await waitForTerminalReady(page); + await page.evaluate(() => window.pickLanguage("python")); + await expect.poll(() => state(page)).toEqual({ picker: "python", mode: "python" }); + await page.reload(); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await expect.poll(() => state(page), { timeout: 20 * 1000 }).toEqual({ picker: "python", mode: "python" }); + // the terminal is that language's too + await expect(page.locator("#terminal-div .xterm-rows").first()).toContainText(">>>", { timeout: 60 * 1000 }); + // another language, another reload + await page.evaluate(() => window.pickLanguage("bash")); + await expect.poll(() => state(page)).toMatchObject({ picker: "bash" }); + await page.reload(); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await expect.poll(() => page.evaluate(() => $("#optionlist").val()), { timeout: 20 * 1000 }).toBe("bash"); +}); + +test("a language left in the session by somebody else does not replace the owner's on a reload, or become the one remembered", async ({ page }) => { + test.setTimeout(150 * 1000); + await page.goto("/"); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready() && window.ShareSession); + await waitForTerminalReady(page); + await page.evaluate(() => window.pickLanguage("python")); + await expect.poll(() => state(page)).toEqual({ picker: "python", mode: "python" }); + // Share starts a session that keeps its address through a reload + await page.locator("#share-btn-1").click(); + await expect(page.locator("#share-session")).toBeVisible(); + const id = await page.evaluate(() => window.dbpath); + // a record of another language in the session (what a collaborator's page used to leave there) + await page.evaluate((i) => firebase.database().ref("editor_values/" + i + "/lang").set({ data: "assembly_x86", silent: true }), id); + await page.waitForTimeout(1500); + await page.reload(); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + expect(await page.evaluate(() => window.dbpath)).toBe(id); // the same session + await page.waitForTimeout(6000); + expect(await state(page)).toEqual({ picker: "python", mode: "python" }); + // what was applied from the session while the page was open was not kept as the language of the visitor + await page.evaluate((i) => firebase.database().ref("editor_values/" + i + "/lang").set({ data: "assembly_x86", silent: true }), id); + await expect.poll(() => state(page), { timeout: 20 * 1000 }).toMatchObject({ picker: "rappel" }); + expect(await page.evaluate(() => localStorage.getItem("openrepl-language"))).toBe("python"); +}); diff --git a/e2e/tests/shared-language.spec.js b/e2e/tests/shared-language.spec.js new file mode 100644 index 00000000..18ed03ef --- /dev/null +++ b/e2e/tests/shared-language.spec.js @@ -0,0 +1,33 @@ +// The language of a shared session: a collaborator who opens the share link takes the owner's language; it must not +// replace it with the first entry of the editor's language list (Assembly) on both pages. +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +const state = (page) => page.evaluate(() => ({ picker: $("#optionlist").val(), mode: $("#select-lang").val() })); + +test("a collaborator joining keeps the owner's language on both pages", async ({ browser }) => { + test.setTimeout(120 * 1000); + const octx = await browser.newContext(); + const owner = await octx.newPage(); + await owner.goto("/"); + await owner.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await waitForTerminalReady(owner); + await owner.evaluate(() => window.pickLanguage("python")); + await expect.poll(() => state(owner)).toEqual({ picker: "python", mode: "python" }); + const url = new URL(await owner.evaluate(() => document.getElementById("share-it").value)); + const vctx = await browser.newContext(); + const viewer = await vctx.newPage(); + await viewer.goto("/" + url.hash); + await viewer.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + expect(await viewer.evaluate(() => isMaster())).toBe(false); + // the collaborator takes the owner's language, and the owner's page is not moved + await expect.poll(() => state(viewer), { timeout: 20 * 1000 }).toEqual({ picker: "python", mode: "python" }); + await viewer.waitForTimeout(6000); + expect(await state(viewer)).toEqual({ picker: "python", mode: "python" }); + expect(await state(owner)).toEqual({ picker: "python", mode: "python" }); + // the owner changes it later: both follow + await owner.evaluate(() => window.pickLanguage("go")); + await expect.poll(() => state(viewer), { timeout: 20 * 1000 }).toMatchObject({ picker: "go" }); + await octx.close(); + await vctx.close(); +}); diff --git a/e2e/tests/shared-link-time.spec.js b/e2e/tests/shared-link-time.spec.js new file mode 100644 index 00000000..558fe102 --- /dev/null +++ b/e2e/tests/shared-link-time.spec.js @@ -0,0 +1,107 @@ +// A share link with a time (page/31-share-session.js): started by opening Share, the same after a reload, ended by the owner or +// by the clock, and what a collaborator sees then. +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +async function owner(browser) { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/"); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready() && window.ShareSession); + await waitForTerminalReady(page); + return { ctx, page }; +} +const link = (page) => page.evaluate(() => document.getElementById("share-it").value); +// the address is put in the box when the terminal opens: wait for it +async function readLink(page) { + await expect.poll(() => link(page), { timeout: 30 * 1000 }).toContain("#"); + return link(page); +} +async function openShare(page) { + await page.locator("#share-btn-1").click(); + await expect(page.locator("#share-session")).toBeVisible(); +} +async function viewer(browser, url, ended) { + const u = new URL(url); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/" + u.hash); + // the files of a link that is over cannot load: only the page's own script is waited for + if (ended) await page.waitForFunction(() => window.OwnerAway && window.OwnerAway.state); + else await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready() && window.OwnerAway); + return { ctx, page }; +} +const dialog = (page) => page.locator("#owner-away"); + +test("the link works for the time chosen and is the same after a reload", async ({ browser }) => { + test.setTimeout(120 * 1000); + const o = await owner(browser); + const before = await readLink(o.page); + await openShare(o.page); + await expect(o.page.locator("#share-left")).toContainText("Ends in"); + expect(await o.page.locator("#share-mins").inputValue()).toBe("60"); + // a guest's choices stop at 1 hour + expect(await o.page.locator("#share-mins option").allTextContents()).toEqual(["30 minutes", "1 hour"]); + await o.page.locator("#share-mins").selectOption("30"); + await expect(o.page.locator("#share-left")).toContainText(/Ends in (29|30) minutes/); + await o.page.locator("#share-add").click(); + await expect(o.page.locator("#share-left")).toContainText(/Ends in (5\d|1 hour [0-9]+) /); + const kept = await readLink(o.page); + expect(kept).toBe(before); + // a reload: the same link, the same time running + await o.page.reload(); + await o.page.waitForFunction(() => window.ShareSession && window.ShareSession.active()); + expect(await readLink(o.page)).toBe(kept); + await openShare(o.page); + await expect(o.page.locator("#share-left")).toContainText("Ends in"); + await o.ctx.close(); +}); + +test("End session tells the collaborators, and a new Share makes a new link", async ({ browser }) => { + test.setTimeout(150 * 1000); + const o = await owner(browser); + await openShare(o.page); + const first = await readLink(o.page); + const v = await viewer(browser, first); + await expect.poll(() => v.page.evaluate(() => window.OwnerAway.state().seenOnline), { timeout: 30 * 1000 }).toBe(true); + await o.page.locator("#share-end").click(); + await o.page.getByRole("button", { name: "End session" }).last().click(); + await expect(dialog(v.page)).toBeVisible({ timeout: 30 * 1000 }); + await expect(dialog(v.page)).toContainText("The owner ended this session"); + await expect(dialog(v.page).getByRole("button", { name: "Copy it to my own editor" })).toBeVisible(); + // the owner's page loads again and gets a link of its own, not the ended one + await expect.poll(async () => (await o.page.evaluate(() => (document.getElementById("share-it") || {}).value || "").catch(() => "")), { timeout: 60 * 1000 }).not.toBe(first); + await o.page.waitForFunction(() => window.ShareSession && window.EditorTabs); + const fresh = await readLink(o.page); + expect(fresh).not.toBe(first); + expect(await o.page.evaluate(() => window.ShareSession.active())).toBe(false); + // somebody who opens the old link later finds it over, with no code + const late = await viewer(browser, first, true); + await expect(dialog(late.page)).toBeVisible({ timeout: 30 * 1000 }); + // nothing of the session is kept, so a late visitor is told that it has ended + await expect(dialog(late.page)).toContainText("This shared session has ended"); + await expect(dialog(late.page).getByRole("button", { name: "Start my own editor" })).toBeVisible(); + // everything of the session is gone from Firebase: no code, no record + const left = await late.page.evaluate(async (id) => { + const snap = await firebase.database().ref("editor_values/" + id).once("value"); + return snap.exists(); + }, new URL(first).hash.slice(1)); + expect(left).toBe(false); + await o.ctx.close(); + await v.ctx.close(); + await late.ctx.close(); +}); + +test("a link whose time ran out expires for the people on it", async ({ browser }) => { + test.setTimeout(150 * 1000); + const o = await owner(browser); + await openShare(o.page); + const v = await viewer(browser, await readLink(o.page)); + await expect.poll(() => v.page.evaluate(() => window.OwnerAway.state().seenOnline), { timeout: 30 * 1000 }).toBe(true); + // the end comes now (the clock of the owner's page is checked every 15 seconds) + await o.page.evaluate(() => { window.ShareSession.record().endsAt = Date.now() - 1000; }); + await expect(dialog(v.page)).toBeVisible({ timeout: 60 * 1000 }); + await expect(dialog(v.page)).toContainText("This link has expired"); + await o.ctx.close(); + await v.ctx.close(); +}); diff --git a/e2e/tests/shared-owner-left.spec.js b/e2e/tests/shared-owner-left.spec.js new file mode 100644 index 00000000..0d65f8ed --- /dev/null +++ b/e2e/tests/shared-owner-left.spec.js @@ -0,0 +1,73 @@ +// A shared session whose owner is not there (page/30-owner-presence.js): the collaborator is told in a popup, a bar stays, +// Run is off, and a session that has ended says so. Two browser contexts: the owner at "/", and one that opens the share link. +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +async function owner(browser) { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/"); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready() && window.OwnerAway); + await waitForTerminalReady(page); + return { ctx, page, link: await page.evaluate(() => document.getElementById("share-it").value) }; +} + +async function viewer(browser, link) { + const url = new URL(link); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/" + url.hash); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready() && window.OwnerAway); + return { ctx, page }; +} + +const dialog = (page) => page.locator("#owner-away"); +const bar = (page) => page.locator("#owner-away-bar"); + +test("a collaborator is told when the owner leaves, and when nobody is there", async ({ browser }) => { + test.setTimeout(180 * 1000); + const o = await owner(browser); + const v = await viewer(browser, o.link); + expect(await v.page.evaluate(() => isMaster())).toBe(false); + // the owner is here: nothing is shown, and the collaborator has seen them online + await expect.poll(() => v.page.evaluate(() => window.OwnerAway.state().seenOnline), { timeout: 30 * 1000 }).toBe(true); + await v.page.waitForTimeout(10 * 1000); + await expect(dialog(v.page)).toBeHidden(); + await expect(bar(v.page)).toHaveCount(0); + + // the owner closes the page + await o.ctx.close(); + await expect(dialog(v.page)).toBeVisible({ timeout: 90 * 1000 }); + await expect(dialog(v.page)).toContainText("The owner has left this session"); + await expect(dialog(v.page).getByRole("button", { name: "Copy it to my own editor" })).toBeVisible(); + await expect(dialog(v.page).getByRole("button", { name: "Stay and wait for them" })).toBeVisible(); + // Esc closes the popup, the bar stays + await v.page.keyboard.press("Escape"); + await expect(dialog(v.page)).toBeHidden(); + await expect(bar(v.page)).toBeVisible(); + await expect(bar(v.page)).toContainText("The owner has left"); + // Run is off while the owner is away + await v.page.evaluate(() => window.CompileandRun()); + await expect(v.page.locator(".notice, #notifications, [role=status]", { hasText: "The owner is not here to run it" }).first()).toBeVisible(); + // a change in the collaborator's page is not shared + expect(await v.page.evaluate(() => window.OwnerAway.active())).toBe(true); + + // a collaborator who opens the link now finds nobody there + const v2 = await viewer(browser, o.link); + await expect(dialog(v2.page)).toBeVisible({ timeout: 60 * 1000 }); + await expect(dialog(v2.page)).toContainText("Nobody is hosting this session"); + await v.ctx.close(); + await v2.ctx.close(); +}); + +test("a link to a session that has ended says so, and does not bring it back", async ({ browser }) => { + test.setTimeout(90 * 1000); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/#-Nosuchsession" + Date.now().toString(36)); + await page.waitForFunction(() => window.OwnerAway && window.OwnerAway.state); + await expect(dialog(page)).toBeVisible({ timeout: 30 * 1000 }); + await expect(dialog(page)).toContainText("This shared session has ended"); + await expect(bar(page)).toHaveCount(0); + await ctx.close(); +}); diff --git a/e2e/tests/shared-terminal.spec.js b/e2e/tests/shared-terminal.spec.js new file mode 100644 index 00000000..c18e4227 --- /dev/null +++ b/e2e/tests/shared-terminal.spec.js @@ -0,0 +1,44 @@ +// A collaborator who joins or reloads sees what the owner's terminal already holds, and does not restart the owner's REPL +// (the page's language is told to the owner, who used to restart for it and lose the terminal). +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +const text = (p) => p.locator("#terminal-div .xterm-rows").first().innerText(); + +test("joining and reloading as a collaborator keeps the owner's terminal", async ({ browser }) => { + test.setTimeout(150 * 1000); + const octx = await browser.newContext(); + const owner = await octx.newPage(); + await owner.goto("/"); + await owner.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await waitForTerminalReady(owner); + await owner.evaluate(() => window.pickLanguage("bash")); + await expect(owner.locator("#terminal-div .xterm-rows").first()).toContainText("#", { timeout: 60 * 1000 }); + await owner.evaluate(() => window.sendToActiveTerminal("echo MARKER-ONE\r")); + await expect.poll(() => text(owner)).toContain("MARKER-ONE"); + const url = new URL(await owner.evaluate(() => document.getElementById("share-it").value)); + + const vctx = await browser.newContext(); + const viewer = await vctx.newPage(); + await viewer.goto("/" + url.hash); + await viewer.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + // what was printed before the collaborator came is shown to them, and the owner's terminal is as it was + await expect.poll(() => text(viewer), { timeout: 30 * 1000 }).toContain("MARKER-ONE"); + await owner.waitForTimeout(5000); + expect(await text(owner)).toContain("MARKER-ONE"); + + await owner.evaluate(() => window.sendToActiveTerminal("echo MARKER-TWO\r")); + await expect.poll(() => text(viewer), { timeout: 30 * 1000 }).toContain("MARKER-TWO"); + + // the collaborator reloads: the same terminal, with everything in it, and the owner's is not restarted + await viewer.reload(); + await viewer.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await expect.poll(() => text(viewer), { timeout: 30 * 1000 }).toContain("MARKER-TWO"); + expect(await text(viewer)).toContain("MARKER-ONE"); + await owner.waitForTimeout(5000); + const mine = await text(owner); + expect(mine).toContain("MARKER-ONE"); + expect(mine).toContain("MARKER-TWO"); + await octx.close(); + await vctx.close(); +}); diff --git a/src/js/src/firetty.ts b/src/js/src/firetty.ts index 028bdbbf..139c8e95 100644 --- a/src/js/src/firetty.ts +++ b/src/js/src/firetty.ts @@ -179,6 +179,13 @@ export class FireTTY { if(optionMenu!==null) { const SelectOption = (optionMenu.getElementsByClassName("list")[0] as HTMLSelectElement); if (SelectOption !== null) { //once + // A page that joins or reloads says which language it has: when that is the one in use already, + // nothing changes. Restarting the REPL for it would end the program and clear the terminal of + // the owner, and a collaborator would find a new, empty one in place of what the owner has. + if (SelectOption.value === d.Data) { + console.log("master is on this language already, not restarting."); + return; + } SelectOption.value = d.Data; let event = new Event('change'); SelectOption.dispatchEvent(event); diff --git a/src/js/src/page/01-session.js b/src/js/src/page/01-session.js index 6a93c921..b9d43e76 100644 --- a/src/js/src/page/01-session.js +++ b/src/js/src/page/01-session.js @@ -34,6 +34,13 @@ window.getExampleRef = () => { window.dbpath = SHARE_ID; return SHARE_ID; } + // a share link that was started and has not ended is the same after a reload (31-share-session.js), unless another tab + // of this browser holds it + var kept = savedShareId(); + if (kept) { + window.dbpath = kept; + return kept; + } ref = ref.push(); if (ref.key) { window.dbpath = ref.key; // save the new reference key for future reference @@ -43,6 +50,19 @@ window.getExampleRef = () => { }; +// The id of the share link this browser started and that has not ended, "" when there is none or another tab holds it now. +function savedShareId() { + try { + var v = JSON.parse(localStorage.getItem("openrepl-share")); + if (!v || !v.id || !(Number(v.endsAt) > Date.now())) return ""; + var tab = JSON.parse(localStorage.getItem("openrepl-share-tab")); + if (tab && tab.at && Date.now() - tab.at < 12000) return ""; + return String(v.id); + } catch (e) { + return ""; + } +} + function handleClickOutside(elementselector, callback) { $(document).on('click', function(event) { var element = $(elementselector); diff --git a/src/js/src/page/06-editor.js b/src/js/src/page/06-editor.js index 1240ac93..bdff4bf6 100644 --- a/src/js/src/page/06-editor.js +++ b/src/js/src/page/06-editor.js @@ -79,8 +79,8 @@ $(function() { let is_silent = (event.detail && event.detail.silent) || false; console.log("is silent change: ", is_silent, event); // Set the language in the Firebase object - // This is a preference per editor - currentEditorValue.update({ + // This is a preference per editor. Not by a collaborator of a session that is over: that would bring back a record of it. + if (!(window.OwnerAway && window.OwnerAway.active())) currentEditorValue.update({ lang: { data: this.value, silent: is_silent // trigger event only when told @@ -125,8 +125,17 @@ $(function() { //all init for editor goes here editorInitialized = true; // Somebody changed the lang. Hey, we have to update it in our editor too! + var firstLang = true; currentEditorValue.child("lang").on("value", function (r) { let langdata = r.val(); + // The owner's page opens in its own language (the one chosen last, 32-remember-language.js) and tells the session + // about it: what the record held when the page opened is from before, and must not replace it. Later changes, + // by a collaborator, do. + if (firstLang) { + firstLang = false; + if (typeof isMaster !== "function" || isMaster()) return; + } + if (!langdata) return; // the record is gone (the session ended) let value = langdata.data; let is_silent = langdata.silent || false; console.log("data recieved from remote: ",langdata); @@ -139,7 +148,13 @@ $(function() { } }); - $selectLang.val(value).trigger(customEvent); + // a language that came from the session is not the visitor's own choice (32-remember-language.js keeps only that) + window.__languageFromSession = true; + try { + $selectLang.val(value).trigger(customEvent); + } finally { + window.__languageFromSession = false; + } } }); @@ -214,6 +229,11 @@ $(function() { if (applyingDeltas) { return; } + // A collaborator's change is not shared while the owner is away (30-owner-presence.js): it stays in the page, + // and cannot replace the owner's code when they come back. + if (window.OwnerAway && window.OwnerAway.active()) { + return; + } // Set the content in the editor object // This is being used for new users, not for already-joined users. @@ -280,8 +300,9 @@ $(function() { if (optionlang==null) { optionlang="c_cpp" } - // Here's where we set the initial content of the editor - editorValues.child(editorId).set({ + // Here's where we set the initial content of the editor. Only the owner starts a session: a link to one + // that has ended must not bring it back. + if (typeof isMaster !== "function" || isMaster()) editorValues.child(editorId).set({ lang: { data: optionlang, silent: true // trigger event only when told @@ -307,7 +328,23 @@ $(function() { if (edRect.top >= 0 && edRect.bottom <= window.innerHeight && !document.body.classList.contains("is-mobile")) { editor.focus(); } - $("#select-lang").trigger('change'); + // The language of the page is told to the session once the editor is ready. A collaborator's list still shows its + // first entry then (Assembly), and telling that would replace the language of the owner on both pages: a + // collaborator takes the language of the session, and tells its own only when the session has none yet. + if (window.OwnerAway && window.OwnerAway.hostSession && (typeof isMaster !== "function" || isMaster())) { + window.OwnerAway.hostSession(db, editorId); // the owner's page says it is here + } + if (typeof isMaster === "function" && !isMaster()) { + currentEditorValue.child("lang").once("value", function (r) { + var shared = r.val(); + if (!shared || shared.data === undefined) $("#select-lang").trigger('change'); + }); + } else { + // the editor's mode follows the language of the page (the picker), not the first entry of its own list + var pageMode = $('#optionlist option:selected').data('editor'); + if (pageMode && $selectLang.val() !== pageMode && $selectLang.find('option[value="' + pageMode + '"]').length) $selectLang.val(pageMode); + $("#select-lang").trigger('change'); + } } }; // end of editor init App @@ -315,7 +352,12 @@ $(function() { currentEditorValue.child("content").once("value", function (contentRef) { // Get the current content var val = contentRef.val(); - initializeEditorApp(val); + if (window.OwnerAway && typeof isMaster === "function" && !isMaster()) { + // a link that has ended or run out shows no code (30-owner-presence.js) + window.OwnerAway.beforeInit(db, editorId, val, function (use) { initializeEditorApp(use); }); + } else { + initializeEditorApp(val); + } }); // initalize the editor App after 10s delay if above fails to setTimeout(initializeEditorApp, 10000); diff --git a/src/js/src/page/30-owner-presence.js b/src/js/src/page/30-owner-presence.js new file mode 100644 index 00000000..2ea857e9 --- /dev/null +++ b/src/js/src/page/30-owner-presence.js @@ -0,0 +1,399 @@ +// A shared session and its owner (docs/lld/11-workspace-sync.md, "The owner is not there"). +// +// Part of the page script. The owner's page says "I am here" in Firebase, next to the code and the language of the session +// (editor_values//owner); a collaborator's page watches it. When the owner has left, or nobody was there when the link +// was opened, or the session has ended, the collaborator is told once in a popup, and a bar stays above the editor. +// Nothing is stored about who the owner is: only whether the page is open, and when it last said so. + +window.OwnerAway = { active: active, hostSession: hostSession, stopHosting: stopHosting, watch: watch, beforeInit: beforeInit, state: function () { return state; } }; + +var HEARTBEAT_MS = 25000; // the owner's page says it is here this often +var GRACE_MS = 8000; // offline this long before a collaborator is told (a reload of the owner's page is not announced) +var STALE_MS = 150000; // a page that is open but has not said so for this long (a hidden tab is slowed down by the browser) +var BACK_RELOAD_MS = 1500; + +var state = { away: false, ended: false, kind: "", seenOnline: false, dismissed: false, blanked: false, mins: 0 }; +var warned = false; + +function active() { return state.away || state.ended; } +function say(text, type) { if (window.notify) window.notify(text, { type: type || "info", timeout: 4000 }); } +function stamp() { return firebase.database.ServerValue.TIMESTAMP; } + +// ---- the owner's page ------------------------------------------------------------------------------------------------- + +var hosting = false; +var hostStopped = false; +var hostRef = null; +var hostTimer = 0; + +// The owner's page stops saying it is here (the session was ended): Firebase is not asked to write "offline" any more, and the +// page does not write it when it goes, so that nothing of the session is left behind. +function stopHosting() { + hostStopped = true; + clearInterval(hostTimer); + if (hostRef) { try { hostRef.onDisconnect().cancel(); } catch (e) { /* not essential */ } } +} + +function hostSession(db, id) { + if (hosting || !id) return; + hosting = true; + var ref = db.ref("editor_values/" + id + "/owner"); + hostRef = ref; + var connected = false; + // when the connection goes (a closed tab, a crash, a lost network), Firebase itself writes "offline" + db.ref(".info/connected").on("value", function (snap) { + connected = snap.val() === true; + if (!connected || hostStopped) return; + ref.onDisconnect().set({ online: false, at: stamp() }).then(function () { + ref.set({ online: true, at: stamp() }); + }).catch(function (e) { console.error("owner presence:", e); }); + }); + hostTimer = setInterval(function () { + if (connected && !hostStopped) ref.update({ online: true, at: stamp() }).catch(function () {}); + }, HEARTBEAT_MS); + window.addEventListener("pagehide", function () { + if (connected && !hostStopped) ref.set({ online: false, at: stamp() }); + }); +} + +// ---- a collaborator's page -------------------------------------------------------------------------------------------- + +var watching = false; +var shareInfo = null; // editor_values//share: {endsAt, mins, state}, written by the owner's page (31-share-session.js) +var graceTimer = 0; +var offsetMs = 0; +var last = null; // the record as it was last read + +// What the link says before the editor is filled: a link that has ended or run out shows no code. cb is told the content to use. +function beforeInit(db, id, content, cb) { + db.ref(".info/serverTimeOffset").once("value", function (o) { + offsetMs = Number(o.val()) || 0; + db.ref("editor_values/" + id + "/share").once("value", function (snap) { + shareInfo = snap.val(); + var over = linkOver(); + if (over) { + state.blanked = true; + setOver(over); + } + watch(db, id, over ? null : content); + cb(over ? "" : content); + }, function () { + watch(db, id, content); + cb(content); + }); + }); +} + +// "" while the link works, else why not: "stopped" (the owner ended it) or "expired" (the time ran out) +function linkOver() { + if (!shareInfo) return ""; + if (shareInfo.state === "ended") return "stopped"; + if (shareInfo.state === "expired") return "expired"; + var at = Number(shareInfo.endsAt) || 0; + if (at && Date.now() + offsetMs >= at) return "expired"; + return ""; +} + +function setOver(kind) { + if (state.ended) return; + state.ended = true; + state.away = false; + state.kind = kind; + state.mins = Number(shareInfo && shareInfo.mins) || 0; + clearTimeout(graceTimer); + graceTimer = 0; + state.dismissed = false; + drawBar(); + showDialog(); + // the owner's page takes the records away when it can; this page does it too a little later, so that nothing of a link that + // is over stays in Firebase because its owner has gone + var wipeId = window.dbpath; + setTimeout(function () { if (window.ShareSession && window.ShareSession.wipe) window.ShareSession.wipe(wipeId); }, 15000); +} + +function watch(db, id, content) { + if (watching || !id) return; + watching = true; + var ref = db.ref("editor_values/" + id + "/owner"); + db.ref(".info/serverTimeOffset").on("value", function (s) { offsetMs = Number(s.val()) || 0; }); + // the end of the link: the owner ended it, or the time runs out + db.ref("editor_values/" + id + "/share").on("value", function (snap) { + shareInfo = snap.val(); + var over = linkOver(); + if (over) setOver(over); + else checkSoon(); + }); + // nothing is kept under this id any more: the session has ended + if (content === null || content === undefined) { + ref.once("value", function (snap) { + if (!snap.exists()) showEnded(); + }); + } + ref.on("value", function (snap) { + last = snap.val(); + evaluate(); + }); + setInterval(evaluate, 15000); + installRunGuard(); +} + +// five minutes before the end, once +function checkSoon() { + if (!shareInfo || warned || state.ended) return; + var ms = (Number(shareInfo.endsAt) || 0) - (Date.now() + offsetMs); + if (ms > 0 && ms <= 5 * 60000) { + warned = true; + say("This session ends in " + Math.ceil(ms / 60000) + " minutes.", "info"); + } +} + +function ownerOnline() { + if (!last || last.online !== true) return false; + var at = Number(last.at) || 0; + return !at || Date.now() + offsetMs - at < STALE_MS; +} + +function evaluate() { + if (state.ended) return; + var over = linkOver(); + if (over) { setOver(over); return; } + checkSoon(); + // an owner page of an older version writes nothing: a missing record says nothing, and nothing is shown + if (last === null) return; + if (ownerOnline()) { + clearTimeout(graceTimer); + graceTimer = 0; + state.seenOnline = true; + if (state.away) back(); + return; + } + if (state.away || graceTimer) return; + graceTimer = setTimeout(function () { + graceTimer = 0; + if (!ownerOnline()) away(); + }, GRACE_MS); +} + +// ---- what the collaborator sees --------------------------------------------------------------------------------------- + +var dialog = null; +var bar = null; + +function away() { + state.away = true; + state.kind = state.seenOnline ? "left" : "nobody"; + state.dismissed = false; + drawBar(); + showDialog(); +} + +function back() { + state.away = false; + state.dismissed = false; + closeDialog(); + drawBar(); + say("The owner is back. Reconnecting…", "success"); + // the page loads again, so the code, the files and the terminal are the owner's, as they are now + setTimeout(function () { location.reload(); }, BACK_RELOAD_MS); +} + +function showEnded() { + if (state.ended) return; + state.ended = true; + state.kind = "ended"; + drawBar(); + showDialog(); +} + +function words() { + if (state.kind === "left") { + return { + title: "The owner has left this session", + body: "You are looking at the code as they left it. Changes you make are not shared any more, and the terminal and the files no longer follow them.", + hint: "If the owner comes back, this notice goes away by itself." + }; + } + if (state.kind === "nobody") { + return { + title: "Nobody is hosting this session", + body: "The owner is not online right now, so you see the last code they shared. You can keep reading it, or copy it to your own editor.", + hint: "The link keeps working: when the owner opens their editor again you are connected." + }; + } + if (state.kind === "stopped") { + return { + title: "The owner ended this session", + body: state.blanked ? "There is nothing to show: the code is not kept after the end. Ask the owner for a new link, or start an editor of your own." : "You see the code as it was when they ended it. Your changes stay in this page and are not shared.", + hint: "" + }; + } + if (state.kind === "expired") { + var mins = state.mins ? "It was set to work for " + (state.mins % 1440 === 0 ? (state.mins === 1440 ? "24 hours" : (state.mins / 1440) + " days") : state.mins < 60 ? state.mins + " minutes" : state.mins === 60 ? "1 hour" : state.mins % 60 === 0 ? (state.mins / 60) + " hours" : state.mins + " minutes") + ". " : ""; + return { + title: "This link has expired", + body: mins + (state.blanked ? "The code is not kept after the end. Ask the owner for a new link, or start an editor of your own." : "Ask the owner for a new link, or copy the code you see to your own editor."), + hint: "" + }; + } + return { + title: "This shared session has ended", + body: "Its code is no longer kept, so there is nothing to show. Ask the owner for a new link, or start an editor of your own.", + hint: "" + }; +} + +function buildDialog() { + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.className = "unsaved-backdrop"; + dialog.id = "owner-away"; + dialog.hidden = true; + dialog.innerHTML = + '
' + + '

' + + '

'; + document.body.appendChild(dialog); + dialog.addEventListener("keydown", function (e) { + if (e.key === "Escape") { e.preventDefault(); dismiss(); } + }); + return dialog; +} + +function dlgButton(label, primary, onClick) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "unsaved-dialog__btn" + (primary ? " is-primary" : ""); + b.textContent = label; + b.addEventListener("click", onClick); + return b; +} + +function showDialog() { + if (state.dismissed) return; + var d = buildDialog(); + var w = words(); + d.querySelector("#owner-away-title").textContent = w.title; + d.querySelector("#owner-away-body").textContent = w.body; + d.querySelector("#owner-away-hint").textContent = w.hint; + var buttons = d.querySelector("#owner-away-buttons"); + buttons.textContent = ""; + var first; + if (state.kind === "ended" || state.blanked) { + first = dlgButton("Start my own editor", true, function () { window.open("/", "_blank", "noopener"); dismiss(); }); + buttons.appendChild(first); + buttons.appendChild(dlgButton("Close", false, dismiss)); + } else if (state.kind === "stopped" || state.kind === "expired") { + first = dlgButton("Copy it to my own editor", true, copyToMyEditor); + buttons.appendChild(first); + buttons.appendChild(dlgButton("Close", false, dismiss)); + } else { + first = dlgButton("Copy it to my own editor", true, copyToMyEditor); + buttons.appendChild(first); + buttons.appendChild(dlgButton("Stay and wait for them", false, dismiss)); + buttons.appendChild(dlgButton("Close", false, dismiss)); + } + var returnFocus = document.activeElement; + d.hidden = false; + d._returnFocus = returnFocus; + first.focus(); +} + +function closeDialog() { + if (!dialog) return; + dialog.hidden = true; +} + +function dismiss() { + state.dismissed = true; + closeDialog(); + var f = dialog && dialog._returnFocus; + if (f && typeof f.focus === "function") { try { f.focus(); } catch (e) { /* the focus is not essential */ } } +} + +// The code and the language that are on the page, in the visitor's own editor and workspace: a code link (the same as +// "Create code link" in the Share popover) opened in a new tab. The owner's workspace and files are not touched. +function copyToMyEditor() { + var ed = document.getElementById("editor"); + var code = ed && ed.env && ed.env.editor ? ed.env.editor.getValue() : ""; + if (!code.trim()) { say("There is no code on the page to copy.", "info"); return; } + // the tab is opened now, by the click, and told where to go when the link is made (a popup blocker would stop a later one) + var tab = window.open("", "_blank"); + fetch("/snippet", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lang: $("#optionlist").val(), code: code }) + }) + .then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); }) + .then(function (res) { + if (!res.ok || !res.body.url) throw new Error(res.body && res.body.error ? res.body.error : "no link"); + if (tab) tab.location.href = res.body.url; else window.open(res.body.url, "_blank"); + dismiss(); + }) + .catch(function () { + if (tab) tab.close(); + say("Could not copy it right now. Try again in a moment.", "error"); + }); +} + +function drawBar() { + if (!active()) { + if (bar) bar.hidden = true; + return; + } + if (state.ended) { if (bar) bar.hidden = true; return; } + if (!bar) { + var ide = document.getElementById("ide"); + var header = ide && ide.querySelector(".editor-header"); + if (!ide || !header) return; + bar = document.createElement("div"); + bar.className = "unsaved-bar"; + bar.id = "owner-away-bar"; + bar.setAttribute("role", "status"); + ide.insertBefore(bar, header); + } + bar.textContent = ""; + var row = document.createElement("div"); + row.className = "unsaved-bar__row"; + var dot = document.createElement("span"); + dot.className = "unsaved-bar__dot"; + dot.setAttribute("aria-hidden", "true"); + var text = document.createElement("span"); + text.className = "unsaved-bar__text"; + var b = document.createElement("strong"); + b.textContent = state.kind === "left" ? "The owner has left. " : "Nobody is hosting this session. "; + text.appendChild(b); + text.appendChild(document.createTextNode("You see the code as they left it; your changes are not shared.")); + row.appendChild(dot); + row.appendChild(text); + row.appendChild(dlgButtonBar("Copy it to my own editor", copyToMyEditor)); + bar.appendChild(row); + bar.hidden = false; +} + +function dlgButtonBar(label, onClick) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "unsaved-bar__btn is-primary"; + b.textContent = label; + b.addEventListener("click", onClick); + return b; +} + +// Run is off for a collaborator while the owner is away: the terminal it would run in is the owner's. +var runGuarded = false; +function installRunGuard() { + if (runGuarded) return; + runGuarded = true; + ["CompileandRun", "RunandDebug"].forEach(function (name) { + var original = window[name]; + if (typeof original !== "function") return; + window[name] = function () { + if (active()) { + say("The owner is not here to run it. Copy it to your own editor to run it yourself.", "info"); + return undefined; + } + return original.apply(this, arguments); + }; + }); +} diff --git a/src/js/src/page/31-share-session.js b/src/js/src/page/31-share-session.js new file mode 100644 index 00000000..f90de8a4 --- /dev/null +++ b/src/js/src/page/31-share-session.js @@ -0,0 +1,277 @@ +// A share link with a time (docs/lld/06-frontend.md, "Share with a time"). +// +// Part of the page script. The owner's Share popover shows how long the link works, a countdown, "Add time" and "End session". +// The link is the id of the page's editor (src/js/src/page/01-session.js, getExampleRef): it is saved in this browser with its +// end, so a reload gives the same link until the end. The end is also written to Firebase beside the code +// (editor_values//share), where a collaborator's page reads it (30-owner-presence.js). +// +// The time is for honest use and for tidying up. Nothing checks it on a server: it is not a lock. + +var KEY = "openrepl-share"; // {id, endsAt, mins} +var MINS_KEY = "openrepl-share-mins"; // the time chosen last +var TAB_KEY = "openrepl-share-tab"; // {tab, at}: the page that holds the saved link says so, so that a second tab does not take it too +var GUEST_CHOICES = [30, 60]; +var USER_CHOICES = [30, 60, 240, 1440, 10080]; +var HEARTBEAT_MS = 5000; +var WARN_MS = 5 * 60000; +var END_PAUSE_MS = 2500; +var tabId = Math.random().toString(36).slice(2); +var offsetMs = 0; +var current = null; // {id, endsAt, mins} of this page's session, when there is one +var warned = false; +var ui = null; + +window.ShareSession = { start: start, active: function () { return !!current; }, end: end, wipe: wipe, record: function () { return current; } }; + +function isGuest() { return String(window.homedir || "").indexOf("guest-") >= 0; } +function settings() { return window.site_settings || {}; } +function maxMinutes() { + var v = Number(isGuest() ? settings().shareMaxGuestMinutes : settings().shareMaxUserMinutes); + return v > 0 ? v : (isGuest() ? 60 : 10080); +} +function choices() { + var base = isGuest() ? GUEST_CHOICES : USER_CHOICES; + var max = maxMinutes(); + var out = base.filter(function (m) { return m <= max; }); + return out.length ? out : [max]; +} +function label(m) { + if (m % 1440 === 0) return m === 1440 ? "24 hours" : (m / 1440) + " days"; + return m < 60 ? m + " minutes" : m === 60 ? "1 hour" : m % 60 === 0 ? (m / 60) + " hours" : m + " minutes"; +} +function now() { return Date.now() + offsetMs; } +function say(text, type) { if (window.notify) window.notify(text, { type: type || "info", timeout: 5000 }); } +function db() { return firebase.database(); } +function shareRef(id) { return db().ref("editor_values/" + id + "/share"); } + +// ---- what is saved in this browser ------------------------------------------------------------------------------------ + +function read() { + try { + var v = JSON.parse(localStorage.getItem(KEY)); + return v && v.id && Number(v.endsAt) ? v : null; + } catch (e) { return null; } +} +function write(v) { + try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch (e) { /* kept in the page only */ } +} +function lastMins() { + try { var m = Number(localStorage.getItem(MINS_KEY)); if (m > 0) return m; } catch (e) { /* the default */ } + return 60; +} +function startMins() { + var list = choices(); + var m = lastMins(); + return list.indexOf(m) >= 0 ? m : list[Math.min(1, list.length - 1)]; +} + +// ---- starting, changing and ending ----------------------------------------------------------------------------------- + +function start(mins) { + var id = window.dbpath; + if (!id || current) return; + try { localStorage.setItem(MINS_KEY, String(mins)); } catch (e) { /* not essential */ } + current = { id: id, endsAt: now() + mins * 60000, mins: mins }; + persist(); + draw(); +} + +// the record, in this browser and in Firebase, for the end that is set now +function persist() { + write(current); + holdLink(); + shareRef(current.id).set({ endsAt: current.endsAt, mins: current.mins, state: "live" }).catch(function (e) { console.error("share:", e); }); +} + +function setMinutes(mins) { + if (!current) return; + current.mins = mins; + current.endsAt = now() + mins * 60000; + warned = false; + try { localStorage.setItem(MINS_KEY, String(mins)); } catch (e) { /* not essential */ } + persist(); + draw(); +} + +function addTime() { + if (!current) return; + var limit = now() + maxMinutes() * 60000; + var next = current.endsAt + current.mins * 60000; + if (next > limit) { + next = limit; + say("That is the longest a link can work here.", "info"); + } + current.endsAt = next; + warned = false; + persist(); + draw(); +} + +// Everything of a session that is kept in Firebase: the code, the language, the queue of changes, the tabs and the terminal +// records, and the end record itself. Whoever sees that a session is over may do this (the owner's page, a collaborator's, and +// the owner's page again when it loads and finds its old link ran out): it does nothing a second time. +function wipe(id) { + if (!id) return Promise.resolve(); + var d = db(); + var jobs = [d.ref("editor_values/" + id).remove(), d.ref("file-browser/" + id).remove()]; + try { if (window.getrepl_firebasedbref) jobs.push(window.getrepl_firebasedbref().child(id).remove()); } catch (e) { /* not essential */ } + return Promise.all(jobs.map(function (p) { return p.catch(function () {}); })); +} + +// state: "ended" (the owner pressed End session) or "expired" (the time ran out). The collaborators are told through the record; +// after a short wait everything of the session is taken out of Firebase, and the page loads again, so that the next Share +// makes a new link. The owner's code is in the page's own drafts. +function end(state) { + if (!current) return; + var id = current.id; + var info = current; + current = null; + write(null); + releaseLink(); + if (window.OwnerAway && window.OwnerAway.stopHosting) window.OwnerAway.stopHosting(); + shareRef(id).update({ state: state, endsAt: Math.min(info.endsAt, now()) }).catch(function () {}); + say(state === "expired" ? "The link ran out of time. Press Share to start a new session." : "Session ended. Press Share to start a new one.", "success"); + draw(); + setTimeout(function () { wipe(id).then(function () { location.reload(); }); }, END_PAUSE_MS); +} + +// ---- a second tab of this browser must not take the same link ------------------------------------------------------ + +var beat = 0; +function holdLink() { + if (beat) return; + var tick = function () { try { localStorage.setItem(TAB_KEY, JSON.stringify({ tab: tabId, at: Date.now() })); } catch (e) { /* not essential */ } }; + tick(); + beat = setInterval(tick, HEARTBEAT_MS); + window.addEventListener("pagehide", releaseLink); +} +function releaseLink() { + if (beat) { clearInterval(beat); beat = 0; } + try { + var v = JSON.parse(localStorage.getItem(TAB_KEY)); + if (v && v.tab === tabId) localStorage.removeItem(TAB_KEY); + } catch (e) { /* not essential */ } +} + +// ---- the popover --------------------------------------------------------------------------------------------------------- + +function buildUI() { + if (ui) return ui; + var host = document.getElementById("share-session"); + if (!host) return null; + host.innerHTML = + '' + + '' + + ''; + ui = { host: host, select: host.querySelector("#share-mins"), left: host.querySelector("#share-left") }; + host.querySelector("#share-add").addEventListener("click", addTime); + host.querySelector("#share-end").addEventListener("click", askEnd); + ui.select.addEventListener("change", function () { setMinutes(Number(ui.select.value)); }); + return ui; +} + +function pad(n) { return n < 10 ? "0" + n : String(n); } +function leftText() { + var ms = current.endsAt - now(); + if (ms <= 0) return "The time is up."; + var mins = Math.ceil(ms / 60000); + var at = new Date(Date.now() + ms); + var text = mins >= 120 ? "Ends in " + Math.round(mins / 60) + " hours" : mins >= 60 ? "Ends in 1 hour " + (mins - 60) + " min" : "Ends in " + mins + (mins === 1 ? " minute" : " minutes"); + return text + " (at " + pad(at.getHours()) + ":" + pad(at.getMinutes()) + ")"; +} + +function draw() { + var u = buildUI(); + if (!u) return; + u.host.hidden = !current; + if (!current) return; + var list = choices(); + if (u.select.options.length !== list.length) { + u.select.textContent = ""; + list.forEach(function (m) { + var o = document.createElement("option"); + o.value = String(m); + o.textContent = label(m); + u.select.appendChild(o); + }); + } + u.select.value = String(current.mins); + if (u.select.value !== String(current.mins)) { + var extra = document.createElement("option"); + extra.value = String(current.mins); + extra.textContent = label(current.mins); + u.select.appendChild(extra); + u.select.value = String(current.mins); + } + u.left.textContent = leftText(); + u.left.classList.toggle("is-soon", current.endsAt - now() <= WARN_MS); +} + +function askEnd() { + var d = document.createElement("div"); + d.className = "unsaved-backdrop"; + d.innerHTML = + '
' + + '

End this session?

'; + d.querySelector("#end-body").textContent = + "The link stops working now. The people on it are told it ended and can copy the code to their own editor. Your editor and your files stay as they are."; + var buttons = d.querySelector(".unsaved-dialog__buttons"); + var close = function () { d.remove(); }; + var keep = document.createElement("button"); + keep.type = "button"; + keep.className = "unsaved-dialog__btn"; + keep.textContent = "Keep it going"; + keep.addEventListener("click", close); + var stop = document.createElement("button"); + stop.type = "button"; + stop.className = "unsaved-dialog__btn is-primary"; + stop.textContent = "End session"; + stop.addEventListener("click", function () { close(); end("ended"); }); + buttons.appendChild(stop); + buttons.appendChild(keep); + d.addEventListener("keydown", function (e) { if (e.key === "Escape") { e.preventDefault(); close(); } }); + document.body.appendChild(d); + keep.focus(); +} + +// ---- the clock ----------------------------------------------------------------------------------------------------------- + +function tick() { + if (!current) return; + var ms = current.endsAt - now(); + if (ms <= 0) { end("expired"); return; } + if (ms <= WARN_MS && !warned) { + warned = true; + say("This session ends in " + Math.ceil(ms / 60000) + " minutes. Open Share and press Add time to keep it going.", "info"); + } + draw(); +} + +$(function () { + if (typeof isMaster === "function" && !isMaster()) return; + firebase.database().ref(".info/serverTimeOffset").on("value", function (s) { offsetMs = Number(s.val()) || 0; }); + // a session that was started before this reload goes on with the same link + var saved = read(); + if (saved && saved.id === window.dbpath && saved.endsAt > Date.now()) { + current = saved; + holdLink(); + } else if (saved && saved.endsAt <= Date.now()) { + // its time ran out while this page was closed: nothing of it stays in Firebase + write(null); + wipe(saved.id); + } + buildUI(); + draw(); + setInterval(tick, 15000); + // opening Share starts the session, with the time chosen last + var dd = document.getElementById("myDropdown"); + if (dd && window.MutationObserver) { + new MutationObserver(function () { + if (dd.classList.contains("show")) { + if (!current) start(startMins()); + else draw(); + } + }).observe(dd, { attributes: true, attributeFilter: ["class"] }); + } +}); diff --git a/src/js/src/page/32-remember-language.js b/src/js/src/page/32-remember-language.js new file mode 100644 index 00000000..08be4d90 --- /dev/null +++ b/src/js/src/page/32-remember-language.js @@ -0,0 +1,42 @@ +// The language of the last visit (page/32-remember-language.js). The page opens in the picker's first choice (C) on "/", so a +// reload put the visitor back in C whatever they had been using. The language chosen last is kept in this browser and chosen +// again when the page opens. A file that is open wins: choosing a file sets the language by its extension, after this. +// +// Only on the home address: a language page (/python) says its own language, a share link follows the owner, and a code link +// (?s=) brings its own. + +var KEY = "openrepl-language"; + +function read() { + try { return localStorage.getItem(KEY) || ""; } catch (e) { return ""; } +} +function write(v) { + try { if (v) localStorage.setItem(KEY, v); } catch (e) { /* not essential */ } +} + +function applies() { + if (location.pathname !== "/" && location.pathname !== "/index.html") return false; + if (typeof isMaster === "function" && !isMaster()) return false; // a collaborator has the owner's language + if (new URLSearchParams(location.search).get("s")) return false; // a code link has its own + return true; +} + +$(function () { + var sel = document.getElementById("optionlist"); + if (!sel || !applies()) return; + var saved = read(); + var option = saved ? Array.prototype.filter.call(sel.options, function (o) { return o.value === saved && !o.disabled && o.getAttribute("data-web") !== "1"; })[0] : null; + if (option && sel.value !== saved) { + sel.value = saved; + sel.dispatchEvent(new Event("change")); + if (typeof window.syncLangChips === "function") window.syncLangChips(); + } + // from now on, what is chosen is what is kept (the HTML and CSS entries are not languages of the terminal) + $(sel).on("change", function () { + if (window.__languageFromSession) return; // the session's language, set by somebody else's change + var chosen = sel.options[sel.selectedIndex]; + if (chosen && chosen.getAttribute("data-web") !== "1") write(sel.value); + }); +}); + +export {}; diff --git a/src/js/src/page/index.js b/src/js/src/page/index.js index 4a1d2973..1ed9737c 100644 --- a/src/js/src/page/index.js +++ b/src/js/src/page/index.js @@ -30,3 +30,6 @@ import "./19-genie-actions"; import "./20-tips"; import "./21-ide-theme"; import "./29-web-language"; // HTML and CSS in the language picker +import "./30-owner-presence"; // a shared session whose owner is not there +import "./31-share-session"; // a share link with a time +import "./32-remember-language"; // the language of the last visit diff --git a/src/resources/css/ui-refresh.css b/src/resources/css/ui-refresh.css index f3f3e7fe..d271945a 100644 --- a/src/resources/css/ui-refresh.css +++ b/src/resources/css/ui-refresh.css @@ -2951,6 +2951,15 @@ body .xterm-helper-textarea:focus-visible { outline: none !important; } } .popover__btn:hover { filter: brightness(1.07); } .popover__btn:disabled { opacity: 0.6; cursor: default; filter: none; } +.popover__btn--danger { color: #f3b4ae; background: transparent; border: 1px solid #6b3434; } +.share-session { margin-top: 12px; } +.share-session[hidden] { display: none; } +.share-session__row { display: flex; align-items: center; gap: 10px; } +.share-session__row label { font-size: 13px; color: var(--muted-1); white-space: nowrap; } +.share-session__select { flex: 1; min-width: 0; height: 34px; padding: 0 10px; font: inherit; font-size: 13.5px; color: var(--paper); background: var(--ink-700); border: 1px solid var(--ink-600); border-radius: 8px; } +.share-session__left { margin: 10px 0 0; font-size: 13px; font-weight: 500; color: var(--accent-color); } +.share-session__left.is-soon { color: #f5c37a; } +.share-session__buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .popover__section .input-group { margin-top: 10px; } .popover__section .input-group[hidden] { display: none; } diff --git a/src/resources/index.html b/src/resources/index.html index 23d6a5db..822f8d94 100644 --- a/src/resources/index.html +++ b/src/resources/index.html @@ -326,6 +326,7 @@

{{.Page.Heading} +

Share code

@@ -762,7 +763,7 @@

Questions, answered

Do I need an account?

No. Start as a guest right away. Guest files are removed an hour after your last visit; sign up free to keep them.

Is it really free?

Yes, you can run code in the browser for free. The source code is open on GitHub. Some extra services may be paid in the future. You can support the project through GitHub Sponsors.

How long does a session last?

Up to 60 minutes per connection. The terminal shows the time left, and one click starts a new session.

-

Can someone code with me?

Yes. Press Share and send the link. They see your terminal and editor live and can type along.

+

Can someone code with me?

Yes. Press Share and send the link. They see your terminal and editor live and can type along. The link works for the time you choose (a guest up to 1 hour, a signed-in user up to 7 days) and is the same after a reload; End session ends it at once and deletes its data. If you close your editor, they are told you have left and can copy the code to their own editor.

Can I use my own files?

Yes. Upload files up to 20 MB, keep up to 50 MB in your workspace, and download it all as a zip.

What can Genie do?

Genie is the AI helper. It reads your editor and terminal, so you can ask why something fails. Its code suggestions show as a diff you accept or reject, with Undo. Open it with Ask Genie or Ctrl+K.

Can Genie do a task for me?

Yes, in Agent mode, for signed-in users. Genie writes code, runs it and reads the output step by step. It asks before it changes your editor, runs your program or types in your terminal, and you can press Stop at any time.

diff --git a/src/resources/js/admin.js b/src/resources/js/admin.js index 544eca2b..53ffc28f 100644 --- a/src/resources/js/admin.js +++ b/src/resources/js/admin.js @@ -1102,6 +1102,8 @@ snapshotIntervalMinutes: int(form.snapEvery.value), previewLinkGuestMinutes: int(form.previewLifeGuest.value), previewLinkUserMinutes: int(form.previewLifeUser.value), + shareLinkGuestMinutes: int(form.shareLifeGuest.value), + shareLinkUserMinutes: int(form.shareLifeUser.value), previewOff: !form.previewOn.checked, previewGuestsOff: !form.previewGuests.checked, genie: { disabled: !form.genie.checked, guestPerMinute: num(form.guest.value), userPerMinute: num(form.user.value), @@ -1162,6 +1164,8 @@ snapshotIntervalMinutes: s.snapshotIntervalMinutes || 0, previewLinkGuestMinutes: s.previewLinkGuestMinutes || 0, previewLinkUserMinutes: s.previewLinkUserMinutes || 0, + shareLinkGuestMinutes: s.shareLinkGuestMinutes || 0, + shareLinkUserMinutes: s.shareLinkUserMinutes || 0, previewOff: !!s.previewOff, previewGuestsOff: !!s.previewGuestsOff, genie: { disabled: !!s.genie.disabled, guestPerMinute: s.genie.guestPerMinute || 0, userPerMinute: s.genie.userPerMinute || 0, @@ -1212,6 +1216,12 @@ form.previewLifeUser = h('input', { type: 'number', class: 'field', id: 'f-preview-life-user', min: '10', max: '1440', step: '1', placeholder: '180', 'aria-label': 'Minutes a preview link works for signed-in users', oninput: update }); form.previewLifeUser.value = settings.previewLinkUserMinutes || ''; form.previewLifeUser.style.maxWidth = '9rem'; + form.shareLifeGuest = h('input', { type: 'number', class: 'field', id: 'f-share-life-guest', min: '10', max: '10080', step: '1', placeholder: '60', 'aria-label': 'Most minutes a share link works for guests', oninput: update }); + form.shareLifeGuest.value = settings.shareLinkGuestMinutes || ''; + form.shareLifeGuest.style.maxWidth = '9rem'; + form.shareLifeUser = h('input', { type: 'number', class: 'field', id: 'f-share-life-user', min: '10', max: '10080', step: '1', placeholder: '10080', 'aria-label': 'Most minutes a share link works for signed-in users', oninput: update }); + form.shareLifeUser.value = settings.shareLinkUserMinutes || ''; + form.shareLifeUser.style.maxWidth = '9rem'; form.colour = colour.input; form.maint = maint.input; form.genie = genie.input; // the Preview pane (pages written by visitors, shown on this site's domain): off for all, or for guests only var previewOn = toggle('f-preview', !settings.previewOff, 'Preview available'); @@ -1621,7 +1631,9 @@ row('Preview is available', null, previewOn.node), row('Preview for guests', 'Visitors who are not signed in. Off keeps it for signed-in users only.', previewGuests.node), row('Minutes a preview link works for guests', 'From 10 to 1440 (a day). Leave it empty for 60 (1 hour). A pane gets a new link by itself before the old one ends; a link opened in a tab of its own, or copied, stops at its end. A change applies to links made after it.', form.previewLifeGuest), - row('Minutes a preview link works for signed-in users', 'From 10 to 1440. Leave it empty for 180 (3 hours).', form.previewLifeUser) ]), + row('Minutes a preview link works for signed-in users', 'From 10 to 1440. Leave it empty for 180 (3 hours).', form.previewLifeUser), + row('Longest a share link works for guests (minutes)', 'From 10 to 10080 (7 days). Leave it empty for 60 (1 hour). The owner chooses the time in the Share popover and may add time up to this: guests 30 minutes or 1 hour; signed-in users also 4 hours, 24 hours and 7 days, as far as this allows.', form.shareLifeGuest), + row('Longest a share link works for signed-in users (minutes)', 'From 10 to 10080 (7 days, as long as a sign-in lasts). Leave it empty for 10080.', form.shareLifeUser) ]), card('Languages', 'Untick a language that is broken. The picker greys it out and its terminals refuse to start with a message; terminals that are already open keep running. Admins can still start it, to test a fix.', langGrid), card('Genie', 'The AI helper. When it is off, its buttons are hidden and the server refuses its requests, so no OpenAI calls are made for visitors.', [ row('Genie is available', null, genie.node), diff --git a/src/resources/knowledge/sharing-a-session.md b/src/resources/knowledge/sharing-a-session.md index 783b7ace..c57474c0 100644 --- a/src/resources/knowledge/sharing-a-session.md +++ b/src/resources/knowledge/sharing-a-session.md @@ -3,4 +3,4 @@ title: Sharing a live session keywords: share, sharing, collaborate, collaboration, pair, pairing, live link, teammate, friend, colleague, invite, send, together, peerchat, chat, repl id link: /about.html --- -Press Share and send the link. Anyone who opens it sees your terminal and editor live and can type along, and there is a chat for talking while you work. A joined guest is announced in your session. Use this to pair on a problem, help a friend, or teach. The link follows the session, so it shows what you are doing now, not a snapshot. +Press Share and send the link. Anyone who opens it sees your terminal and editor live and can type along, and there is a chat for talking while you work. A joined guest is announced in your session. Use this to pair on a problem, help a friend, or teach. The link follows the session, so it shows what you are doing now, not a snapshot. Opening Share starts a timed link: a guest chooses 30 minutes or 1 hour, a signed-in user up to 7 days. The link stays the same through reloads until it ends, with a countdown, Add time and End session. When it ends or runs out, everyone on it is told and all its data is deleted. If you close your editor, the people on your link are told you have left, can read the code and copy it to their own editor, and are connected again when you return. diff --git a/src/server/admin_health.go b/src/server/admin_health.go index 1d4600b7..742f6abf 100644 --- a/src/server/admin_health.go +++ b/src/server/admin_health.go @@ -242,10 +242,8 @@ func (server *Server) health() (status string, checks []healthCheck) { } if project, custom := firebaseProject(); custom { add("Firebase", healthOK, "signing in with the project "+project+" (from OPENREPL_FIREBASE_CONFIG)") - } else if utils.IsDev() { - add("Firebase", healthWarn, "this dev server uses the built-in project, which is the production one ("+project+")") } else { - add("Firebase", healthOK, "signing in with the built-in project "+project) + add("Firebase", healthFail, "no Firebase config: sign-in, shared sessions and the shared editor do not work. Set OPENREPL_FIREBASE_CONFIG") } switch st := currentSettingsStoreStatus(); { case st.Healthy: diff --git a/src/server/firebase_test.go b/src/server/firebase_test.go index 24157a21..85dba543 100644 --- a/src/server/firebase_test.go +++ b/src/server/firebase_test.go @@ -1,7 +1,6 @@ package server import ( - "encoding/base64" "encoding/json" "os" "strings" @@ -27,30 +26,22 @@ func firebaseEnv(t *testing.T, value string) { }) } -// The built-in config is the production project and has to stay exactly what -// it was: it is base64 in the source, which makes a typo easy to miss. -func TestBuiltInFirebaseConfigIsTheProductionProject(t *testing.T) { +// There is no built-in project: a server started without OPENREPL_FIREBASE_CONFIG serves no config of any project, and says so in +// the console of the page, instead of quietly using the production one. +func TestNoFirebaseConfigIsServedWithoutTheEnvironment(t *testing.T) { firebaseEnv(t, "") js := string(firebaseConfigJS()) - if _, err := base64.StdEncoding.DecodeString(builtinFirebaseConfig); err != nil { - t.Fatalf("the built-in config is not valid base64: %v", err) + if !strings.Contains(js, "const firebaseconfig = null;") || !strings.Contains(js, "OPENREPL_FIREBASE_CONFIG") { + t.Errorf("config.js without a Firebase config:\n%s", js) } - for _, want := range []string{ - "const firebaseconfig = {", - `authDomain: "openrepl-app.firebaseapp.com"`, - `projectId: "openrepl-app"`, - `databaseURL: "https://openrepl-app-default-rtdb.firebaseio.com"`, - } { - if !strings.Contains(js, want) { - t.Errorf("the built-in config lacks %s:\n%s", want, js) + for _, leak := range []string{"openrepl-app", "AIza", "apiKey", "firebaseio.com"} { + if strings.Contains(js, leak) { + t.Errorf("a project is still built in: %q in\n%s", leak, js) } } - if !strings.Contains(js, `apiKey: "AIza`) { - t.Error("the built-in config has no api key") - } } -func TestFirebaseConfigFromTheEnvironmentReplacesTheBuiltInOne(t *testing.T) { +func TestFirebaseConfigFromTheEnvironmentIsServed(t *testing.T) { firebaseEnv(t, `{"apiKey":"AIzaSyDevKey1234567890","authDomain":"my-dev.firebaseapp.com","projectId":"my-dev-project"}`) js := string(firebaseConfigJS()) if strings.Contains(js, "openrepl-app") { @@ -79,20 +70,3 @@ func TestAWrongFirebaseConfigStopsTheServerFromStarting(t *testing.T) { t.Fatalf("no config: %v", err) } } - -// The built-in config is base64 of a snippet. Giving that very text to -// OPENREPL_FIREBASE_CONFIG has to work, and has to give the same project. -func TestTheBuiltInConfigIsAcceptedInTheEnvironment(t *testing.T) { - firebaseEnv(t, builtinFirebaseConfig) - cfg, set, err := utils.FirebaseConfigFromEnv() - if err != nil || !set { - t.Fatalf("%v %v", set, err) - } - if cfg["projectId"] != "openrepl-app" || cfg["authDomain"] != "openrepl-app.firebaseapp.com" || - cfg["databaseURL"] != "https://openrepl-app-default-rtdb.firebaseio.com" || !strings.HasPrefix(cfg["apiKey"], "AIza") { - t.Fatalf("%v", cfg) - } - if js := string(firebaseConfigJS()); !strings.Contains(js, `"projectId": "openrepl-app"`) { - t.Fatalf("%s", js) - } -} diff --git a/src/server/handlers.go b/src/server/handlers.go index 5d18dbb2..99dad60b 100755 --- a/src/server/handlers.go +++ b/src/server/handlers.go @@ -473,25 +473,18 @@ func (server *Server) handleConfig(w http.ResponseWriter, r *http.Request) { } } -// builtinFirebaseConfig is the production Firebase project's web config, used -// unless OPENREPL_FIREBASE_CONFIG names another one. -const builtinFirebaseConfig = "CmNvbnN0IGZpcmViYXNlY29uZmlnID0gewogIGFwaUtleTogIkFJemFTeUFTZ0FhUnY2eVhV" + - "SlFWY0hhQV9sUkZWTXk5QVlaZVJscyIsCiAgYXV0aERvbWFpbjogIm9wZW5yZXBsLWFwcC5m" + - "aXJlYmFzZWFwcC5jb20iLAogIHByb2plY3RJZDogIm9wZW5yZXBsLWFwcCIsCiAgZGF0YWJh" + - "c2VVUkw6ICJodHRwczovL29wZW5yZXBsLWFwcC1kZWZhdWx0LXJ0ZGIuZmlyZWJhc2Vpby5j" + - "b20iCn07Cgo=" - -// firebaseConfigJS is the part of config.js that defines `firebaseconfig`. +// noFirebaseConfig is what /config.js says when the server has no Firebase config: nothing of any project, and a line in the +// console of the page that says what to do. The pages that sign in or share a session cannot work without one. +const noFirebaseConfig = "\nconst firebaseconfig = null;\nconsole.error(\"OpenREPL: this server has no Firebase config. Set OPENREPL_FIREBASE_CONFIG (README, Environment).\");\n\n" + +// firebaseConfigJS is the part of config.js that defines `firebaseconfig`: the one OPENREPL_FIREBASE_CONFIG names. There is no +// built-in project: a server started without one must not quietly use somebody else's (the production project's), so what it +// serves says plainly that there is none. func firebaseConfigJS() []byte { if js, ok := utils.FirebaseConfigJS(); ok { return js } - decoded, err := base64.StdEncoding.DecodeString(builtinFirebaseConfig) - if err != nil { - log.Println("Error: decoding the built-in firebase config: ", err) - return nil - } - return decoded + return []byte(noFirebaseConfig) } // serverTitleVariables returns the title variables for one connection. The diff --git a/src/server/knowledge_test.go b/src/server/knowledge_test.go index 183b6e49..1dfa6d98 100644 --- a/src/server/knowledge_test.go +++ b/src/server/knowledge_test.go @@ -122,6 +122,8 @@ func TestSearchFindsTheRightNoteForQuestionsAboutTheSite(t *testing.T) { cases := []struct{ q, want string }{ {"How can I send my code to a teammate?", "sharing-a-session"}, {"how do I share my session with a friend", "sharing-a-session"}, + {"what happens to people on my share link when I close my editor", "sharing-a-session"}, + {"how long does my share link work and can I end the session", "sharing-a-session"}, {"can someone type along in my terminal", "sharing-a-session"}, {"how do I make a link to my code", "code-links"}, {"how do I fork a REPL", "fork-a-repl"}, diff --git a/src/server/settings.go b/src/server/settings.go index f45940a9..e487eaa1 100644 --- a/src/server/settings.go +++ b/src/server/settings.go @@ -74,6 +74,11 @@ type SiteSettings struct { // for a new one before it ends; a link opened in a tab of its own ends with it. PreviewLinkGuestMinutes int `json:"previewLinkGuestMinutes"` PreviewLinkUserMinutes int `json:"previewLinkUserMinutes"` + // ShareLinkGuestMinutes and ShareLinkUserMinutes are the longest a share link of a live session may work (the time chosen + // in the Share popover, with the time added), in minutes, from 10 to 10080 (7 days); 0 means the built-in 60 (guest: 1 + // hour) and 10080 (signed in: 7 days, as long as a sign-in lasts). + ShareLinkGuestMinutes int `json:"shareLinkGuestMinutes"` + ShareLinkUserMinutes int `json:"shareLinkUserMinutes"` // PreviewOff switches the Preview pane off for everyone, and PreviewGuestsOff for visitors who are not signed in: // the pane shows pages written by visitors on this site's domain, so abuse can be stopped here without a deploy. PreviewOff bool `json:"previewOff"` @@ -205,6 +210,11 @@ func (s SiteSettings) normalize() (SiteSettings, error) { return s, fmt.Errorf("the time a preview link works for %s must be between %d and %d minutes (or empty)", name, minPreviewLinkMinutes, maxPreviewLinkMinutes) } } + for name, m := range map[string]int{"guests": s.ShareLinkGuestMinutes, "signed-in users": s.ShareLinkUserMinutes} { + if m != 0 && (m < minShareLinkMinutes || m > maxShareLinkMinutes) { + return s, fmt.Errorf("the longest a share link works for %s must be between %d and %d minutes (or empty)", name, minShareLinkMinutes, maxShareLinkMinutes) + } + } if s.DBCapacityMB != 0 && (s.DBCapacityMB < minDBCapacityMB || s.DBCapacityMB > maxDBCapacityMB) { return s, fmt.Errorf("the database capacity must be between %d and %d MB (or empty)", minDBCapacityMB, maxDBCapacityMB) } @@ -448,6 +458,9 @@ type publicSettings struct { PreviewGuestsOff bool `json:"previewGuestsOff"` // the largest file the editor opens, in MB (the effective number: 16 when none is set) MaxEditorFileMB int `json:"maxEditorFileMB"` + // ShareMaxGuestMinutes and ShareMaxUserMinutes are the longest a share link works (SiteSettings.ShareLinkGuestMinutes) + ShareMaxGuestMinutes int `json:"shareMaxGuestMinutes"` + ShareMaxUserMinutes int `json:"shareMaxUserMinutes"` // the models that are switched off, and the one visitors start with (empty: // the built-in choice); model-choice.js reads them DisabledModels []string `json:"disabledModels"` @@ -612,6 +625,26 @@ const ( defaultPreviewLinkSignedIn = 180 // a signed-in user: 3 hours ) +// the longest a share link works, in minutes: the least and the most the admin may set, and what it is when nothing is set +const ( + minShareLinkMinutes = 10 + maxShareLinkMinutes = 10080 + defaultShareLinkGuest = 60 // a guest: 1 hour + defaultShareLinkSignedIn = 10080 // a signed-in user: 7 days, as long as a sign-in lasts +) + +// shareLinkMax is the longest a share link works in force for a guest or a signed-in user: the one set, or the built-in time. +func (s SiteSettings) shareLinkMax(guest bool) int { + m, def := s.ShareLinkUserMinutes, defaultShareLinkSignedIn + if guest { + m, def = s.ShareLinkGuestMinutes, defaultShareLinkGuest + } + if m < minShareLinkMinutes || m > maxShareLinkMinutes { + m = def + } + return m +} + // previewLinkLife is how long a preview link works in force for a guest or a signed-in user: the one set, or the built-in time. func (s SiteSettings) previewLinkLife(guest bool) time.Duration { m, def := s.PreviewLinkUserMinutes, defaultPreviewLinkSignedIn @@ -642,24 +675,26 @@ func (s SiteSettings) public() publicSettings { models = []string{} } return publicSettings{ - ColorOfTheDay: s.ColorOfTheDay, - Announcement: s.Announcement, - Maintenance: s.Maintenance, - DisabledLanguages: langs, - GenieDisabled: s.Genie.Disabled, - PreviewOff: s.PreviewOff, - PreviewGuestsOff: s.PreviewGuestsOff, - MaxEditorFileMB: s.maxEditorFileMB(), - DisabledModels: models, - CustomModels: s.Genie.publicCustomModels(), - DefaultModel: s.Genie.DefaultModel, - FeaturedModels: append([]string{}, s.Genie.FeaturedModels...), - GenieContext: s.Genie.context(), - AgentEnabled: !s.Genie.AgentDisabled && !s.Genie.Disabled, - AgentAutoOff: s.Genie.AgentAutoOff, - AgentRiskyOff: s.Genie.AgentRiskyOff, - AgentOnPractice: s.Genie.AgentOnPractice, - AgentMaxSteps: s.Genie.agentMaxSteps(), + ColorOfTheDay: s.ColorOfTheDay, + Announcement: s.Announcement, + Maintenance: s.Maintenance, + DisabledLanguages: langs, + GenieDisabled: s.Genie.Disabled, + PreviewOff: s.PreviewOff, + PreviewGuestsOff: s.PreviewGuestsOff, + MaxEditorFileMB: s.maxEditorFileMB(), + ShareMaxGuestMinutes: s.shareLinkMax(true), + ShareMaxUserMinutes: s.shareLinkMax(false), + DisabledModels: models, + CustomModels: s.Genie.publicCustomModels(), + DefaultModel: s.Genie.DefaultModel, + FeaturedModels: append([]string{}, s.Genie.FeaturedModels...), + GenieContext: s.Genie.context(), + AgentEnabled: !s.Genie.AgentDisabled && !s.Genie.Disabled, + AgentAutoOff: s.Genie.AgentAutoOff, + AgentRiskyOff: s.Genie.AgentRiskyOff, + AgentOnPractice: s.Genie.AgentOnPractice, + AgentMaxSteps: s.Genie.agentMaxSteps(), } } @@ -875,6 +910,12 @@ func settingsChanges(a, b SiteSettings) []string { if a.previewLinkLife(false) != b.previewLinkLife(false) { out = append(out, fmt.Sprintf("preview link works %d minutes for signed-in users", int(b.previewLinkLife(false).Minutes()))) } + if a.shareLinkMax(true) != b.shareLinkMax(true) { + out = append(out, fmt.Sprintf("share link works at most %d minutes for guests", b.shareLinkMax(true))) + } + if a.shareLinkMax(false) != b.shareLinkMax(false) { + out = append(out, fmt.Sprintf("share link works at most %d minutes for signed-in users", b.shareLinkMax(false))) + } if a.maxEditorFileMB() != b.maxEditorFileMB() { out = append(out, fmt.Sprintf("largest editor file %d MB", b.maxEditorFileMB())) } diff --git a/src/server/settings_sharelink_test.go b/src/server/settings_sharelink_test.go new file mode 100644 index 00000000..90692ee3 --- /dev/null +++ b/src/server/settings_sharelink_test.go @@ -0,0 +1,26 @@ +package server + +import "testing" + +func TestShareLinkLimits(t *testing.T) { + var s SiteSettings + if s.shareLinkMax(true) != 60 || s.shareLinkMax(false) != 10080 { + t.Errorf("defaults: %d %d", s.shareLinkMax(true), s.shareLinkMax(false)) + } + s.ShareLinkGuestMinutes, s.ShareLinkUserMinutes = 30, 720 + if s.shareLinkMax(true) != 30 || s.shareLinkMax(false) != 720 { + t.Errorf("set: %d %d", s.shareLinkMax(true), s.shareLinkMax(false)) + } + pub := s.public() + if pub.ShareMaxGuestMinutes != 30 || pub.ShareMaxUserMinutes != 720 { + t.Errorf("public: %d %d", pub.ShareMaxGuestMinutes, pub.ShareMaxUserMinutes) + } + for _, bad := range []int{5, 10081, -3} { + if _, err := (SiteSettings{ShareLinkGuestMinutes: bad}).normalize(); err == nil { + t.Errorf("%d minutes was accepted", bad) + } + } + if _, err := (SiteSettings{ShareLinkUserMinutes: 10080}).normalize(); err != nil { + t.Errorf("7 days was refused: %v", err) + } +} diff --git a/src/utils/config.go b/src/utils/config.go index e606d52f..dab8f434 100644 --- a/src/utils/config.go +++ b/src/utils/config.go @@ -803,10 +803,8 @@ func configSummary(dev bool) string { switch fb, set, err := FirebaseConfigFromEnv(); { case err != nil: fmt.Fprintf(&b, "; firebase: INVALID (%s)", err.Error()) - case !set && dev: - b.WriteString("; firebase: the built-in project, which is the production one (set " + EnvFirebaseConfig + " to use a development project)") case !set: - b.WriteString("; firebase: the built-in project") + b.WriteString("; firebase: NOT SET (sign-in, shared sessions and the shared editor do not work: set " + EnvFirebaseConfig + ")") case dev: obj, _ := json.Marshal(fb) fmt.Fprintf(&b, "; firebase: %s (from env)", obj) diff --git a/src/utils/config_test.go b/src/utils/config_test.go index 454521ab..f2dc4738 100644 --- a/src/utils/config_test.go +++ b/src/utils/config_test.go @@ -466,11 +466,11 @@ func TestFirebaseConfigAcceptsAnEmulatorStyleDatabaseURL(t *testing.T) { func TestSummaryFirebase(t *testing.T) { settings(t, nil, nil) - if got := configSummary(false); !strings.Contains(got, "firebase: the built-in project") || strings.Contains(got, "production one") { - t.Errorf("production, unset: %s", got) - } - if got := configSummary(true); !strings.Contains(got, "the production one") || !strings.Contains(got, EnvFirebaseConfig) { - t.Errorf("dev, unset should say that this is the production project: %s", got) + // no built-in project: an unset config is said loudly, in production and in dev + for _, dev := range []bool{false, true} { + if got := configSummary(dev); !strings.Contains(got, "firebase: NOT SET") || !strings.Contains(got, EnvFirebaseConfig) || strings.Contains(got, "built-in") { + t.Errorf("dev=%v, unset: %s", dev, got) + } } settings(t, map[string]string{EnvFirebaseConfig: devFirebase}, nil) From 565766fb9bd2ea1c8406ca0a0e645590ad8fadca Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Sat, 14 Nov 2026 11:56:52 +0530 Subject: [PATCH 02/10] Lock a collaborator's page when a session is over, and clean up left-over sessions in Firebase from the server - When a shared session ends or expires, a collaborator's page can only read: the editor is read-only, every Firebase write from the page is dropped (except the cleanup), the terminal and peer chat send nothing, the file browser and uploads are refused. The cleanup also removes chat-list/. - A server job removes, every so many hours (admin setting, default 24), the sessions whose link ended or ran out and the sessions left with no owner, from openrepl, editor_values, chat-list and file-browser. It signs in to the Realtime Database as the service account of OPENREPL_FIREBASE_ADMIN (or OPENREPL_FIRESTORE_CREDENTIALS); the Health page shows its last run. - .env.example and README: OPENREPL_FIREBASE_ADMIN, and no built-in Firebase project any more. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 25 +- README.md | 1 + docs/lld/06-frontend.md | 2 +- e2e/tests/shared-link-time.spec.js | 28 ++- src/js/src/firetty.ts | 2 + src/js/src/page/06-editor.js | 2 + src/js/src/page/30-owner-presence.js | 60 ++++- src/js/src/page/31-share-session.js | 12 +- src/persist/rtdb_token.go | 92 +++++++ src/persist/rtdb_token_test.go | 64 +++++ src/resources/chat-widget/src/index.ts | 8 + src/resources/css/ui-refresh.css | 1 + src/resources/js/admin.js | 8 +- src/server/admin_health.go | 4 + src/server/server.go | 1 + src/server/session_cleanup.go | 332 +++++++++++++++++++++++++ src/server/session_cleanup_test.go | 187 ++++++++++++++ src/server/settings.go | 25 ++ 18 files changed, 838 insertions(+), 16 deletions(-) create mode 100644 src/persist/rtdb_token.go create mode 100644 src/persist/rtdb_token_test.go create mode 100644 src/server/session_cleanup.go create mode 100644 src/server/session_cleanup_test.go diff --git a/.env.example b/.env.example index 46a5703a..397a93f5 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,18 @@ OPENREPL_MONGODB_DB=openrepl # worker never uses this. OPENREPL_FIRESTORE_CREDENTIALS= +# Optional. The service account key (the same kind of key: JSON, its base64, or the +# path of the file) that the server signs in to the Firebase Realtime Database +# with, for the cleanup of shared sessions: every so many hours (Site settings, +# default 24) it removes the sessions that ended, ran out or were left behind, from +# openrepl, editor_values, chat-list and file-browser. The Firebase Admin SDK key of +# the project (Project settings, Service accounts, Generate new private key) works +# for both this and OPENREPL_FIRESTORE_CREDENTIALS, so the same key can be given to +# both; when this one is empty the server uses OPENREPL_FIRESTORE_CREDENTIALS. With +# neither the cleanup does not run (the admin Health page says so) and ended +# sessions are removed only by the pages that see them. A worker never uses this. +OPENREPL_FIREBASE_ADMIN= + # Optional. A long random secret for the server's own use. It signs the session # cookies (through a key derived from it) and encrypts the API keys an admin # saves in the dashboard (Site settings; a key saved there wins over the ones @@ -135,10 +147,12 @@ OPENREPL_HOST=localhost # Delete from /tmp what nobody has touched for this long (the Docker image sets 1h). #OPENREPL_TMP_MAX_AGE=1h -# The Firebase project the page signs in with. Leave it out to use the built-in -# (production) project. To test sign-in with a development project of your own, -# give its web app config (Firebase console > Project settings > Your apps) in -# any of these forms. apiKey, authDomain and projectId are required: +# The Firebase project the page signs in with and shares sessions through. There is +# no built-in project: without this the server says so in its log and in the console of +# the page, and sign-in and shared sessions do not work. Give the web app config +# (Firebase console > Project settings > Your apps) of your project, or of a +# development project of your own, in any of these forms. apiKey, authDomain and +# projectId are required: # # - JSON on one line (the single quotes are for gotty's own reading; leave them # out for `docker run --env-file`): @@ -147,8 +161,7 @@ OPENREPL_HOST=localhost # - the snippet the console shows, on one line, in quotes: # OPENREPL_FIREBASE_CONFIG='const firebaseConfig = { apiKey: "AIza...", authDomain: "my-dev.firebaseapp.com", projectId: "my-dev" };' # -# - base64 of either, which has no quotes. This is also the form of the -# server's built-in config. Make it with: base64 < firebase-dev.txt | tr -d '\n' +# - base64 of either, which has no quotes. Make it with: base64 < firebase-dev.txt | tr -d '\n' # OPENREPL_FIREBASE_CONFIG=eyJhcGlLZXkiOiJBSXphLi4uIiwiYXV0aERvbWFpbiI6Ii4uLiIsInByb2plY3RJZCI6Ii4uLiJ9 # # A wrong value stops the server at start-up and says which field is wrong. diff --git a/README.md b/README.md index 4f5091de..f1d0fcbd 100755 --- a/README.md +++ b/README.md @@ -646,6 +646,7 @@ The server-side settings come from environment variables, and gotty can load the | `OPENREPL_ADMIN_EMAILS` | Owner accounts, comma-separated. Owners are admins and the only ones who can add or remove other admins in the dashboard. With none set, nobody is an admin. | `user.email` | | `OPENREPL_OPENAI_API_KEY` | The OpenAI key, as it is (not base64). | `user.OpenaiAPIKey` (base64) | | `OPENREPL_MONGODB_URI` | Optional. Keeps the admin settings and every database (sessions, feedback, blog, snippets, practice) in MongoDB (for example an Atlas `mongodb+srv://` URI) instead of files under `/opt/gotty`, so they survive deploys that wipe the disk. The first start copies the existing files in, once. A secret. Gateway and standalone only; a worker keeps files. | | +| `OPENREPL_FIREBASE_ADMIN` | Optional. A Google service account key (the JSON, its base64, or a file path) that the server signs in to the Firebase Realtime Database with, for the cleanup of shared sessions (every `sessionCleanupHours`, daily by default; an admin setting). Without it the server falls back to the key of `OPENREPL_FIRESTORE_CREDENTIALS`, and without either the cleanup does not run (the Health page says so) and ended sessions are removed only by the pages that see them. | | | `OPENREPL_FIRESTORE_CREDENTIALS` | Optional. A Google service account key (the JSON, its base64, or a file path) for the Firebase project's Firestore. Used when `OPENREPL_MONGODB_URI` is not set and Firestore answers; otherwise the server uses files. Keep the security rules of `kv_*` and `settings` closed. | | | `OPENREPL_FIRESTORE_PROJECT` | The project, when the key does not name it (the emulator). | | | `OPENREPL_MONGODB_DB` | The MongoDB database for it. Default `openrepl`. | | diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index e3c68c19..c7662fea 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -55,7 +55,7 @@ Since T20 the page script is split into feature files under `src/js/src/page/`, | `06-editor.js` | Ace set-up, themes, fonts, modes, Ctrl+S, editor sharing over Firebase | | `07-file-browser.js` | The Files panel (jstree), context menu and live file events | | `30-owner-presence.js` | A shared session whose owner is not there: the owner's page writes `editor_values//owner` `{online, at}` (set on connect, `onDisconnect` writes offline, a heartbeat every 25 s); a collaborator's page watches it and, after 8 s offline or 150 s of silence, shows a popup (left / nobody hosting; an id with no content: ended, and a link to an ended session no longer re-creates it), a bar above the editor, no sharing of its own edits and no Run, and "Copy it to my own editor" (a code link, `POST /snippet`, in a new tab). When the owner is back the page loads again. A missing record (an owner page of an older version) shows nothing. Test: `e2e/tests/shared-owner-left.spec.js`. Designed in the "Shared session: owner left notice" canvas. | -| `31-share-session.js` | Share with a time. Opening the Share popover starts a session (`ShareSession.start`, the time chosen last, 1 hour at first): `{id, endsAt, mins}` is saved in the browser (`localStorage` `openrepl-share`) and written to `editor_values//share` `{endsAt, mins, state}`. `getExampleRef` (`01-session.js`) gives the saved id back after a reload (`savedShareId`), unless another tab holds it (`openrepl-share-tab`, a 5 s heartbeat cleared on `pagehide`), so the link stays the same until it ends. The popover has the time list (guests 30 minutes, 1 hour; signed-in 30 minutes, 1 hour, 4 hours, 24 hours, 7 days; both cut at the admin's "Longest a share link works" settings, `shareLinkGuestMinutes` / `shareLinkUserMinutes`, default 60 and 10080, served as `site_settings.shareMaxGuestMinutes` / `shareMaxUserMinutes`), a countdown, Add time (up to the longest) and End session (asked first). The clock is checked every 15 s with the Firebase server time offset; five minutes before the end the owner is told once. End or expiry (`end`): the record says `ended` or `expired` for the collaborators, the owner's presence is stopped (`OwnerAway.stopHosting`), and after 2.5 s `wipe` removes `editor_values/`, `file-browser/` and the terminal records, and the page loads again (a new link next time). A collaborator's page (`30-owner-presence.js`) shows "The owner ended this session" or "This link has expired" and wipes the same records 15 s later; the owner's page wipes the records of a link that ran out while it was closed when it loads. A link that is over shows no code (`beforeInit`). A link whose data is gone says "This shared session has ended". **Not a lock**: nothing on a server checks the time, and the data stays in Firebase until some page removes it. Test: `e2e/tests/shared-link-time.spec.js`. Designed in the "Shared session: owner left notice, and a link with a time" canvas (boards 5 to 8). | +| `31-share-session.js` | Share with a time. Opening the Share popover starts a session (`ShareSession.start`, the time chosen last, 1 hour at first): `{id, endsAt, mins}` is saved in the browser (`localStorage` `openrepl-share`) and written to `editor_values//share` `{endsAt, mins, state}`. `getExampleRef` (`01-session.js`) gives the saved id back after a reload (`savedShareId`), unless another tab holds it (`openrepl-share-tab`, a 5 s heartbeat cleared on `pagehide`), so the link stays the same until it ends. The popover has the time list (guests 30 minutes, 1 hour; signed-in 30 minutes, 1 hour, 4 hours, 24 hours, 7 days; both cut at the admin's "Longest a share link works" settings, `shareLinkGuestMinutes` / `shareLinkUserMinutes`, default 60 and 10080, served as `site_settings.shareMaxGuestMinutes` / `shareMaxUserMinutes`), a countdown, Add time (up to the longest) and End session (asked first). The clock is checked every 15 s with the Firebase server time offset; five minutes before the end the owner is told once. End or expiry (`end`): the record says `ended` or `expired` for the collaborators, the owner's presence is stopped (`OwnerAway.stopHosting`), and after 2.5 s `wipe` removes `editor_values/`, `file-browser/` and the terminal records, and the page loads again (a new link next time). A collaborator's page (`30-owner-presence.js`) shows "The owner ended this session" or "This link has expired" and wipes the same records 15 s later; the owner's page wipes the records of a link that ran out while it was closed when it loads. A link that is over shows no code (`beforeInit`). A link whose data is gone says "This shared session has ended". **A collaborator's page of a session that is over writes nothing** (`30-owner-presence.js`, `lockPage`): the editor is read-only, every Firebase write from the page (`firebase.database.Reference.prototype` set, update, push, remove, transaction) is dropped except the wipe, the terminal and the peer chat send nothing, and the requests to `/ws_filebrowser` and `/upload_file` are refused. **Not a lock**: nothing on a server checks the time before the data is read. What is left behind when no page is open is removed by the server's cleanup job (`server/session_cleanup.go`): every `sessionCleanupHours` (admin setting, default 24; started 5 minutes after the server, not on a worker) it signs in to the Realtime Database REST API as the service account of `OPENREPL_FIREBASE_ADMIN` (or the key of `OPENREPL_FIRESTORE_CREDENTIALS`; `persist.RTDBTokens` mints the token, scope `firebase.database`), lists the ids under `openrepl`, `editor_values`, `chat-list` and `file-browser`, and for each reads `editor_values//share` and `.../owner`: an end that has passed, or a state `ended` or `expired`, is removed from the four places at once; an id with no share record is removed when its owner page is not saying it is there (no `owner` record, or one that is offline or older than 10 minutes) and it has been seen so for a whole interval (the job remembers it between runs, so a live session of an older owner page or one that has not pressed Share is not taken from under it). The Health page shows the last run. Test: `e2e/tests/shared-link-time.spec.js`. Designed in the "Shared session: owner left notice, and a link with a time" canvas (boards 5 to 8). | | `08-genie-nudge.js` to `17-share-code.js` | One file per Phase 1 and 2 feature: Genie nudge, hero chips, workspace controls, terminal state, landing, phone layout, accessibility, empty Files note, language pages, share-code links | | `18-genie-review.js`, `lib-diff.mjs` | Review of what Genie wants to put in the editor (below). `lib-diff.mjs` is the pure diff code; node tests in `src/js/test` (`npm test`) | diff --git a/e2e/tests/shared-link-time.spec.js b/e2e/tests/shared-link-time.spec.js index 558fe102..9daf1ef9 100644 --- a/e2e/tests/shared-link-time.spec.js +++ b/e2e/tests/shared-link-time.spec.js @@ -64,11 +64,26 @@ test("End session tells the collaborators, and a new Share makes a new link", as const first = await readLink(o.page); const v = await viewer(browser, first); await expect.poll(() => v.page.evaluate(() => window.OwnerAway.state().seenOnline), { timeout: 30 * 1000 }).toBe(true); + const id = new URL(first).hash.slice(1); + // chat and terminal records exist while the session lives + await v.page.evaluate((i) => firebase.database().ref("chat-list/" + i).push({ eventT: "join", uid: "x", timestamp: Date.now() }), id); await o.page.locator("#share-end").click(); await o.page.getByRole("button", { name: "End session" }).last().click(); await expect(dialog(v.page)).toBeVisible({ timeout: 30 * 1000 }); await expect(dialog(v.page)).toContainText("The owner ended this session"); await expect(dialog(v.page).getByRole("button", { name: "Copy it to my own editor" })).toBeVisible(); + // the collaborator's page can write nothing now: the editor is read-only, Firebase and the owner's files are shut + await v.page.keyboard.press("Escape"); + expect(await v.page.evaluate(() => document.getElementById("editor").env.editor.getReadOnly())).toBe(true); + const dropped = await v.page.evaluate(async (i) => { + const ref = firebase.database().ref("chat-list/" + i); + await ref.push({ eventT: "message", text: "after the end" }); + await firebase.database().ref("editor_values/" + i + "/content").set("changed after the end"); + let files = "reached"; + try { await fetch(preprocessurl("/ws_filebrowser?q=load")); } catch (e) { files = "refused"; } + return files; + }, id); + expect(dropped).toBe("refused"); // the owner's page loads again and gets a link of its own, not the ended one await expect.poll(async () => (await o.page.evaluate(() => (document.getElementById("share-it") || {}).value || "").catch(() => "")), { timeout: 60 * 1000 }).not.toBe(first); await o.page.waitForFunction(() => window.ShareSession && window.EditorTabs); @@ -82,11 +97,14 @@ test("End session tells the collaborators, and a new Share makes a new link", as await expect(dialog(late.page)).toContainText("This shared session has ended"); await expect(dialog(late.page).getByRole("button", { name: "Start my own editor" })).toBeVisible(); // everything of the session is gone from Firebase: no code, no record - const left = await late.page.evaluate(async (id) => { - const snap = await firebase.database().ref("editor_values/" + id).once("value"); - return snap.exists(); - }, new URL(first).hash.slice(1)); - expect(left).toBe(false); + await expect.poll(() => late.page.evaluate(async (id) => { + const left = []; + for (const path of ["editor_values", "file-browser", "openrepl", "chat-list"]) { + const snap = await firebase.database().ref(path + "/" + id).once("value"); + if (snap.exists()) left.push(path); + } + return left; + }, id), { timeout: 40 * 1000 }).toEqual([]); await o.ctx.close(); await v.ctx.close(); await late.ctx.close(); diff --git a/src/js/src/firetty.ts b/src/js/src/firetty.ts index 139c8e95..682e2d9b 100644 --- a/src/js/src/firetty.ts +++ b/src/js/src/firetty.ts @@ -323,6 +323,8 @@ export class FireTTY { }; dboutput(type: string, data: any) { + // a collaborator's page of a session that is over sends nothing (30-owner-presence.js) + if (!this.master && (window as any).OwnerAway && (window as any).OwnerAway.over && (window as any).OwnerAway.over()) return; //console.log("db output event: ", type, data); this.firebasedbref.push ({ eventT: type, diff --git a/src/js/src/page/06-editor.js b/src/js/src/page/06-editor.js index bdff4bf6..132fde12 100644 --- a/src/js/src/page/06-editor.js +++ b/src/js/src/page/06-editor.js @@ -164,6 +164,8 @@ $(function() { // Initialize the ACE editor editor = ace.edit("editor"); + // a collaborator of a session that is over only reads (30-owner-presence.js) + if (window.OwnerAway && window.OwnerAway.over()) editor.setReadOnly(true); try { editor.textInput.getElement().setAttribute("aria-label", "Code editor"); } catch (e) {} // T15 editor.setTheme(getTheme(), function () { if (window.applyIdeTheme) window.applyIdeTheme(editor, getTheme()); }); editor.setFontSize("14px"); diff --git a/src/js/src/page/30-owner-presence.js b/src/js/src/page/30-owner-presence.js index 2ea857e9..08b63d23 100644 --- a/src/js/src/page/30-owner-presence.js +++ b/src/js/src/page/30-owner-presence.js @@ -5,7 +5,7 @@ // was opened, or the session has ended, the collaborator is told once in a popup, and a bar stays above the editor. // Nothing is stored about who the owner is: only whether the page is open, and when it last said so. -window.OwnerAway = { active: active, hostSession: hostSession, stopHosting: stopHosting, watch: watch, beforeInit: beforeInit, state: function () { return state; } }; +window.OwnerAway = { over: over, active: active, hostSession: hostSession, stopHosting: stopHosting, watch: watch, beforeInit: beforeInit, state: function () { return state; } }; var HEARTBEAT_MS = 25000; // the owner's page says it is here this often var GRACE_MS = 8000; // offline this long before a collaborator is told (a reload of the owner's page is not announced) @@ -16,6 +16,8 @@ var state = { away: false, ended: false, kind: "", seenOnline: false, dismissed: var warned = false; function active() { return state.away || state.ended; } +// the session is over (ended, expired, or gone): a collaborator's page then writes nothing +function over() { return !!state.ended; } function say(text, type) { if (window.notify) window.notify(text, { type: type || "info", timeout: 4000 }); } function stamp() { return firebase.database.ServerValue.TIMESTAMP; } @@ -103,6 +105,7 @@ function setOver(kind) { clearTimeout(graceTimer); graceTimer = 0; state.dismissed = false; + lockPage(); drawBar(); showDialog(); // the owner's page takes the records away when it can; this page does it too a little later, so that nothing of a link that @@ -201,6 +204,7 @@ function showEnded() { if (state.ended) return; state.ended = true; state.kind = "ended"; + lockPage(); drawBar(); showDialog(); } @@ -380,6 +384,60 @@ function dlgButtonBar(label, onClick) { return b; } +// When the session is over the collaborator's page can only read. The editor is read-only; the terminal and the peer chat send +// nothing (firetty.ts and the chat widget ask over()); the owner's files cannot be reached (below); the page says so to the eye. +function lockPage() { + document.body.classList.add("session-over"); + var ed = document.getElementById("editor"); + if (ed && ed.env && ed.env.editor) ed.env.editor.setReadOnly(true); + installWriteGuard(); +} + +var guarded = false; +function blockedUrl(u) { + var s = typeof u === "string" ? u : (u && u.url) || ""; + return /\/(ws_filebrowser|upload_file)(\?|$)/.test(s); +} +// A collaborator's page reaches the owner's files through the file browser (and uploads): not any more once the session is over. +function installWriteGuard() { + if (guarded) return; + guarded = true; + var f = window.fetch; + if (f) { + window.fetch = function (input) { + if (over() && blockedUrl(input)) return Promise.reject(new TypeError("This session has ended.")); + return f.apply(this, arguments); + }; + } + // and every write to Firebase from this page (the editor, the terminal, the files and tabs, the chat): the wipe of the session + // alone is let through + try { + var R = firebase.database.Reference.prototype; + ["set", "update", "push", "remove", "transaction", "setWithPriority"].forEach(function (name) { + var orig = R[name]; + if (typeof orig !== "function") return; + R[name] = function () { + if (over() && !window.__sessionWipe) return name === "push" ? this : Promise.resolve(); + return orig.apply(this, arguments); + }; + }); + } catch (e) { /* the page's own checks above still hold */ } + var open = XMLHttpRequest.prototype.open; + var send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + this.__sessionOverBlocked = blockedUrl(url); + return open.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + if (over() && this.__sessionOverBlocked) { + var x = this; + setTimeout(function () { x.dispatchEvent(new Event("error")); }, 0); + return undefined; + } + return send.apply(this, arguments); + }; +} + // Run is off for a collaborator while the owner is away: the terminal it would run in is the owner's. var runGuarded = false; function installRunGuard() { diff --git a/src/js/src/page/31-share-session.js b/src/js/src/page/31-share-session.js index f90de8a4..0486ed81 100644 --- a/src/js/src/page/31-share-session.js +++ b/src/js/src/page/31-share-session.js @@ -113,8 +113,16 @@ function addTime() { function wipe(id) { if (!id) return Promise.resolve(); var d = db(); - var jobs = [d.ref("editor_values/" + id).remove(), d.ref("file-browser/" + id).remove()]; - try { if (window.getrepl_firebasedbref) jobs.push(window.getrepl_firebasedbref().child(id).remove()); } catch (e) { /* not essential */ } + var jobs; + window.__sessionWipe = true; // a collaborator's page writes nothing once the session is over, except this (30-owner-presence.js) + try { + jobs = [d.ref("editor_values/" + id).remove(), d.ref("file-browser/" + id).remove(), d.ref("chat-list/" + id).remove()]; + if (window.getrepl_firebasedbref) jobs.push(window.getrepl_firebasedbref().child(id).remove()); + } catch (e) { + jobs = jobs || []; + } finally { + window.__sessionWipe = false; + } return Promise.all(jobs.map(function (p) { return p.catch(function () {}); })); } diff --git a/src/persist/rtdb_token.go b/src/persist/rtdb_token.go new file mode 100644 index 00000000..90b7c547 --- /dev/null +++ b/src/persist/rtdb_token.go @@ -0,0 +1,92 @@ +package persist + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "sync" + "time" +) + +// OAuth tokens for the Firebase Realtime Database REST API, from the service account key the operator gave (the same kind of key +// as Firestore's, parsed the same way): the server signs a JWT with the key and trades it at Google for a short token. A +// service account is not bound by the database's security rules, which is what lets a job on the server clean up what the +// browsers of the users left behind. + +// RTDBTokens gives access tokens for one service account. +type RTDBTokens struct { + acct *serviceAccount + rsaKey *rsa.PrivateKey + http *http.Client + + mu sync.Mutex + token string + expires time.Time +} + +// NewRTDBTokens reads the key from raw (the JSON, its base64, or the path of a file). +func NewRTDBTokens(raw string) (*RTDBTokens, error) { + a, err := parseCredentials(raw) + if err != nil { + return nil, err + } + key, err := parsePrivateKey(a.PrivateKey) + if err != nil { + return nil, err + } + return &RTDBTokens{acct: a, rsaKey: key, http: &http.Client{Timeout: 15 * time.Second}}, nil +} + +// ProjectID is the Google Cloud project the key belongs to. +func (t *RTDBTokens) ProjectID() string { return t.acct.ProjectID } + +// Token returns a token, asking Google for a new one a minute before the old one ends. +func (t *RTDBTokens) Token() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.token != "" && time.Now().Before(t.expires.Add(-time.Minute)) { + return t.token, nil + } + now := time.Now() + enc := func(v interface{}) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + unsigned := enc(map[string]string{"alg": "RS256", "typ": "JWT"}) + "." + enc(map[string]interface{}{ + "iss": t.acct.ClientEmail, "scope": "https://www.googleapis.com/auth/firebase.database https://www.googleapis.com/auth/userinfo.email", + "aud": t.acct.TokenURI, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), + }) + sum := sha256.Sum256([]byte(unsigned)) + sig, err := rsa.SignPKCS1v15(rand.Reader, t.rsaKey, crypto.SHA256, sum[:]) + if err != nil { + return "", err + } + form := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, "assertion": {unsigned + "." + base64.RawURLEncoding.EncodeToString(sig)}} + resp, err := t.http.PostForm(t.acct.TokenURI, form) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var tok struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + Description string `json:"error_description"` + } + json.Unmarshal(body, &tok) + if resp.StatusCode != 200 || tok.AccessToken == "" { + return "", fmt.Errorf("Google refused the service account (%d %s %s)", resp.StatusCode, tok.Error, tok.Description) + } + t.token = tok.AccessToken + t.expires = now.Add(time.Duration(tok.ExpiresIn) * time.Second) + return t.token, nil +} diff --git a/src/persist/rtdb_token_test.go b/src/persist/rtdb_token_test.go new file mode 100644 index 00000000..96d5dfc1 --- /dev/null +++ b/src/persist/rtdb_token_test.go @@ -0,0 +1,64 @@ +package persist + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestRTDBTokensAreMintedWithTheDatabaseScopeAndKept(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + var asked int32 + var scope string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&asked, 1) + r.ParseForm() + parts := strings.Split(r.Form.Get("assertion"), ".") + if len(parts) == 3 { + raw, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var c map[string]interface{} + json.Unmarshal(raw, &c) + scope, _ = c["scope"].(string) + } + w.Write([]byte(`{"access_token":"tok-1","expires_in":3600}`)) + })) + defer srv.Close() + acct, _ := json.Marshal(map[string]string{ + "type": "service_account", "project_id": "demo", "client_email": "svc@demo.iam.gserviceaccount.com", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), "token_uri": srv.URL, + }) + tokens, err := NewRTDBTokens(string(acct)) + if err != nil { + t.Fatal(err) + } + if tokens.ProjectID() != "demo" { + t.Errorf("project %q", tokens.ProjectID()) + } + for i := 0; i < 3; i++ { + tok, err := tokens.Token() + if err != nil || tok != "tok-1" { + t.Fatalf("%q %v", tok, err) + } + } + if asked != 1 { + t.Errorf("Google was asked %d times, want 1 (the token is kept)", asked) + } + if !strings.Contains(scope, "firebase.database") { + t.Errorf("scope %q", scope) + } + if _, err := NewRTDBTokens("not a key"); err == nil { + t.Error("a bad key was accepted") + } +} diff --git a/src/resources/chat-widget/src/index.ts b/src/resources/chat-widget/src/index.ts index 9da54fd9..fc29a5cc 100644 --- a/src/resources/chat-widget/src/index.ts +++ b/src/resources/chat-widget/src/index.ts @@ -393,6 +393,14 @@ const setupFBListener = () => { firebase.initializeApp(config.firebaseconfig); } chatfirebasedbref = window.firebase.database().ref(CHAT_LIST_KEY).child(config.dbpath); + // a collaborator's page of a session that is over (30-owner-presence.js) writes nothing to the peer chat + const chatRef: any = chatfirebasedbref; + const sendToChat = chatRef.push.bind(chatRef); + chatRef.push = (value?: any, onComplete?: any) => { + const w: any = window; + if (w.OwnerAway && w.OwnerAway.over && w.OwnerAway.over()) return Promise.resolve(chatRef); + return sendToChat(value, onComplete); + }; // firebase callback chatfirebasedbref.on("child_added", (data: firebase.database.DataSnapshot | null, prevChildKey?: string | undefined) => { diff --git a/src/resources/css/ui-refresh.css b/src/resources/css/ui-refresh.css index d271945a..bdb7bb76 100644 --- a/src/resources/css/ui-refresh.css +++ b/src/resources/css/ui-refresh.css @@ -2952,6 +2952,7 @@ body .xterm-helper-textarea:focus-visible { outline: none !important; } .popover__btn:hover { filter: brightness(1.07); } .popover__btn:disabled { opacity: 0.6; cursor: default; filter: none; } .popover__btn--danger { color: #f3b4ae; background: transparent; border: 1px solid #6b3434; } +.session-over #file-browser, .session-over #terminal-div, .session-over #editor-tabs .etab__close { pointer-events: none; opacity: 0.6; } .share-session { margin-top: 12px; } .share-session[hidden] { display: none; } .share-session__row { display: flex; align-items: center; gap: 10px; } diff --git a/src/resources/js/admin.js b/src/resources/js/admin.js index 53ffc28f..6e3ae925 100644 --- a/src/resources/js/admin.js +++ b/src/resources/js/admin.js @@ -1104,6 +1104,7 @@ previewLinkUserMinutes: int(form.previewLifeUser.value), shareLinkGuestMinutes: int(form.shareLifeGuest.value), shareLinkUserMinutes: int(form.shareLifeUser.value), + sessionCleanupHours: int(form.sessionCleanup.value), previewOff: !form.previewOn.checked, previewGuestsOff: !form.previewGuests.checked, genie: { disabled: !form.genie.checked, guestPerMinute: num(form.guest.value), userPerMinute: num(form.user.value), @@ -1166,6 +1167,7 @@ previewLinkUserMinutes: s.previewLinkUserMinutes || 0, shareLinkGuestMinutes: s.shareLinkGuestMinutes || 0, shareLinkUserMinutes: s.shareLinkUserMinutes || 0, + sessionCleanupHours: s.sessionCleanupHours || 0, previewOff: !!s.previewOff, previewGuestsOff: !!s.previewGuestsOff, genie: { disabled: !!s.genie.disabled, guestPerMinute: s.genie.guestPerMinute || 0, userPerMinute: s.genie.userPerMinute || 0, @@ -1222,6 +1224,9 @@ form.shareLifeUser = h('input', { type: 'number', class: 'field', id: 'f-share-life-user', min: '10', max: '10080', step: '1', placeholder: '10080', 'aria-label': 'Most minutes a share link works for signed-in users', oninput: update }); form.shareLifeUser.value = settings.shareLinkUserMinutes || ''; form.shareLifeUser.style.maxWidth = '9rem'; + form.sessionCleanup = h('input', { type: 'number', class: 'field', id: 'f-session-cleanup', min: '1', max: '720', step: '1', placeholder: '24', 'aria-label': 'Hours between two cleanups of shared sessions', oninput: update }); + form.sessionCleanup.value = settings.sessionCleanupHours || ''; + form.sessionCleanup.style.maxWidth = '9rem'; form.colour = colour.input; form.maint = maint.input; form.genie = genie.input; // the Preview pane (pages written by visitors, shown on this site's domain): off for all, or for guests only var previewOn = toggle('f-preview', !settings.previewOff, 'Preview available'); @@ -1633,7 +1638,8 @@ row('Minutes a preview link works for guests', 'From 10 to 1440 (a day). Leave it empty for 60 (1 hour). A pane gets a new link by itself before the old one ends; a link opened in a tab of its own, or copied, stops at its end. A change applies to links made after it.', form.previewLifeGuest), row('Minutes a preview link works for signed-in users', 'From 10 to 1440. Leave it empty for 180 (3 hours).', form.previewLifeUser), row('Longest a share link works for guests (minutes)', 'From 10 to 10080 (7 days). Leave it empty for 60 (1 hour). The owner chooses the time in the Share popover and may add time up to this: guests 30 minutes or 1 hour; signed-in users also 4 hours, 24 hours and 7 days, as far as this allows.', form.shareLifeGuest), - row('Longest a share link works for signed-in users (minutes)', 'From 10 to 10080 (7 days, as long as a sign-in lasts). Leave it empty for 10080.', form.shareLifeUser) ]), + row('Longest a share link works for signed-in users (minutes)', 'From 10 to 10080 (7 days, as long as a sign-in lasts). Leave it empty for 10080.', form.shareLifeUser), + row('Hours between two cleanups of shared sessions', 'From 1 to 720. Leave it empty for 24 (daily). The server removes, from Firebase, the sessions whose link ended or ran out, and sessions that have no end and no owner online for a whole interval (the terminal, the code, the chat and the files records). It needs the service account key of the project (OPENREPL_FIREBASE_ADMIN, or OPENREPL_FIRESTORE_CREDENTIALS); the Health page says if it is missing.', form.sessionCleanup) ]), card('Languages', 'Untick a language that is broken. The picker greys it out and its terminals refuse to start with a message; terminals that are already open keep running. Admins can still start it, to test a fix.', langGrid), card('Genie', 'The AI helper. When it is off, its buttons are hidden and the server refuses its requests, so no OpenAI calls are made for visitors.', [ row('Genie is available', null, genie.node), diff --git a/src/server/admin_health.go b/src/server/admin_health.go index 742f6abf..2dbaee4d 100644 --- a/src/server/admin_health.go +++ b/src/server/admin_health.go @@ -245,6 +245,10 @@ func (server *Server) health() (status string, checks []healthCheck) { } else { add("Firebase", healthFail, "no Firebase config: sign-in, shared sessions and the shared editor do not work. Set OPENREPL_FIREBASE_CONFIG") } + { + st, detail := sessionCleanupStatus() + add("Shared session cleanup", st, detail) + } switch st := currentSettingsStoreStatus(); { case st.Healthy: add("Settings store", healthOK, st.Detail) diff --git a/src/server/server.go b/src/server/server.go index f1ebed15..b77266c6 100755 --- a/src/server/server.go +++ b/src/server/server.go @@ -205,6 +205,7 @@ func (server *Server) Run(ctx context.Context, options ...RunOption) error { if err != nil { return err } + server.startSessionCleanup() go func() { select { diff --git a/src/server/session_cleanup.go b/src/server/session_cleanup.go new file mode 100644 index 00000000..55a797a4 --- /dev/null +++ b/src/server/session_cleanup.go @@ -0,0 +1,332 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" + + "persist" + "utils" +) + +// The cleanup of shared sessions in the Firebase Realtime Database. +// +// The pages remove what a session leaves when it ends or runs out (page/31-share-session.js), but only if one of them is open +// to do it. This job, on the server, finds what is left over and removes it: it signs in to the database as the service account +// of the project (OPENREPL_FIREBASE_ADMIN, or the key of OPENREPL_FIRESTORE_CREDENTIALS), lists the session ids, and for each: +// +// - a session with a share record (editor_values//share) whose end has passed, is removed; +// - a session with no share record is removed too, once nothing says its owner is there (no live presence record, +// editor_values//owner) and it has been seen so for a whole interval: an owner page of an older version, or one that has not +// pressed Share, writes no share record, and its live session must not be taken from under it. +// +// "Removed" is the id under openrepl (the terminal), editor_values (the code), chat-list and file-browser. How often it runs is +// an admin setting (SiteSettings.SessionCleanupHours, daily by default). + +// the places of the database that hold a session, by its id +var sessionPaths = []string{"openrepl", "editor_values", "chat-list", "file-browser"} + +const ( + sessionCleanupFirstRun = 5 * time.Minute // after the server starts + sessionCleanupCheck = time.Minute // how often the loop looks at the clock + sessionOwnerFresh = 10 * time.Minute + sessionCleanupMaxIDs = 20000 // an id past this many in one run waits for the next + sessionCleanupParallel = 8 + sessionCleanupHTTPLimit = 20 * time.Second +) + +// rtdb is a client of the Realtime Database REST API. +type rtdb struct { + base string // https://-default-rtdb.firebaseio.com, or the emulator's http://host:port + query url.Values // what every request carries (the emulator's ns=) + token func() (string, error) + http *http.Client +} + +func (c *rtdb) url(path string, extra url.Values) string { + q := url.Values{} + for k, v := range c.query { + q[k] = v + } + for k, v := range extra { + q[k] = v + } + return c.base + "/" + strings.Trim(path, "/") + ".json?" + q.Encode() +} + +func (c *rtdb) do(method, path string, extra url.Values, out interface{}) error { + req, err := http.NewRequest(method, c.url(path, extra), nil) + if err != nil { + return err + } + tok, err := c.token() + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if resp.StatusCode != 200 { + return fmt.Errorf("the database answered %d for %s %s", resp.StatusCode, method, path) + } + if out != nil && len(body) > 0 && string(body) != "null" { + return json.Unmarshal(body, out) + } + return nil +} + +// ids are the keys under a path, without reading what is below them. +func (c *rtdb) ids(path string) ([]string, error) { + var m map[string]interface{} + if err := c.do("GET", path, url.Values{"shallow": {"true"}}, &m); err != nil { + return nil, err + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +func (c *rtdb) remove(path string) error { return c.do("DELETE", path, nil, nil) } + +// what the pages write: the end of a link and the presence of its owner +type shareRecord struct { + EndsAt int64 `json:"endsAt"` // milliseconds + State string `json:"state"` +} +type ownerRecord struct { + Online bool `json:"online"` + At int64 `json:"at"` +} + +// sessionVerdict says whether a session is to be removed now. firstSeen is when the job first found it with no share record +// and no live owner (zero when it has not), the caller keeps it. +func sessionVerdict(share *shareRecord, owner *ownerRecord, firstSeen time.Time, now time.Time, interval time.Duration) (remove bool, seen bool) { + if share != nil && (share.EndsAt > 0 || share.State == "ended" || share.State == "expired") { + if share.State == "ended" || share.State == "expired" || share.EndsAt <= now.UnixNano()/1e6 { + return true, false + } + return false, false // a link that is still running + } + // no share record: not removed while the owner's page says it is there + if owner != nil && owner.Online && owner.At > 0 && now.Sub(time.Unix(0, owner.At*1e6)) < sessionOwnerFresh { + return false, false + } + if firstSeen.IsZero() { + return false, true // seen now; removed when a whole interval has passed and it is still so + } + return now.Sub(firstSeen) >= interval, true +} + +type cleanupResult struct { + At time.Time + Scanned int + Removed int + Waiting int // sessions with no share record that are waiting for their interval + Errors int + Duration time.Duration + Err string +} + +// runSessionCleanup goes through every session id once. seen is the job's memory of the ids it has found with no share record. +func runSessionCleanup(c *rtdb, now time.Time, seen map[string]time.Time, interval time.Duration) cleanupResult { + started := time.Now() + res := cleanupResult{At: now} + set := map[string]bool{} + for _, p := range sessionPaths { + ids, err := c.ids(p) + if err != nil { + res.Errors++ + res.Err = err.Error() + continue + } + for _, id := range ids { + set[id] = true + } + } + all := make([]string, 0, len(set)) + for id := range set { + all = append(all, id) + } + sort.Strings(all) + if len(all) > sessionCleanupMaxIDs { + all = all[:sessionCleanupMaxIDs] + } + res.Scanned = len(all) + + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, sessionCleanupParallel) + still := map[string]time.Time{} + for _, id := range all { + wg.Add(1) + sem <- struct{}{} + go func(id string) { + defer wg.Done() + defer func() { <-sem }() + var share *shareRecord + var owner *ownerRecord + var s shareRecord + var o ownerRecord + if err := c.do("GET", "editor_values/"+id+"/share", nil, &s); err != nil { + mu.Lock() + res.Errors++ + mu.Unlock() + return + } else if s != (shareRecord{}) { + share = &s + } + if err := c.do("GET", "editor_values/"+id+"/owner", nil, &o); err == nil && o != (ownerRecord{}) { + owner = &o + } + mu.Lock() + first := seen[id] + mu.Unlock() + remove, wasSeen := sessionVerdict(share, owner, first, now, interval) + mu.Lock() + if remove { + mu.Unlock() + failed := false + for _, p := range sessionPaths { + if err := c.remove(p + "/" + id); err != nil { + failed = true + } + } + mu.Lock() + if failed { + res.Errors++ + } else { + res.Removed++ + } + mu.Unlock() + return + } + if wasSeen { + if first.IsZero() { + first = now + } + still[id] = first + res.Waiting++ + } + mu.Unlock() + }(id) + } + wg.Wait() + // what is not found any more, or has a share record or a live owner now, is forgotten + for k := range seen { + delete(seen, k) + } + for k, v := range still { + seen[k] = v + } + res.Duration = time.Since(started) + return res +} + +// ---- the credentials and the loop ----------------------------------------------------------------------------------- + +const envFirebaseAdmin = "OPENREPL_FIREBASE_ADMIN" + +// newSessionCleanupClient builds the database client from the environment, or says why it cannot. +func newSessionCleanupClient() (*rtdb, error) { + raw := strings.TrimSpace(os.Getenv(envFirebaseAdmin)) + if raw == "" { + raw = strings.TrimSpace(os.Getenv(utils.EnvFirestoreCredentials)) + } + if raw == "" { + return nil, fmt.Errorf("no service account key: set %s (or %s)", envFirebaseAdmin, utils.EnvFirestoreCredentials) + } + tokens, err := persist.NewRTDBTokens(raw) + if err != nil { + return nil, err + } + cfg, _, _ := utils.FirebaseConfigFromEnv() + base := strings.TrimSpace(cfg["databaseURL"]) + if base == "" { + base = "https://" + tokens.ProjectID() + "-default-rtdb.firebaseio.com" + } + u, err := url.Parse(base) + if err != nil || u.Host == "" { + return nil, fmt.Errorf("the database address %q is not usable", base) + } + c := &rtdb{base: u.Scheme + "://" + u.Host, query: u.Query(), token: tokens.Token, http: &http.Client{Timeout: sessionCleanupHTTPLimit}} + return c, nil +} + +var ( + sessionCleanupMu sync.Mutex + sessionCleanupLast cleanupResult + sessionCleanupReason string // why the job is not running, "" when it is +) + +func recordCleanup(r cleanupResult) { + sessionCleanupMu.Lock() + sessionCleanupLast = r + sessionCleanupMu.Unlock() +} + +// sessionCleanupStatus is what the health panel says of the job. +func sessionCleanupStatus() (status, detail string) { + sessionCleanupMu.Lock() + defer sessionCleanupMu.Unlock() + if sessionCleanupReason != "" { + return healthWarn, "not running: " + sessionCleanupReason + ". Ended and expired sessions are then removed only by the pages that see them" + } + r := sessionCleanupLast + if r.At.IsZero() { + return healthOK, "waiting for its first run (5 minutes after the server started, then every " + fmt.Sprint(GetSiteSettings().sessionCleanupInterval()) + ")" + } + d := fmt.Sprintf("last run %s: %d sessions looked at, %d removed, %d waiting", r.At.Format("2006-01-02 15:04"), r.Scanned, r.Removed, r.Waiting) + if r.Errors > 0 { + return healthWarn, d + fmt.Sprintf(", %d errors (%s)", r.Errors, r.Err) + } + return healthOK, d +} + +// StartSessionCleanup runs the cleanup every SessionCleanupHours (an admin setting), in the background, for the life of the +// server. A worker does not: the gateway or the single server does it for the project. +func (server *Server) startSessionCleanup() { + if server.options.Mode == ModeWorker { + return + } + go func() { + client, err := newSessionCleanupClient() + sessionCleanupMu.Lock() + if err != nil { + sessionCleanupReason = err.Error() + } + sessionCleanupMu.Unlock() + if err != nil { + log.Printf("session cleanup is off: %v", err) + return + } + seen := map[string]time.Time{} + last := time.Now().Add(sessionCleanupFirstRun - GetSiteSettings().sessionCleanupInterval()) + for { + time.Sleep(sessionCleanupCheck) + interval := GetSiteSettings().sessionCleanupInterval() + if time.Since(last) < interval { + continue + } + last = time.Now() + res := runSessionCleanup(client, time.Now(), seen, interval) + recordCleanup(res) + log.Printf("session cleanup: %d sessions looked at, %d removed, %d waiting, %d errors in %s %s", res.Scanned, res.Removed, res.Waiting, res.Errors, res.Duration.Round(time.Millisecond), res.Err) + } + }() +} diff --git a/src/server/session_cleanup_test.go b/src/server/session_cleanup_test.go new file mode 100644 index 00000000..b45c45b9 --- /dev/null +++ b/src/server/session_cleanup_test.go @@ -0,0 +1,187 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +func TestSessionVerdict(t *testing.T) { + now := time.Now() + ms := func(d time.Duration) int64 { return now.Add(d).UnixNano() / 1e6 } + day := 24 * time.Hour + cases := []struct { + name string + share *shareRecord + owner *ownerRecord + first time.Time + remove bool + seen bool + }{ + {"a link whose end has passed", &shareRecord{EndsAt: ms(-time.Minute), State: "live"}, nil, time.Time{}, true, false}, + {"a link still running", &shareRecord{EndsAt: ms(time.Hour), State: "live"}, nil, time.Time{}, false, false}, + {"a link that was ended", &shareRecord{EndsAt: ms(time.Hour), State: "ended"}, nil, time.Time{}, true, false}, + {"no share record, the owner is here", nil, &ownerRecord{Online: true, At: ms(-time.Minute)}, time.Time{}, false, false}, + {"no share record, the owner's heartbeat is old", nil, &ownerRecord{Online: true, At: ms(-time.Hour)}, time.Time{}, false, true}, + {"no share record, nobody, seen the first time", nil, nil, time.Time{}, false, true}, + {"no share record, nobody, seen less than an interval ago", nil, nil, now.Add(-day / 2), false, true}, + {"no share record, nobody, seen a whole interval ago", nil, nil, now.Add(-day - time.Minute), true, true}, + {"no share record, the owner left", nil, &ownerRecord{Online: false, At: ms(-2 * day)}, now.Add(-2 * day), true, true}, + } + for _, c := range cases { + remove, seen := sessionVerdict(c.share, c.owner, c.first, now, day) + if remove != c.remove || seen != c.seen { + t.Errorf("%s: remove=%v seen=%v, want %v %v", c.name, remove, seen, c.remove, c.seen) + } + } +} + +// a Realtime Database that keeps its tree in memory and answers what the cleanup asks: shallow lists, reads and deletes +type fakeRTDB struct { + mu sync.Mutex + tree map[string]interface{} +} + +func (f *fakeRTDB) node(path string) (interface{}, bool) { + var cur interface{} = f.tree + for _, p := range strings.Split(strings.Trim(path, "/"), "/") { + if p == "" { + continue + } + m, ok := cur.(map[string]interface{}) + if !ok { + return nil, false + } + if cur, ok = m[p]; !ok { + return nil, false + } + } + return cur, true +} + +func (f *fakeRTDB) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), ".json") + switch r.Method { + case "GET": + v, ok := f.node(path) + if !ok { + w.Write([]byte("null")) + return + } + if r.URL.Query().Get("shallow") == "true" { + out := map[string]bool{} + if m, ok := v.(map[string]interface{}); ok { + for k := range m { + out[k] = true + } + } + json.NewEncoder(w).Encode(out) + return + } + json.NewEncoder(w).Encode(v) + case "DELETE": + parts := strings.Split(path, "/") + parent, ok := f.node(strings.Join(parts[:len(parts)-1], "/")) + if m, isMap := parent.(map[string]interface{}); ok && isMap { + delete(m, parts[len(parts)-1]) + } + w.Write([]byte("null")) + } +} + +func TestRunSessionCleanup(t *testing.T) { + now := time.Now() + ms := func(d time.Duration) int64 { return now.Add(d).UnixNano() / 1e6 } + session := func(share, owner map[string]interface{}) map[string]interface{} { + v := map[string]interface{}{"content": "code"} + if share != nil { + v["share"] = share + } + if owner != nil { + v["owner"] = owner + } + return v + } + fake := &fakeRTDB{tree: map[string]interface{}{ + "editor_values": map[string]interface{}{ + "over": session(map[string]interface{}{"endsAt": ms(-time.Hour), "state": "live"}, nil), + "running": session(map[string]interface{}{"endsAt": ms(3 * 24 * time.Hour), "state": "live"}, nil), + "here": session(nil, map[string]interface{}{"online": true, "at": ms(-time.Minute)}), + "nobody": session(nil, nil), + }, + "openrepl": map[string]interface{}{"over": map[string]interface{}{"t": 1}, "running": map[string]interface{}{"t": 1}, "orphan": map[string]interface{}{"t": 1}}, + "chat-list": map[string]interface{}{"over": map[string]interface{}{"m": 1}, "running": map[string]interface{}{"m": 1}}, + "file-browser": map[string]interface{}{"over": map[string]interface{}{"x": 1}, "nobody": map[string]interface{}{"x": 1}}, + }} + srv := httptest.NewServer(fake) + defer srv.Close() + u, _ := url.Parse(srv.URL) + c := &rtdb{base: u.Scheme + "://" + u.Host, query: url.Values{"ns": {"demo"}}, token: func() (string, error) { return "test-token", nil }, http: http.DefaultClient} + seen := map[string]time.Time{} + day := 24 * time.Hour + + // the first run: the over one goes everywhere; the share-less ones without a live owner are noted, not removed + res := runSessionCleanup(c, now, seen, day) + if res.Removed != 1 || res.Errors != 0 { + t.Fatalf("first run: %+v", res) + } + for _, p := range sessionPaths { + if _, ok := fake.node(p + "/over"); ok { + t.Errorf("%s/over is still there", p) + } + } + for _, keep := range []string{"editor_values/running", "openrepl/running", "chat-list/running", "editor_values/here", "editor_values/nobody", "openrepl/orphan", "file-browser/nobody"} { + if _, ok := fake.node(keep); !ok { + t.Errorf("%s was removed by the first run", keep) + } + } + if len(seen) != 2 || seen["nobody"].IsZero() || seen["orphan"].IsZero() { + t.Errorf("seen after the first run: %v", seen) + } + + // a whole interval later: the ones nobody has, and the orphan records, go; a running link and an owner that is there stay + res = runSessionCleanup(c, now.Add(day+time.Minute), seen, day) + if res.Removed != 2 { + t.Fatalf("second run: %+v", res) + } + for _, gone := range []string{"editor_values/nobody", "file-browser/nobody", "openrepl/orphan"} { + if _, ok := fake.node(gone); ok { + t.Errorf("%s is still there after the second run", gone) + } + } + for _, keep := range []string{"editor_values/running", "openrepl/running", "chat-list/running", "editor_values/here"} { + if _, ok := fake.node(keep); !ok { + t.Errorf("%s was removed", keep) + } + } +} + +func TestSessionCleanupSetting(t *testing.T) { + var s SiteSettings + if s.sessionCleanupInterval() != 24*time.Hour { + t.Errorf("default %v", s.sessionCleanupInterval()) + } + s.SessionCleanupHours = 6 + if s.sessionCleanupInterval() != 6*time.Hour { + t.Errorf("set %v", s.sessionCleanupInterval()) + } + for _, bad := range []int{-1, 721} { + if _, err := (SiteSettings{SessionCleanupHours: bad}).normalize(); err == nil { + t.Errorf("%d hours was accepted", bad) + } + } + if _, err := (SiteSettings{SessionCleanupHours: 720}).normalize(); err != nil { + t.Errorf("720 hours was refused: %v", err) + } +} diff --git a/src/server/settings.go b/src/server/settings.go index e487eaa1..7dcd7c90 100644 --- a/src/server/settings.go +++ b/src/server/settings.go @@ -79,6 +79,9 @@ type SiteSettings struct { // hour) and 10080 (signed in: 7 days, as long as a sign-in lasts). ShareLinkGuestMinutes int `json:"shareLinkGuestMinutes"` ShareLinkUserMinutes int `json:"shareLinkUserMinutes"` + // SessionCleanupHours is how often the server removes the shared sessions that ended, ran out or were left in the Firebase + // Realtime Database (session_cleanup.go), from 1 to 720 hours; 0 means the built-in 24 (daily). + SessionCleanupHours int `json:"sessionCleanupHours"` // PreviewOff switches the Preview pane off for everyone, and PreviewGuestsOff for visitors who are not signed in: // the pane shows pages written by visitors on this site's domain, so abuse can be stopped here without a deploy. PreviewOff bool `json:"previewOff"` @@ -210,6 +213,9 @@ func (s SiteSettings) normalize() (SiteSettings, error) { return s, fmt.Errorf("the time a preview link works for %s must be between %d and %d minutes (or empty)", name, minPreviewLinkMinutes, maxPreviewLinkMinutes) } } + if s.SessionCleanupHours != 0 && (s.SessionCleanupHours < minSessionCleanupHours || s.SessionCleanupHours > maxSessionCleanupHours) { + return s, fmt.Errorf("the time between two cleanups of shared sessions must be between %d and %d hours (or empty)", minSessionCleanupHours, maxSessionCleanupHours) + } for name, m := range map[string]int{"guests": s.ShareLinkGuestMinutes, "signed-in users": s.ShareLinkUserMinutes} { if m != 0 && (m < minShareLinkMinutes || m > maxShareLinkMinutes) { return s, fmt.Errorf("the longest a share link works for %s must be between %d and %d minutes (or empty)", name, minShareLinkMinutes, maxShareLinkMinutes) @@ -633,6 +639,22 @@ const ( defaultShareLinkSignedIn = 10080 // a signed-in user: 7 days, as long as a sign-in lasts ) +// how often the server cleans up the shared sessions, in hours +const ( + minSessionCleanupHours = 1 + maxSessionCleanupHours = 720 + defaultSessionCleanupHours = 24 +) + +// sessionCleanupInterval is the time between two cleanups in force: the one set, or a day. +func (s SiteSettings) sessionCleanupInterval() time.Duration { + h := s.SessionCleanupHours + if h < minSessionCleanupHours || h > maxSessionCleanupHours { + h = defaultSessionCleanupHours + } + return time.Duration(h) * time.Hour +} + // shareLinkMax is the longest a share link works in force for a guest or a signed-in user: the one set, or the built-in time. func (s SiteSettings) shareLinkMax(guest bool) int { m, def := s.ShareLinkUserMinutes, defaultShareLinkSignedIn @@ -910,6 +932,9 @@ func settingsChanges(a, b SiteSettings) []string { if a.previewLinkLife(false) != b.previewLinkLife(false) { out = append(out, fmt.Sprintf("preview link works %d minutes for signed-in users", int(b.previewLinkLife(false).Minutes()))) } + if a.sessionCleanupInterval() != b.sessionCleanupInterval() { + out = append(out, fmt.Sprintf("shared session cleanup every %d hours", int(b.sessionCleanupInterval().Hours()))) + } if a.shareLinkMax(true) != b.shareLinkMax(true) { out = append(out, fmt.Sprintf("share link works at most %d minutes for guests", b.shareLinkMax(true))) } From 40a11c0b7aa2e8519be3f127bfde8d18a0f0794e Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Sat, 14 Nov 2026 12:09:26 +0530 Subject: [PATCH 03/10] Give code links an end: a day chosen in a calendar, a most set by the admin, a weekly cleanup - Creating a code link takes the last day it works (a date input from tomorrow up to the admin's most, a month ahead picked for you); the server checks the range and keeps the end as 23:59:59 UTC of that day. - An ended link answers 410 with no code, and /s/ says it has expired. - Links made before they had an end are given one a month from the first start with this, once; the server removes ended links every week (Health shows the last run). - Admin: "Longest a code link works" (1 to 365 days, default 30) and an Expires column in the list of shared code. Co-Authored-By: Claude Sonnet 5.5 --- docs/lld/01-server-and-routing.md | 2 +- docs/lld/06-frontend.md | 2 +- e2e/tests/code-link-expiry.spec.js | 43 ++++++ src/js/src/page/17-share-code.js | 30 ++++- src/resources/about.html | 2 +- src/resources/css/ui-refresh.css | 5 + src/resources/index.html | 6 + src/resources/js/admin.js | 7 + src/resources/knowledge/code-links.md | 4 +- src/server/admin_content.go | 4 +- src/server/admin_health.go | 2 + src/server/knowledge_test.go | 1 + src/server/server.go | 1 + src/server/settings.go | 28 ++++ src/server/snippet_expiry.go | 182 ++++++++++++++++++++++++++ src/server/snippet_expiry_test.go | 164 +++++++++++++++++++++++ src/server/snippets.go | 80 +++++++++-- 17 files changed, 546 insertions(+), 17 deletions(-) create mode 100644 e2e/tests/code-link-expiry.spec.js create mode 100644 src/server/snippet_expiry.go create mode 100644 src/server/snippet_expiry_test.go diff --git a/docs/lld/01-server-and-routing.md b/docs/lld/01-server-and-routing.md index 9a71c9f3..2c594fb9 100644 --- a/docs/lld/01-server-and-routing.md +++ b/docs/lld/01-server-and-routing.md @@ -98,7 +98,7 @@ All paths are relative to `pathPrefix`: `/`, or `//` with `--random-url` | `/practice/dsa-questions` | GET | `handlePracticeQuestions` | public | Renders `static/practice.html`. | | `/practice/progress` | GET, PUT (POST accepted) | `handlePracticeProgress` | session | A signed-in user's practice questions and done marks (LLD 05). GET returns the stored document, or `{"signedIn": false}` for guests. PUT merges the body into the stored copy and returns the result; guests get 401. Bodies up to 2 MB, 200 questions, 1,000 marks; 240 writes per user per 10 minutes. | | `/sitemap.xml` | GET | `handleSitemap` | public | The home page, practice list, docs, about, privacy and terms pages, and every language page. `robots.txt` points to it. | -| `/snippet` | GET, POST | `handleSnippet` | public, 30 new links per IP per 10 minutes | Share-code links. POST `{lang, code}` (code up to 64 KB) stores a snapshot and returns `{id, url}`; GET `?id=` returns `{lang, code, created}`. Errors: 400 unknown language or no code, 413 too large, 429 rate limit, 503 store unavailable. | +| `/snippet` | GET, POST | `handleSnippet` | public, 30 new links per IP per 10 minutes | Share-code links. POST `{lang, code, expires}` (code up to 64 KB; `expires` the last day the link works, `YYYY-MM-DD` in UTC, from today up to today plus the admin's most, `snippetMaxDays`, default 30; empty means a month, or the most when shorter) stores a snapshot with an end (23:59:59 UTC of that day) and returns `{id, url, expires}`; GET `?id=` returns `{lang, code, created, expires}`, or **410** `{expired: true}` with no code once the end has passed (`/s/` answers 410 with a page too). Errors: 400 unknown language, no code, or a day outside the range (with the reason), 413 too large, 429 rate limit, 503 store unavailable. A link made before links had an end is given one a month from the first start that has this (`stampOldSnippets`, `snippet_expiry.go`, once; the most when shorter), and every week the server removes the links whose end has passed (`removeExpiredSnippets`; 10 minutes after the start, then every 7 days; not on a worker); the Health page shows the last run ("Code link cleanup"). Settings: `snippetMaxDays` ("Longest a code link works"), 1 to 365, default 30. | | `/s/` | GET | `handleSnippetLink` | public | Redirects (302) to the language's page with `?s=`, or to `/?repl=&s=`; 404 page for an unknown id. | | `/login` | GET, POST | `handleLoginSession` | public | GET: current session status as JSON. POST: create a session from the Firebase sign-in result (LLD 05). | | `/logout` | POST | `handleLogoutSession` | session | Deletes the session and cookie. | diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index c7662fea..10d0b3e8 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -84,7 +84,7 @@ What the parts do, together: - **Hero and landing.** Language chips and cards (`pickLanguage`), the usage heading, the docker command copy and the request form. - **Genie nudge.** `setupGenieErrorNudge` watches terminal output for the first error. - **Language pages.** `langPageForRepl` and a `#optionlist` change handler move the address to the language's page with `history.replaceState` and update the title, hero and canonical link (variants without a page keep the address). -- **Share-code links.** "Create code link" in the Share popover POSTs the editor content to `/snippet` and shows the link. On a page opened with `?s=`, `applySharedSnippet` puts the saved code in the editor once the language's starter code has loaded, and says so in a notice. +- **Share-code links.** "Create code link" in the Share popover POSTs the editor content and a day (`#code-link-until`, a date input: `min` tomorrow, `max` today plus the admin's most from `site_settings.snippetMaxDays`, value a month from today or the most when shorter, all counted in UTC like the server, `setupCodeLinkDate`) to `/snippet` and shows the link and "Works until ...". A link that has expired answers 410, and the notice says so. Test: `e2e/tests/code-link-expiry.spec.js`. On a page opened with `?s=`, `applySharedSnippet` puts the saved code in the editor once the language's starter code has loaded, and says so in a notice. - **Accessibility helpers.** `syncTerminalTabsA11y` and arrow-key handling for the terminal tabs, the Esc-then-Tab way out of the editor and terminal (`moveFocusOutOf`), `describeCodeInputs`, and the Share popover's keyboard support. - **Empty Files panel.** `updateFilesEmptyState` (LLD 04). diff --git a/e2e/tests/code-link-expiry.spec.js b/e2e/tests/code-link-expiry.spec.js new file mode 100644 index 00000000..7e43c1a7 --- /dev/null +++ b/e2e/tests/code-link-expiry.spec.js @@ -0,0 +1,43 @@ +// A code link has an end (page/17-share-code.js, server/snippets.go): the calendar offers tomorrow up to the most the site +// allows, starts on a month ahead, and the server refuses a day outside that. +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +const utcDay = (n) => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +}; + +test("a code link is made with an end date chosen in the calendar", async ({ page }) => { + test.setTimeout(120 * 1000); + await page.goto("/"); + await page.waitForFunction(() => window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await waitForTerminalReady(page); + await page.locator("#share-btn-1").click(); + const date = page.locator("#code-link-until"); + await expect(date).toBeVisible(); + // from tomorrow to the most the site allows (a month by default), and a month is picked + expect(await date.getAttribute("min")).toBe(utcDay(1)); + expect(await date.getAttribute("max")).toBe(utcDay(30)); + expect(await date.inputValue()).toBe(utcDay(30)); + await expect(page.locator("#code-link-until-hint")).toContainText("up to 30 days"); + + // a day chosen: the link says when it works until + await date.fill(utcDay(3)); + await page.evaluate(() => editor.env.editor.setValue("print('expiry')", -1)); + await page.locator("#create-code-link").click(); + await expect(page.locator("#code-link-ends")).toContainText("Works until", { timeout: 20 * 1000 }); + const id = (await page.locator("#code-link").inputValue()).split("/s/")[1]; + const got = await page.evaluate((i) => fetch("/snippet?id=" + i).then((r) => r.json()), id); + const end = new Date(got.expires * 1000); + expect(end.toISOString().slice(0, 10)).toBe(utcDay(3)); + expect(end.toISOString().slice(11, 19)).toBe("23:59:59"); + + // the server keeps the limit whatever the page says + const far = await page.evaluate((day) => fetch("/snippet", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang: "python", code: "x", expires: day }) }).then(async (r) => ({ status: r.status, body: await r.json() })), utcDay(31)); + expect(far.status).toBe(400); + expect(far.body.error).toContain("at most 30 days"); + const past = await page.evaluate((day) => fetch("/snippet", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang: "python", code: "x", expires: day }) }).then((r) => r.status), utcDay(-1)); + expect(past).toBe(400); +}); diff --git a/src/js/src/page/17-share-code.js b/src/js/src/page/17-share-code.js index 9c773b5c..d5a70461 100644 --- a/src/js/src/page/17-share-code.js +++ b/src/js/src/page/17-share-code.js @@ -32,7 +32,32 @@ function onStarterCodeLoaded(lang) { applySharedSnippet(); } +// The day a new code link works until: the calendar (a date input) offers tomorrow up to today plus the most the admin allows, and +// starts on a month from today, or on the most when that is shorter. Days are counted in UTC, as the server counts them +// (server/snippets.go, snippetExpiryFor), so a day it offers is a day the server takes. +function utcDay(offsetDays) { + var d = new Date(); + d.setUTCDate(d.getUTCDate() + offsetDays); + return d.toISOString().slice(0, 10); +} +function codeLinkMaxDays() { + var n = Number((window.site_settings || {}).snippetMaxDays); + return n > 0 ? n : 30; +} +function setupCodeLinkDate() { + var input = document.getElementById("code-link-until"); + if (!input) return; + var max = codeLinkMaxDays(); + input.min = utcDay(1); + input.max = utcDay(max); + input.value = utcDay(Math.min(30, max)); + var hint = document.getElementById("code-link-until-hint"); + if (hint) hint.textContent = "Chosen from tomorrow up to " + (max === 1 ? "1 day" : max + " days") + " (the most this site allows). " + (max >= 30 ? "A month is picked for you." : "The most is picked for you."); +} +window.setupCodeLinkDate = setupCodeLinkDate; + $(function () { + setupCodeLinkDate(); var params = new URLSearchParams(location.search); var id = params.get("s"); if (id && /^[A-Za-z0-9]{8}$/.test(id) && isMaster()) { @@ -69,7 +94,7 @@ $(function () { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lang: $("#optionlist").val(), code: code }) + body: JSON.stringify({ lang: $("#optionlist").val(), code: code, expires: $("#code-link-until").val() || "" }) }) .then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); }) .then(function (res) { @@ -78,6 +103,9 @@ $(function () { return; } $("#code-link").val(res.body.url); + var ends = $("#code-link-ends"); + if (!ends.length) ends = $('').insertAfter("#code-link-row"); + ends.text(res.body.expires ? "Works until " + new Date(res.body.expires * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }) + ". Then it says it has expired." : ""); $("#code-link-row").prop("hidden", false); $("#code-link").trigger("focus").trigger("select"); btn.textContent = "Create a new link"; diff --git a/src/resources/about.html b/src/resources/about.html index 32b0bde1..fd2c23f6 100644 --- a/src/resources/about.html +++ b/src/resources/about.html @@ -68,7 +68,7 @@

About OpenREPL

What you can do

  • Run and debug your code, with gdb for C, C++, Go, Rust and assembly.
  • -
  • Share a live link so others can watch and type along, or make a code link to a copy of your code.
  • +
  • Share a live link so others can watch and type along, or make a code link to a copy of your code that works until a day you choose.
  • Fork a terminal to run a server and a client side by side.
  • Keep files in your workspace, and sign in to keep them between visits.
  • Ask Genie, the AI helper, about your code and your terminal output, review its suggestions as a diff, let it carry out a task in Agent mode when you are signed in (it can also work in your terminal, open terminal tabs and manage your files), or right-click selected code to explain, fix, comment or test it. Add files, folders and selected code to the chat from the Files panel and the editor, so Genie reads exactly what you point at. Genie keeps working when you close its panel, and the Ask Genie button tells you when it is done or needs you. Pin it to dock it beside the editor.
  • diff --git a/src/resources/css/ui-refresh.css b/src/resources/css/ui-refresh.css index bdb7bb76..cac9347a 100644 --- a/src/resources/css/ui-refresh.css +++ b/src/resources/css/ui-refresh.css @@ -2953,6 +2953,11 @@ body .xterm-helper-textarea:focus-visible { outline: none !important; } .popover__btn:disabled { opacity: 0.6; cursor: default; filter: none; } .popover__btn--danger { color: #f3b4ae; background: transparent; border: 1px solid #6b3434; } .session-over #file-browser, .session-over #terminal-div, .session-over #editor-tabs .etab__close { pointer-events: none; opacity: 0.6; } +.share-code__expiry { display: flex; align-items: center; gap: 10px; margin: 8px 0 0; } +.share-code__expiry label { font-size: 13px; color: var(--muted-1); white-space: nowrap; } +.share-code__date { flex: 1; min-width: 0; height: 34px; padding: 0 10px; font: inherit; font-size: 13.5px; color: var(--paper); background: var(--ink-700); border: 1px solid var(--ink-600); border-radius: 8px; color-scheme: dark; } +.share-code__date:disabled { opacity: 0.6; } +.share-code__ends { margin: 8px 0 0; font-size: 12.5px; color: var(--accent-color); } .share-session { margin-top: 12px; } .share-session[hidden] { display: none; } .share-session__row { display: flex; align-items: center; gap: 10px; } diff --git a/src/resources/index.html b/src/resources/index.html index 822f8d94..c308a437 100644 --- a/src/resources/index.html +++ b/src/resources/index.html @@ -331,6 +331,11 @@

    {{.Page.Heading}

    Share code

    A link to a copy of the code in the editor. It opens with the code and language loaded, and doesn't change if you keep editing.

    + +