-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(updater): tier 1 — notify admin and pad users of available updates #7601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
07e75b1
db2fcd8
3ba019d
7bbda2a
59c96b9
39c3820
7610f2a
dc14c0e
892490e
fe8978f
a43a31e
dac1b03
1d7e68c
571d648
b695d01
732894c
70a04f8
abc9a05
46b6129
fa97f20
fd13ebd
6327502
6cac3a5
0f9f6af
774fca7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you useSuspense Query you can save the manual return null and show with suspense boundary that the ui is loading e.g. with a loading spinner |
||
| if (updateStatus.currentVersion === updateStatus.latest.version) return null; | ||
|
|
||
| return ( | ||
| <div className="update-banner" role="status"> | ||
| <strong><Trans i18nKey="update.banner.title"/></strong>{' '} | ||
| <span> | ||
| <Trans | ||
| i18nKey="update.banner.body" | ||
| values={{latest: updateStatus.latest.version, current: updateStatus.currentVersion}} | ||
| /> | ||
| </span>{' '} | ||
| <Link to="/update">{t('update.banner.cta')}</Link> | ||
| </div> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import {useEffect, useState} from 'react'; | ||
| import {Trans, useTranslation} from 'react-i18next'; | ||
| import {useStore} from '../store/store'; | ||
|
|
||
| type FetchState = | ||
| | {kind: 'loading'} | ||
| | {kind: 'disabled'} | ||
| | {kind: 'unauthorized'} | ||
| | {kind: 'error', status: number} | ||
| | {kind: 'ok'}; | ||
|
|
||
| export const UpdatePage = () => { | ||
| const {t} = useTranslation(); | ||
| const us = useStore((s) => s.updateStatus); | ||
| const setUpdateStatus = useStore((s) => s.setUpdateStatus); | ||
| // Self-fetch so the page renders an explicit state even if UpdateBanner's | ||
| // best-effort fetch never landed (route returns 404 when tier=off, 401/403 | ||
| // if requireAdminForStatus is set, or a transient network error). | ||
| const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'}); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as this :) |
||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
| fetch('/admin/update/status', {credentials: 'same-origin'}) | ||
| .then(async (r) => { | ||
| if (cancelled) return; | ||
| if (r.ok) { | ||
| const data = await r.json(); | ||
| setUpdateStatus(data); | ||
| setFetchState({kind: 'ok'}); | ||
| } else if (r.status === 404) { | ||
| setFetchState({kind: 'disabled'}); | ||
| } else if (r.status === 401 || r.status === 403) { | ||
| setFetchState({kind: 'unauthorized'}); | ||
| } else { | ||
| setFetchState({kind: 'error', status: r.status}); | ||
| } | ||
| }) | ||
| .catch(() => { | ||
| if (!cancelled) setFetchState({kind: 'error', status: 0}); | ||
| }); | ||
| return () => { cancelled = true; }; | ||
| }, [setUpdateStatus]); | ||
|
|
||
| if (fetchState.kind === 'loading') { | ||
| return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>; | ||
| } | ||
| if (fetchState.kind === 'disabled') { | ||
| return ( | ||
| <div className="update-page"> | ||
| <h1><Trans i18nKey="update.page.title"/></h1> | ||
| <p>{t('update.page.disabled', {defaultValue: 'Update checks are disabled (updates.tier = "off").'})}</p> | ||
| </div> | ||
| ); | ||
| } | ||
| if (fetchState.kind === 'unauthorized') { | ||
| return ( | ||
| <div className="update-page"> | ||
| <h1><Trans i18nKey="update.page.title"/></h1> | ||
| <p>{t('update.page.unauthorized', {defaultValue: 'You are not authorised to view update status.'})}</p> | ||
| </div> | ||
| ); | ||
| } | ||
| if (fetchState.kind === 'error' || !us) { | ||
| const status = fetchState.kind === 'error' ? fetchState.status : 0; | ||
| return ( | ||
| <div className="update-page"> | ||
| <h1><Trans i18nKey="update.page.title"/></h1> | ||
| <p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}</p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const upToDate = !us.latest || us.currentVersion === us.latest.version; | ||
|
Comment on lines
+12
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Updates off hangs admin page If the status endpoint is missing or returns non-OK (notably when updates.tier='off' disables route registration), the admin UpdatePage never initiates a fetch and stays stuck on "Loading..." indefinitely. Agent Prompt
|
||
|
|
||
| return ( | ||
| <div className="update-page"> | ||
| <h1><Trans i18nKey="update.page.title"/></h1> | ||
| <dl> | ||
| <dt><Trans i18nKey="update.page.current"/></dt> | ||
| <dd>{us.currentVersion}</dd> | ||
| <dt><Trans i18nKey="update.page.latest"/></dt> | ||
| <dd>{us.latest ? us.latest.version : '—'}</dd> | ||
| <dt><Trans i18nKey="update.page.last_check"/></dt> | ||
| <dd>{us.lastCheckAt ?? '—'}</dd> | ||
| <dt><Trans i18nKey="update.page.install_method"/></dt> | ||
| <dd>{us.installMethod}</dd> | ||
| <dt><Trans i18nKey="update.page.tier"/></dt> | ||
| <dd>{us.tier}</dd> | ||
| </dl> | ||
| {upToDate ? ( | ||
| <p><Trans i18nKey="update.page.up_to_date"/></p> | ||
| ) : us.latest ? ( | ||
| <> | ||
| <h2><Trans i18nKey="update.page.changelog"/></h2> | ||
| <pre style={{whiteSpace: 'pre-wrap'}}>{us.latest.body}</pre> | ||
| <p><a href={us.latest.htmlUrl} rel="noreferrer noopener" target="_blank">{us.latest.htmlUrl}</a></p> | ||
| </> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default UpdatePage; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| # 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", | ||
| "requireAdminForStatus": false | ||
| }, | ||
| "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. | | ||
| | `updates.requireAdminForStatus` | `false` | Lock the `/admin/update/status` endpoint to authenticated admin sessions. Default `false` matches existing Etherpad behavior — `/health` already exposes `releaseId` publicly, and changelog data comes from a public GitHub release. Set `true` to hide the full update payload from non-admins without disabling the updater (`tier: "off"` is the heavier opt-out that removes the endpoints entirely). | | ||
| | `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 `<!-- updater: vulnerable-below X.Y.Z -->` 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/<repo>/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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Best would be to use tanstack query. If you use fetch in a call it is mostly always wrong see https://react.dev/reference/react/useEffect#fetching-data-with-effects