Skip to content

Commit 49bdc8a

Browse files
feat(updates): "What's new" dialog + optional automatic self-update
Two complementary features around the existing self-update path: What's new — after a build is deployed, every signed-in user sees a one-time dialog with the release notes for everything that changed since the build they last acknowledged. Notes are Markdown generated from the commit messages (bookkeeping trailers stripped) and rendered with the in-app Markdown pipeline. Tracked per user via User.lastSeenVersion, so it shows exactly once per update and never replays history to new users. Automatic updates — an opt-in global setting (Setting.autoUpdateEnabled) makes the instance check GitHub a few times a day and apply a newer build automatically, reusing the health-checked, auto-rollback self-update. Off by default, requires SELF_UPDATE_ENABLED, backs off for a day after a failed attempt so a persistent failure can't loop. Toggle lives in the admin Update panel. - API: GET /version/whats-new (+ /seen) for any user; getChangelog / getRecentChangelog in the version service; startAutoUpdate scheduler wired into the boot/shutdown lifecycle; autoUpdateEnabled surfaced and editable via /admin/version and /admin/settings. - Web: WhatsNew modal mounted in the app shell; auto-update switch in the admin Update panel. FR/EN i18n at parity. - Tests: integration coverage for the first-contact-silent → show-once → dismissed lifecycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNvAeoRFGJkRnaBGwMstZ9
1 parent 15255c3 commit 49bdc8a

15 files changed

Lines changed: 381 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ the source.
3838
opt-in zero-knowledge vault whose contents are encrypted in the browser and never
3939
readable by the server.
4040
- **Administration** — create users, set per-user quotas, define the global cap, manage
41-
Quick-Upload codes and read the audit log.
41+
Quick-Upload codes and read the audit log. One-click (or **automatic**) self-update from
42+
GitHub, with a "What's new" dialog that shows each user the release notes once per update.
4243

4344
## Architecture
4445

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "lastSeenVersion" TEXT;
3+
4+
-- AlterTable
5+
ALTER TABLE "Setting" ADD COLUMN "autoUpdateEnabled" BOOLEAN NOT NULL DEFAULT false;

apps/api/prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ model User {
6969
lastLoginAt DateTime?
7070
// If set, a daily job permanently deletes the user's files after this many days without a login.
7171
autoDeleteAfterDays Int?
72+
// The build (git SHA) for which this user last dismissed the "What's new" dialog. null until
73+
// they first load the app; lets us show release notes exactly once per update, per user.
74+
lastSeenVersion String?
7275
createdAt DateTime @default(now())
7376
updatedAt DateTime @updatedAt
7477
@@ -331,6 +334,9 @@ model Setting {
331334
globalStorageCapBytes BigInt @default(0)
332335
// Optional VirusTotal API key set from the admin panel; overrides VIRUSTOTAL_API_KEY in .env.
333336
virustotalApiKey String?
337+
// When true, the instance checks GitHub periodically and applies a newer build automatically
338+
// (requires SELF_UPDATE_ENABLED). Off by default — auto-applying code is opt-in.
339+
autoUpdateEnabled Boolean @default(false)
334340
updatedAt DateTime @updatedAt
335341
}
336342

apps/api/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createContext } from './context.js';
77
import { buildServer } from './server.js';
88
import { startRemoteWorker } from './worker/remote-worker.js';
99
import { startMaintenance } from './services/maintenance.js';
10+
import { startAutoUpdate } from './services/autoUpdate.js';
1011
import { prisma } from './db.js';
1112

