diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c23547eb78..f3cc1381ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# Unreleased + +### Notable enhancements + +- New built-in self-update subsystem (Tier 1: notify). + - Periodic check against the GitHub Releases API for the configured repo (default `ether/etherpad`). Configurable via the new `updates.*` settings block, default tier `"notify"`. Set `updates.tier` to `"off"` to disable entirely. + - The admin UI shows a banner and a dedicated "Etherpad updates" page with the current version, latest version, install method, and changelog. + - Pad users see a discreet footer badge **only** when the running version is severely outdated (one or more major versions behind) or flagged as vulnerable in a recent release manifest. The public endpoint that drives this never leaks the version string itself. + - New top-level `adminEmail` setting. When set, the updater emails the admin on first detection of severe / vulnerable status, with escalating cadence (weekly while vulnerable, monthly while severely outdated). PR 1 ships the dedupe + cadence logic; real SMTP wiring lands in a follow-up PR. + - Tier 1 ships in this release. Tiers 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases. + - See `doc/admin/updates.md` for full configuration. + # 2.7.1 ### Notable enhancements and fixes diff --git a/admin/src/App.tsx b/admin/src/App.tsx index ae23ab3d340..27d5a2ae367 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -6,7 +6,8 @@ import {NavLink, Outlet, useNavigate} from "react-router-dom"; import {useStore} from "./store/store.ts"; import {LoadingScreen} from "./utils/LoadingScreen.tsx"; import {Trans, useTranslation} from "react-i18next"; -import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react"; +import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell} from "lucide-react"; +import {UpdateBanner} from "./components/UpdateBanner"; const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : '' export const App = () => { @@ -105,6 +106,7 @@ export const App = () => {
  • Communication
  • +
  • @@ -112,6 +114,7 @@ export const App = () => { setSidebarOpen(!sidebarOpen) }}>
    +
    diff --git a/admin/src/components/UpdateBanner.tsx b/admin/src/components/UpdateBanner.tsx new file mode 100644 index 00000000000..36f1faddc29 --- /dev/null +++ b/admin/src/components/UpdateBanner.tsx @@ -0,0 +1,35 @@ +import {useEffect} from 'react'; +import {Link} from 'react-router-dom'; +import {Trans, useTranslation} from 'react-i18next'; +import {useStore} from '../store/store'; + +export const UpdateBanner = () => { + const {t} = useTranslation(); + const updateStatus = useStore((s) => s.updateStatus); + const setUpdateStatus = useStore((s) => s.setUpdateStatus); + + useEffect(() => { + let cancelled = false; + fetch('/admin/update/status', {credentials: 'same-origin'}) + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data && !cancelled) setUpdateStatus(data); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [setUpdateStatus]); + + if (!updateStatus || !updateStatus.latest) return null; + if (updateStatus.currentVersion === updateStatus.latest.version) return null; + + return ( +
    + {' '} + + + {' '} + {t('update.banner.cta')} +
    + ); +}; diff --git a/admin/src/index.css b/admin/src/index.css index 3190b153da6..64eae3ccc4f 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -895,3 +895,30 @@ input, button, select, optgroup, textarea { .manage-pads-header { display: flex; } + +/* Update banner — shown on every admin page when a new version is available. */ +.update-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + margin: 0 0 12px 0; + background: #fff3cd; + color: #664d03; + border: 1px solid #ffe69c; + border-radius: 4px; + font-size: 14px; +} +.update-banner a { + color: inherit; + text-decoration: underline; + font-weight: 500; +} + +/* Update page layout. */ +.update-page { padding: 16px 0; } +.update-page h1 { margin-bottom: 16px; } +.update-page dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 24px; } +.update-page dt { font-weight: 600; color: #555; } +.update-page dd { margin: 0; } +.update-page pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 12px; font-size: 13px; max-height: 400px; overflow: auto; } diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 5efc26de6ba..c7dcc456bf6 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -13,6 +13,7 @@ import i18n from "./localization/i18n.ts"; import {PadPage} from "./pages/PadPage.tsx"; import {ToastDialog} from "./utils/Toast.tsx"; import {ShoutPage} from "./pages/ShoutPage.tsx"; +import {UpdatePage} from "./pages/UpdatePage.tsx"; const router = createBrowserRouter(createRoutesFromElements( <>}> @@ -22,6 +23,7 @@ const router = createBrowserRouter(createRoutesFromElements( }/> }/> }/> + }/> }/> diff --git a/admin/src/pages/UpdatePage.tsx b/admin/src/pages/UpdatePage.tsx new file mode 100644 index 00000000000..9ac3a6b2d0c --- /dev/null +++ b/admin/src/pages/UpdatePage.tsx @@ -0,0 +1,40 @@ +import {Trans, useTranslation} from 'react-i18next'; +import {useStore} from '../store/store'; + +export const UpdatePage = () => { + const {t} = useTranslation(); + const us = useStore((s) => s.updateStatus); + + if (!us) return
    {t('admin.loading', {defaultValue: 'Loading...'})}
    ; + + const upToDate = !us.latest || us.currentVersion === us.latest.version; + + return ( +
    +

    +
    +
    +
    {us.currentVersion}
    +
    +
    {us.latest ? us.latest.version : '—'}
    +
    +
    {us.lastCheckAt ?? '—'}
    +
    +
    {us.installMethod}
    +
    +
    {us.tier}
    +
    + {upToDate ? ( +

    + ) : us.latest ? ( + <> +

    +
    {us.latest.body}
    +

    {us.latest.htmlUrl}

    + + ) : null} +
    + ); +}; + +export default UpdatePage; diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts index 1ccc036f491..f3748f47cd4 100644 --- a/admin/src/store/store.ts +++ b/admin/src/store/store.ts @@ -3,6 +3,23 @@ import {Socket} from "socket.io-client"; import {PadSearchResult} from "../utils/PadSearch.ts"; import {InstalledPlugin} from "../pages/Plugin.ts"; +export interface UpdateStatusPayload { + currentVersion: string; + latest: null | { + version: string; + tag: string; + body: string; + publishedAt: string; + prerelease: boolean; + htmlUrl: string; + }; + lastCheckAt: string | null; + installMethod: string; + tier: string; + policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string}; + vulnerableBelow: Array<{announcedBy: string; threshold: string}>; +} + type ToastState = { description?:string, title: string, @@ -25,7 +42,9 @@ type StoreState = { pads: PadSearchResult|undefined, setPads: (pads: PadSearchResult)=>void, installedPlugins: InstalledPlugin[], - setInstalledPlugins: (plugins: InstalledPlugin[])=>void + setInstalledPlugins: (plugins: InstalledPlugin[])=>void, + updateStatus: UpdateStatusPayload | null, + setUpdateStatus: (s: UpdateStatusPayload) => void, } @@ -48,5 +67,7 @@ export const useStore = create()((set) => ({ pads: undefined, setPads: (pads)=>set({pads}), installedPlugins: [], - setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}) + setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}), + updateStatus: null, + setUpdateStatus: (s) => set({updateStatus: s}), })); diff --git a/doc/admin/updates.md b/doc/admin/updates.md new file mode 100644 index 00000000000..74b133794c7 --- /dev/null +++ b/doc/admin/updates.md @@ -0,0 +1,81 @@ +# Etherpad updates + +Etherpad ships with a built-in update subsystem. **Tier 1 (notify)** is enabled by default: a banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No automatic execution happens at this tier — admins are simply informed. + +Tiers 2 (manual click), 3 (auto with grace window), and 4 (autonomous in maintenance window) are designed but not yet implemented. They will land in subsequent releases. + +## Settings + +In `settings.json`: + +```jsonc +{ + "updates": { + "tier": "notify", + "source": "github", + "channel": "stable", + "installMethod": "auto", + "checkIntervalHours": 6, + "githubRepo": "ether/etherpad" + }, + "adminEmail": null +} +``` + +| Setting | Default | Notes | +| --- | --- | --- | +| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. | +| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. | +| `updates.channel` | `"stable"` | Reserved. Stable releases only. | +| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. | +| `updates.checkIntervalHours` | `6` | How often to poll GitHub Releases. | +| `updates.githubRepo` | `"ether/etherpad"` | Override for forks. | +| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. | + +## What "outdated" means + +- **`severe`** — running at least one major version behind the latest release. +- **`vulnerable`** — the running version is below a `vulnerable-below` threshold announced in a recent release. Releases declare these via a `` HTML comment in their body. The newest such directive wins. + +## Email cadence (when `adminEmail` is set) + +| Trigger | First send | Repeat | +| --- | --- | --- | +| Vulnerable status detected | Immediate | Weekly while still vulnerable | +| New release announced while still vulnerable | Immediate | n/a (one event per tag change) | +| Severely outdated detected | Immediate | Monthly while still severely outdated | +| Up to date | No email | — | + +If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it. + +PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired. + +## Pad-side badge + +Pad users see no version information by default. A small badge appears in the bottom-right corner only when: + +- The instance is `severe` (one or more major versions behind), or +- The instance is `vulnerable` (running below an announced threshold). + +The public endpoint `/api/version-status` returns only `{outdated: null|"severe"|"vulnerable"}` — it never leaks the running version, so attackers do not gain a fingerprint vector. + +## Disabling everything + +Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render. + +## Privacy + +The version check sends no telemetry. Etherpad fetches the public GitHub Releases API (`api.github.com/repos//releases/latest`) with `If-None-Match` to be cache-friendly. The only metadata GitHub sees is the same as any other GitHub API client — your IP and a `User-Agent: etherpad-self-update` header. No instance ID, no version, no identifiers travel upstream. + +## How install method is detected + +`updates.installMethod` defaults to `"auto"`, which uses these heuristics in order: + +1. `/.dockerenv` exists → `"docker"`. +2. `.git/` directory present and the install root is writable → `"git"`. +3. `package-lock.json` present and writable → `"npm"`. +4. Otherwise → `"managed"`. + +Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout). + +In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers. diff --git a/docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md b/docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md new file mode 100644 index 00000000000..ac3aefc006f --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md @@ -0,0 +1,2335 @@ +# Auto-Update PR 1 — Tier 1 (Notify) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Tier 1 of the four-tier auto-update feature: every Etherpad admin sees a banner on `/admin` when their instance is behind, pad users see a discreet badge only when severely outdated or running a flagged-vulnerable version, and the configured `adminEmail` receives an escalating-cadence nudge. **No execution code in this PR** — that lands in PR 2 (Tier 2 manual). + +**Architecture:** A new `src/node/updater/` subsystem. Periodic poll of GitHub Releases, in-memory + on-disk state at `var/update-state.json`, two HTTP endpoints (`GET /admin/update/status`, `GET /api/version-status`), an admin-UI banner + read-only update page, a pad-UI footer badge, and a `Notifier` that emails on first detection of severe/vulnerable status (escalating: weekly while vulnerable, monthly while severe). Settings additions: `updates.*` block (mainly `tier`, default `"notify"`) and a top-level `adminEmail`. + +**Tech Stack:** Node 18+ (native `fetch`), TypeScript, Express 5, vitest (unit), mocha (legacy backend integration), Playwright (UI), React + react-router + i18next (admin UI), pnpm monorepo. + +**Spec:** `/home/jose/etherpad/etherpad-lite/docs/superpowers/specs/2026-04-25-auto-update-design.md` + +**Conventions:** +- All pushes land on `johnmclear/etherpad-lite` — never `ether/etherpad-lite` directly. +- Working dir: `/home/jose/etherpad/etherpad-lite`. +- Backend unit tests use **vitest** under `src/tests/backend-new/specs/`; integration / API tests use **mocha** under `src/tests/backend/specs/`. The differences matter: vitest uses `import {describe, it, expect} from 'vitest'`, mocha uses `describe`/`it` globals + `assert`. +- Run unit tests: `cd src && pnpm test:vitest -- run tests/backend-new/specs/updater/`. +- Run integration tests: `cd src && pnpm test -- --grep ""`. +- Run admin Playwright: `cd src && pnpm test-admin`. +- Run pad Playwright: `cd src && pnpm test-ui`. +- Run type-check: `pnpm ts-check` from repo root. +- Commit messages follow the existing style (e.g. `feat(updater): ...`, `test(updater): ...`). +- Frequent commits: every passing test → commit. + +--- + +## Task 0: Branch off fork + +**Files:** none. + +- [ ] **Step 1: Confirm clean working tree** + +```bash +cd /home/jose/etherpad/etherpad-lite +git status +``` + +Expected: working tree clean, current branch may be unrelated. If there are uncommitted changes other than the spec doc, stop and surface to the user. + +- [ ] **Step 2: Make sure `develop` is up-to-date from `origin` (ether)** + +```bash +git fetch origin develop +``` + +- [ ] **Step 3: Create branch off origin/develop** + +```bash +git checkout -b feat/auto-update-tier1 origin/develop +``` + +- [ ] **Step 4: Cherry-pick the design spec onto the new branch** + +```bash +# The spec was written into the working tree but not committed. +# It should still be present after the checkout because it's untracked. +git status +# Expect: "Untracked files: docs/superpowers/specs/2026-04-25-auto-update-design.md" +git add docs/superpowers/specs/2026-04-25-auto-update-design.md +git commit -m "docs(updater): add four-tier auto-update design spec" +``` + +If `git status` after step 3 doesn't show the spec as untracked (e.g., because checkout placed it at a different path or removed it), Read the file at `/home/jose/etherpad/etherpad-lite/docs/superpowers/specs/2026-04-25-auto-update-design.md` to verify it exists, then add and commit it. + +- [ ] **Step 5: Add this plan to the same first commit (amend)** + +```bash +git add docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md +git commit --amend --no-edit +``` + +- [ ] **Step 6: Push to fork** + +```bash +git push -u fork feat/auto-update-tier1 +``` + +--- + +## Task 1: Shared types module + +Pure-types module. No tests needed (compiler is the test). + +**Files:** +- Create: `src/node/updater/types.ts` + +> **Path note:** From the repo root `/home/jose/etherpad/etherpad-lite`, source files live under `src/node/`, `src/static/`, `src/locales/`, etc. Tests live under `src/tests/backend/`, `src/tests/backend-new/`, `src/tests/frontend-new/`. The `src/` directory IS the `ep_etherpad-lite` pnpm workspace package — when running test/dev/build scripts via pnpm, `cd src` first (or use `pnpm --filter ep_etherpad-lite run