1213
// Prefer IPv4 when resolving outbound hosts. Some self-hosted boxes advertise IPv6 but have
@@ -31,11 +32,13 @@ async function main(): Promise<void> {
3132
const app = await buildServer(ctx);
3233
const stopWorker = startRemoteWorker(ctx, app.log);
3334
const stopMaintenance = startMaintenance(ctx, app.log);
35+
const stopAutoUpdate = startAutoUpdate(ctx, app.log);
3436

3537
const shutdown = async (signal: string) => {
3638
app.log.info({ signal }, 'shutting down');
3739
stopWorker();
3840
stopMaintenance();
41+
stopAutoUpdate();
3942
await app.close();
4043
await prisma.$disconnect();
4144
process.exit(0);

apps/api/src/routes/admin.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,16 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
157157
const { check } = req.query as { check?: string };
158158
const local = await getLocalVersion();
159159
const status = readUpdateStatus();
160+
const setting = await prisma.setting
161+
.findUnique({ where: { id: GLOBAL_SETTING_ID }, select: { autoUpdateEnabled: true } })
162+
.catch(() => null);
160163
const base = {
161164
current: local,
162165
status,
163166
// True when a "running" status is actually stuck (so the UI can offer a relaunch).
164167
stuck: isUpdateStuck(status),
165168
selfUpdateEnabled: app.ctx.env.SELF_UPDATE_ENABLED,
169+
autoUpdateEnabled: setting?.autoUpdateEnabled ?? false,
166170
repo: app.ctx.env.GITHUB_REPO,
167171
branch: app.ctx.env.UPDATE_BRANCH,
168172
};
@@ -205,9 +209,10 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
205209
if (!body) return;
206210
// Build the update from only the provided fields, so the VT key and the cap can be saved
207211
// independently from their own forms.
208-
const data: { globalStorageCapBytes?: bigint; virustotalApiKey?: string | null } = {};
212+
const data: { globalStorageCapBytes?: bigint; virustotalApiKey?: string | null; autoUpdateEnabled?: boolean } = {};
209213
if (body.globalStorageCapBytes !== undefined) data.globalStorageCapBytes = BigInt(body.globalStorageCapBytes);
210214
if (body.virustotalApiKey !== undefined) data.virustotalApiKey = body.virustotalApiKey.trim() || null;
215+
if (body.autoUpdateEnabled !== undefined) data.autoUpdateEnabled = body.autoUpdateEnabled;
211216

212217
const setting = await prisma.setting.upsert({
213218
where: { id: GLOBAL_SETTING_ID },
@@ -223,6 +228,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
223228
return {
224229
globalStorageCapBytes: Number(setting.globalStorageCapBytes),
225230
virustotalConfigured: app.ctx.virustotal.enabled,
231+
autoUpdateEnabled: setting.autoUpdateEnabled,
226232
};
227233
});
228234

apps/api/src/routes/version.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* User-facing build info: the "What's new" dialog shown once per update, to every user (not just
3+
* admins). The current build is the checked-out git SHA; each user's `lastSeenVersion` records
4+
* the build they last acknowledged, so we can surface the release notes for everything that
5+
* changed since — exactly once.
6+
*/
7+
import type { FastifyPluginAsync } from 'fastify';
8+
import { prisma } from '../db.js';
9+
import { getChangelog, getLocalVersion, getRecentChangelog } from '../services/version.js';
10+
11+
export const versionRoutes: FastifyPluginAsync = async (app) => {
12+
app.addHook('preHandler', app.requireAuth);
13+
14+
// GET /version/whats-new — release notes since the user's last acknowledged build, or
15+
// { show: false } when there is nothing new (or this isn't a git deployment).
16+
app.get('/whats-new', async (req) => {
17+
const local = await getLocalVersion();
18+
if (!local.isGit || !local.sha) return { show: false };
19+
20+
const user = await prisma.user.findUniqueOrThrow({
21+
where: { id: req.user!.id },
22+
select: { lastSeenVersion: true },
23+
});
24+
const seen = user.lastSeenVersion;
25+
26+
// First contact (new user, or upgrading from before this feature): remember the current
27+
// build silently so we never dump the entire project history on them.
28+
if (!seen) {
29+
await prisma.user.update({ where: { id: req.user!.id }, data: { lastSeenVersion: local.sha } });
30+
return { show: false };
31+
}
32+
if (seen === local.sha) return { show: false };
33+
34+
// Notes for seen..current; if `seen` is unreachable (force-push/rebase), fall back to the
35+
// most recent commits so the user still sees something meaningful.
36+
const log = (await getChangelog(seen, local.sha)) ?? (await getRecentChangelog(20));
37+
if (!log) {
38+
await prisma.user.update({ where: { id: req.user!.id }, data: { lastSeenVersion: local.sha } });
39+
return { show: false };
40+
}
41+
return { show: true, version: local.shortSha, notes: log.markdown, count: log.count };
42+
});
43+
44+
// POST /version/whats-new/seen — dismiss the dialog for the current build.
45+
app.post('/whats-new/seen', async (req) => {
46+
const local = await getLocalVersion();
47+
if (local.sha) {
48+
await prisma.user.update({ where: { id: req.user!.id }, data: { lastSeenVersion: local.sha } });
49+
}
50+
return { ok: true };
51+
});
52+
};

apps/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { quickRoutes } from './routes/quick.js';
1717
import { remoteRoutes } from './routes/remote.js';
1818
import { shareRoutes } from './routes/shares.js';
1919
import { spaceRoutes } from './routes/spaces.js';
20+
import { versionRoutes } from './routes/version.js';
2021
import { sharePublicRoutes } from './routes/share-public.js';
2122
import { twoFactorRoutes } from './routes/twofa.js';
2223
import { accountRoutes } from './routes/account.js';
@@ -97,6 +98,7 @@ export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
9798
await web.register(sharePublicRoutes, { prefix: '/s' });
9899
await web.register(twoFactorRoutes, { prefix: '/2fa' });
99100
await web.register(accountRoutes, { prefix: '/account' });
101+
await web.register(versionRoutes, { prefix: '/version' });
100102
await web.register(adminRoutes, { prefix: '/admin' });
101103
await web.register(apiV1Routes, { prefix: '/api/v1' });
102104
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Optional automatic self-update. When an admin enables it (Setting.autoUpdateEnabled) and the
3+
* deployment allows self-update, the instance checks GitHub a few times a day and applies a newer
4+
* build automatically — using the same health-checked, auto-rollback self-update path as the
5+
* manual button. Off by default: auto-applying code is opt-in.
6+
*/
7+
import type { FastifyBaseLogger } from 'fastify';
8+
import type { AppContext } from '../context.js';
9+
import { prisma } from '../db.js';
10+
import { getLocalVersion, getRemoteVersion, isUpdateStuck, readUpdateStatus, startUpdate } from './version.js';
11+
12+
// Check a few times a day — frequent enough to pick up releases promptly, light on the GitHub
13+
// rate limit. The first check runs shortly after boot.
14+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
15+
const KICKOFF_MS = 5 * 60 * 1000;
16+
// After a failed update, wait a day before trying again so a persistent failure can't loop.
17+
const FAILED_BACKOFF_MS = 24 * 60 * 60 * 1000;
18+
19+
export async function autoUpdateTick(ctx: AppContext, log: FastifyBaseLogger): Promise<void> {
20+
if (!ctx.env.SELF_UPDATE_ENABLED) return;
21+
const setting = await prisma.setting
22+
.findUnique({ where: { id: 'global' }, select: { autoUpdateEnabled: true } })
23+
.catch(() => null);
24+
if (!setting?.autoUpdateEnabled) return;
25+
26+
const status = readUpdateStatus();
27+
if (status.state === 'running' && !isUpdateStuck(status)) return; // already updating
28+
if (status.state === 'failed' && status.finishedAt && Date.now() - Date.parse(status.finishedAt) < FAILED_BACKOFF_MS) {
29+
return; // a recent failure needs a human look before we retry
30+
}
31+
32+
const local = await getLocalVersion();
33+
if (!local.isGit || !local.sha) return;
34+
const remote = await getRemoteVersion(ctx.env);
35+
if (!remote || remote.sha === local.sha) return; // up to date or GitHub unreachable
36+
37+
log.info({ from: local.shortSha, to: remote.shortSha }, 'auto-update: newer build available — applying');
38+
const res = startUpdate(ctx.env);
39+
if (!res.ok) log.warn({ error: res.error }, 'auto-update: could not start');
40+
}
41+
42+
/** Schedule the periodic auto-update check. Returns a stop function for graceful shutdown. */
43+
export function startAutoUpdate(ctx: AppContext, log: FastifyBaseLogger): () => void {
44+
let stopped = false;
45+
const run = () => {
46+
if (!stopped) void autoUpdateTick(ctx, log).catch((err) => log.error({ err }, 'auto-update tick failed'));
47+
};
48+
const timer = setInterval(run, CHECK_INTERVAL_MS);
49+
const kickoff = setTimeout(run, KICKOFF_MS);
50+
return () => {
51+
stopped = true;
52+
clearInterval(timer);
53+
clearTimeout(kickoff);
54+
};
55+
}

apps/api/src/services/version.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,78 @@ export async function getLocalVersion(): Promise<LocalVersion> {
8585
return cachedLocal;
8686
}
8787

88+
// Trailer / footer lines we never want to show end users in the "What's new" notes.
89+
const NOISE_LINE = /^(Co-Authored-By|Claude-Session|Claude-[\w-]+|Signed-off-by|Reviewed-by):/i;
90+
91+
/** Strip bookkeeping trailers and the generator footer from a commit body. */
92+
function cleanBody(body: string): string {
93+
return body
94+
.split('\n')
95+
.filter((l) => !NOISE_LINE.test(l.trim()) && !/^🤖 Generated with/.test(l.trim()) && !/^https:\/\/claude\.ai/.test(l.trim()))
96+
.join('\n')
97+
.trim();
98+
}
99+
100+
export interface Changelog {
101+
/** Markdown ready to render in the "What's new" dialog. */
102+
markdown: string;
103+
/** Number of commits summarised (after capping). */
104+
count: number;
105+
}
106+
107+
// Beyond this many commits the notes get unwieldy; we summarise the newest and note the rest.
108+
const MAX_CHANGELOG_COMMITS = 40;
109+
110+
/**
111+
* Build human-readable release notes for the commits in `from..to` (newest first), as Markdown.
112+
* Returns null when there is nothing to show or git can't produce a range (e.g. a force-push made
113+
* `from` unreachable — the caller falls back to the most recent commits instead).
114+
*/
115+
export async function getChangelog(from: string, to: string): Promise<Changelog | null> {
116+
if (!repoRoot || from === to) return null;
117+
// %x1e separates commits, %x1f separates subject from body within a commit.
118+
const fmt = '--format=%s%x1f%b%x1e';
119+
let stdout: string;
120+
try {
121+
const res = await exec('git', ['log', fmt, `${from}..${to}`], { cwd: repoRoot, maxBuffer: 8 * 1024 * 1024 });
122+
stdout = res.stdout;
123+
} catch {
124+
return null; // `from` not reachable — caller decides on a fallback
125+
}
126+
return renderChangelog(stdout);
127+
}
128+
129+
/** The most recent `n` commits as Markdown — a fallback when the range can't be computed. */
130+
export async function getRecentChangelog(n: number): Promise<Changelog | null> {
131+
if (!repoRoot) return null;
132+
try {
133+
const { stdout } = await exec('git', ['log', `-${n}`, '--format=%s%x1f%b%x1e'], { cwd: repoRoot, maxBuffer: 8 * 1024 * 1024 });
134+
return renderChangelog(stdout);
135+
} catch {
136+
return null;
137+
}
138+
}
139+
140+
function renderChangelog(raw: string): Changelog | null {
141+
const commits = raw
142+
.split('\x1e')
143+
.map((c) => c.trim())
144+
.filter(Boolean)
145+
.map((c) => {
146+
const [subject, ...rest] = c.split('\x1f');
147+
return { subject: (subject ?? '').trim(), body: cleanBody(rest.join('\x1f')) };
148+
})
149+
.filter((c) => c.subject);
150+
if (commits.length === 0) return null;
151+
152+
const shown = commits.slice(0, MAX_CHANGELOG_COMMITS);
153+
const blocks = shown.map((c) => (c.body ? `### ${c.subject}\n\n${c.body}` : `### ${c.subject}`));
154+
if (commits.length > shown.length) {
155+
blocks.push(`_…and ${commits.length - shown.length} more change(s)._`);
156+
}
157+
return { markdown: blocks.join('\n\n'), count: commits.length };
158+
}
159+
88160
interface GithubCommit {
89161
sha: string;
90162
html_url: string;

apps/api/test/integration.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,34 @@ runIf('API integration', () => {
844844
});
845845
});
846846

847+
describe("what's new", () => {
848+
it('stays silent on first contact, then shows release notes once per build', async () => {
849+
const user = await createUser({ email: 'wn@test.local', password: 'correct-horse-battery' });
850+
const auth = await login(app, 'wn@test.local', 'correct-horse-battery');
851+
852+
// First load: nothing to show, and the current build is remembered silently.
853+
const first = await app.inject({ method: 'GET', url: '/version/whats-new', headers: { cookie: auth.cookie } });
854+
expect(first.statusCode).toBe(200);
855+
expect(first.json().show).toBe(false);
856+
const afterFirst = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
857+
// The test checkout is a git repo, so a build SHA was recorded.
858+
expect(afterFirst.lastSeenVersion).toBeTruthy();
859+
860+
// Simulate having last seen an older build → notes appear (range falls back to recent commits).
861+
await prisma.user.update({ where: { id: user.id }, data: { lastSeenVersion: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' } });
862+
const shown = await app.inject({ method: 'GET', url: '/version/whats-new', headers: { cookie: auth.cookie } });
863+
expect(shown.json().show).toBe(true);
864+
expect(typeof shown.json().notes).toBe('string');
865+
expect(shown.json().notes.length).toBeGreaterThan(0);
866+
867+
// Dismissing records the current build, so it never shows again for this version.
868+
const seen = await app.inject({ method: 'POST', url: '/version/whats-new/seen', headers: authHeaders(auth) });
869+
expect(seen.statusCode).toBe(200);
870+
const again = await app.inject({ method: 'GET', url: '/version/whats-new', headers: { cookie: auth.cookie } });
871+
expect(again.json().show).toBe(false);
872+
});
873+
});
874+
847875
describe('admin maintenance', () => {
848876
it('reconciles a drifted usedBytes counter', async () => {
849877
const admin = await createUser({

0 commit comments

Comments
 (0)