Skip to content

Commit dc20340

Browse files
feat(admin+spaces): version rollback, post-update reload, openable space files
Admin rollback — a "Previous versions" panel lists recent commits and can restore the deployment to any earlier one. It reuses the self-update path (GET /admin/version/history, POST /admin/rollback with an ancestor-only SHA guard; self-update.sh honours UPDATE_TARGET_REF) so the build, health-check and auto-rollback-on-failure all apply. Post-update reload — when an admin starts an update/rollback, the panel reloads the page once it succeeds (the API is now on the new build), which also surfaces the "What's new" dialog. The dialog stays silent after a rollback (current build is an ancestor of what the user last saw). Shared Spaces: open & edit files — space files now open in the in-app viewer (image/PDF/audio/video/text/markdown/html); editors get an editable text surface that re-uploads in place (versioned), mirroring the Drive. Docs — in-app docs gain a "Shared Spaces" section and updated "Updates" notes (auto-update, rollback, What's new); API.md clarifies that vaults and shared spaces are out of scope for the token REST API / WebDAV.
1 parent cc0fcd1 commit dc20340

10 files changed

Lines changed: 327 additions & 9 deletions

File tree

apps/api/src/routes/admin.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
commitsBehind,
1818
getLocalVersion,
1919
getRemoteVersion,
20+
getVersionHistory,
21+
isAncestor,
2022
isUpdateStuck,
2123
readUpdateStatus,
2224
startUpdate,
@@ -187,6 +189,37 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
187189
return reply.code(202).send({ ok: true });
188190
});
189191

192+
// GET /admin/version/history — recent commits on the checkout, as rollback candidates.
193+
app.get('/version/history', async () => {
194+
const [history, local] = await Promise.all([getVersionHistory(15), getLocalVersion()]);
195+
return { history, currentSha: local.sha };
196+
});
197+
198+
// POST /admin/rollback — roll the deployment back to a previous commit. The target must be a
199+
// known ancestor of the current build (so this can only go BACKWARDS to a version that ran
200+
// here), and the same health-checked, auto-rollback path as a forward update is used.
201+
app.post('/rollback', async (req, reply) => {
202+
const body = (req.body ?? {}) as { sha?: unknown };
203+
const sha = typeof body.sha === 'string' ? body.sha.trim() : '';
204+
if (!/^[0-9a-f]{7,40}$/.test(sha)) return reply.code(400).send({ error: 'A valid commit SHA is required' });
205+
206+
const local = await getLocalVersion();
207+
if (!local.sha) return reply.code(400).send({ error: 'This deployment is not a git checkout' });
208+
if (local.sha === sha || local.shortSha === sha) {
209+
return reply.code(400).send({ error: 'That is already the running version' });
210+
}
211+
// Only allow rolling back to a commit that is an ancestor of HEAD (a version that ran here),
212+
// never to arbitrary or future refs.
213+
if (!(await isAncestor(sha, local.sha))) {
214+
return reply.code(400).send({ error: 'Can only roll back to a previous version of this deployment' });
215+
}
216+
217+
const result = startUpdate(app.ctx.env, sha);
218+
if (!result.ok) return reply.code(409).send({ error: result.error });
219+
await audit(req, 'admin.rollback.start', { target: sha });
220+
return reply.code(202).send({ ok: true });
221+
});
222+
190223
// ── Maintenance (manual trigger) ─────────────────────────────────────────--
191224
app.post('/maintenance', async (req) => {
192225
const summary = await runMaintenance(app.ctx, req.log);

apps/api/src/routes/version.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import type { FastifyPluginAsync } from 'fastify';
88
import { prisma } from '../db.js';
9-
import { getChangelog, getLocalVersion, getRecentChangelog } from '../services/version.js';
9+
import { getChangelog, getLocalVersion, getRecentChangelog, isAncestor } from '../services/version.js';
1010

1111
export const versionRoutes: FastifyPluginAsync = async (app) => {
1212
app.addHook('preHandler', app.requireAuth);
@@ -31,6 +31,13 @@ export const versionRoutes: FastifyPluginAsync = async (app) => {
3131
}
3232
if (seen === local.sha) return { show: false };
3333

34+
// If the current build is an ancestor of what the user last saw, the deployment was rolled
35+
// BACK — there is nothing "new" to announce, so just record the build silently.
36+
if (await isAncestor(local.sha, seen)) {
37+
await prisma.user.update({ where: { id: req.user!.id }, data: { lastSeenVersion: local.sha } });
38+
return { show: false };
39+
}
40+
3441
// Notes for seen..current; if `seen` is unreachable (force-push/rebase), fall back to the
3542
// most recent commits so the user still sees something meaningful.
3643
const log = (await getChangelog(seen, local.sha)) ?? (await getRecentChangelog(20));

apps/api/src/services/version.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,42 @@ function renderChangelog(raw: string): Changelog | null {
157157
return { markdown: blocks.join('\n\n'), count: commits.length };
158158
}
159159

160+
export interface HistoryCommit {
161+
sha: string;
162+
shortSha: string;
163+
subject: string;
164+
committedAt: string | null;
165+
}
166+
167+
/** The most recent commits on the current checkout (newest first) — rollback candidates. */
168+
export async function getVersionHistory(n = 15): Promise<HistoryCommit[]> {
169+
if (!repoRoot) return [];
170+
try {
171+
const { stdout } = await exec('git', ['log', `-${n}`, '--format=%H%x1f%h%x1f%cI%x1f%s'], { cwd: repoRoot });
172+
return stdout
173+
.trim()
174+
.split('\n')
175+
.filter(Boolean)
176+
.map((l) => {
177+
const [sha, shortSha, committedAt, subject] = l.split('\x1f');
178+
return { sha: sha ?? '', shortSha: shortSha ?? '', committedAt: committedAt || null, subject: subject ?? '' };
179+
});
180+
} catch {
181+
return [];
182+
}
183+
}
184+
185+
/** True when `ancestor` is an ancestor of `descendant` (git merge-base --is-ancestor exit 0). */
186+
export async function isAncestor(ancestor: string, descendant: string): Promise<boolean> {
187+
if (!repoRoot) return false;
188+
try {
189+
await exec('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd: repoRoot });
190+
return true;
191+
} catch {
192+
return false;
193+
}
194+
}
195+
160196
interface GithubCommit {
161197
sha: string;
162198
html_url: string;
@@ -243,8 +279,12 @@ export function isUpdateStuck(status: UpdateStatus): boolean {
243279
return status.state === 'running' && status.startedAt !== null && Date.now() - Date.parse(status.startedAt) > STALE_RUNNING_MS;
244280
}
245281

246-
/** Spawn the detached self-update script. It survives the PM2 reload it triggers. */
247-
export function startUpdate(env: Env): StartUpdateResult {
282+
/**
283+
* Spawn the detached self-update script. It survives the PM2 reload it triggers. When `targetRef`
284+
* is given, the checkout is reset to THAT commit (a rollback to a previous version) instead of the
285+
* latest on the tracked branch — the build/health-check/auto-rollback safety net is identical.
286+
*/
287+
export function startUpdate(env: Env, targetRef?: string): StartUpdateResult {
248288
if (!env.SELF_UPDATE_ENABLED) return { ok: false, error: 'Self-update is disabled on this instance.' };
249289
if (!repoRoot) return { ok: false, error: 'This deployment is not a git checkout; update it manually.' };
250290
const current = readUpdateStatus();
@@ -281,7 +321,7 @@ export function startUpdate(env: Env): StartUpdateResult {
281321
cwd: repoRoot,
282322
detached: true,
283323
stdio: 'ignore',
284-
env: { ...process.env, UPDATE_BRANCH: env.UPDATE_BRANCH },
324+
env: { ...process.env, UPDATE_BRANCH: env.UPDATE_BRANCH, ...(targetRef ? { UPDATE_TARGET_REF: targetRef } : {}) },
285325
});
286326
child.unref();
287327
return { ok: true };

apps/api/test/integration.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,32 @@ runIf('API integration', () => {
872872
});
873873
});
874874

875+
describe('admin version history & rollback', () => {
876+
it('lists history and validates rollback targets', async () => {
877+
await createUser({ email: 'rb@test.local', password: 'correct-horse-battery', role: 'ADMIN' });
878+
const auth = await login(app, 'rb@test.local', 'correct-horse-battery');
879+
880+
const hist = await app.inject({ method: 'GET', url: '/admin/version/history', headers: { cookie: auth.cookie } });
881+
expect(hist.statusCode).toBe(200);
882+
expect(Array.isArray(hist.json().history)).toBe(true);
883+
const currentSha = hist.json().currentSha as string | null;
884+
885+
// A malformed SHA is rejected up front.
886+
const bad = await app.inject({ method: 'POST', url: '/admin/rollback', headers: authHeaders(auth), payload: { sha: 'not-a-sha' } });
887+
expect(bad.statusCode).toBe(400);
888+
889+
// A well-formed but unknown SHA isn't an ancestor of HEAD → refused (never goes forward/sideways).
890+
const unknown = await app.inject({ method: 'POST', url: '/admin/rollback', headers: authHeaders(auth), payload: { sha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' } });
891+
expect(unknown.statusCode).toBe(400);
892+
893+
// Rolling back to the version already running is a no-op error.
894+
if (currentSha) {
895+
const same = await app.inject({ method: 'POST', url: '/admin/rollback', headers: authHeaders(auth), payload: { sha: currentSha } });
896+
expect(same.statusCode).toBe(400);
897+
}
898+
});
899+
});
900+
875901
describe('admin maintenance', () => {
876902
it('reconciles a drifted usedBytes counter', async () => {
877903
const admin = await createUser({

apps/web/src/app/(app)/admin/UpdatePanel.tsx

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* terminal state.
88
*/
99
import { useCallback, useEffect, useRef, useState } from 'react';
10-
import { GitCommitHorizontal, RefreshCw, Download, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
10+
import { GitCommitHorizontal, RefreshCw, Download, CheckCircle2, AlertTriangle, Loader2, History, RotateCcw } from 'lucide-react';
1111
import { api, ApiError } from '@/lib/api';
1212
import { useT } from '@/lib/i18n';
1313
import { confirm, toast } from '@/components/ui/overlays';
@@ -33,6 +33,12 @@ interface UpdateStatus {
3333
finishedAt: string | null;
3434
message: string | null;
3535
}
36+
interface HistoryCommit {
37+
sha: string;
38+
shortSha: string;
39+
subject: string;
40+
committedAt: string | null;
41+
}
3642
interface VersionInfo {
3743
current: LocalVersion;
3844
status: UpdateStatus;
@@ -52,7 +58,12 @@ export function UpdatePanel() {
5258
const [info, setInfo] = useState<VersionInfo | null>(null);
5359
const [error, setError] = useState<string | null>(null);
5460
const [busy, setBusy] = useState<'check' | 'update' | null>(null);
61+
const [history, setHistory] = useState<HistoryCommit[] | null>(null);
62+
const [showHistory, setShowHistory] = useState(false);
5563
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
64+
// Set when THIS admin starts an update/rollback, so we reload the page once it succeeds
65+
// (the API has restarted on the new build) — which then surfaces the "What's new" dialog.
66+
const initiated = useRef(false);
5667

5768
const fetchVersion = useCallback(async (check: boolean) => {
5869
const res = await api.get<VersionInfo>(`/admin/version${check ? '?check=1' : ''}`);
@@ -100,6 +111,50 @@ export function UpdatePanel() {
100111
}
101112
}
102113

114+
// When an update/rollback this admin started finishes successfully, the API is now running the
115+
// new build — reload so the browser fetches the new bundle (and the What's-new dialog appears).
116+
useEffect(() => {
117+
if (info?.status.state === 'success' && initiated.current) {
118+
initiated.current = false;
119+
toast(t('admin.updateReloading'), 'info');
120+
const id = setTimeout(() => window.location.reload(), 1800);
121+
return () => clearTimeout(id);
122+
}
123+
}, [info?.status.state, t]);
124+
125+
async function loadHistory() {
126+
if (history) {
127+
setShowHistory((s) => !s);
128+
return;
129+
}
130+
try {
131+
const res = await api.get<{ history: HistoryCommit[]; currentSha: string | null }>('/admin/version/history');
132+
setHistory(res.history);
133+
setShowHistory(true);
134+
} catch (e) {
135+
setError(e instanceof ApiError ? e.message : String(e));
136+
}
137+
}
138+
139+
async function rollback(commit: HistoryCommit) {
140+
const ok = await confirm({
141+
title: t('admin.rollbackConfirmTitle'),
142+
message: t('admin.rollbackConfirmMsg', { sha: commit.shortSha, subject: commit.subject }),
143+
confirmLabel: t('admin.rollbackConfirmBtn'),
144+
danger: true,
145+
});
146+
if (!ok) return;
147+
setError(null);
148+
try {
149+
await api.post('/admin/rollback', { sha: commit.sha });
150+
initiated.current = true;
151+
toast(t('admin.rollbackStarted'), 'info');
152+
await fetchVersion(false);
153+
} catch (e) {
154+
setError(e instanceof ApiError ? e.message : String(e));
155+
}
156+
}
157+
103158
async function toggleAuto() {
104159
if (!info) return;
105160
try {
@@ -121,6 +176,7 @@ export function UpdatePanel() {
121176
setError(null);
122177
try {
123178
await api.post('/admin/update');
179+
initiated.current = true;
124180
toast(t('admin.updateStarted'), 'info');
125181
await fetchVersion(false);
126182
} catch (e) {
@@ -251,6 +307,51 @@ export function UpdatePanel() {
251307
</div>
252308
)}
253309

310+
{/* Version history / rollback */}
311+
{selfUpdateEnabled && current.isGit && (
312+
<div className="rounded-lg border border-white/[0.07] bg-white/[0.02]">
313+
<button
314+
type="button"
315+
onClick={loadHistory}
316+
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-sm text-zinc-200"
317+
>
318+
<span className="flex items-center gap-2">
319+
<History size={15} className="text-zinc-400" /> {t('admin.rollbackTitle')}
320+
</span>
321+
<span className="text-xs text-zinc-500">{showHistory ? t('admin.hide') : t('admin.show')}</span>
322+
</button>
323+
{showHistory && history && (
324+
<div className="border-t border-white/[0.06] p-2">
325+
<p className="px-1 pb-2 text-xs text-zinc-500">{t('admin.rollbackHint')}</p>
326+
<div className="space-y-1">
327+
{history.map((c) => {
328+
const isCurrent = c.sha === current.sha;
329+
return (
330+
<div key={c.sha} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-white/[0.03]">
331+
<code className="shrink-0 font-mono text-xs text-zinc-400">{c.shortSha}</code>
332+
<span className="min-w-0 flex-1 truncate text-xs text-zinc-300">{c.subject}</span>
333+
{isCurrent ? (
334+
<span className="shrink-0 rounded-full bg-emerald-400/10 px-2 py-0.5 text-[11px] text-emerald-300">
335+
{t('admin.rollbackCurrent')}
336+
</span>
337+
) : (
338+
<button
339+
className="btn-ghost shrink-0 px-2 py-1 text-xs"
340+
onClick={() => rollback(c)}
341+
disabled={busy !== null || running}
342+
>
343+
<RotateCcw size={13} /> {t('admin.rollbackTo')}
344+
</button>
345+
)}
346+
</div>
347+
);
348+
})}
349+
</div>
350+
</div>
351+
)}
352+
</div>
353+
)}
354+
254355
{/* Automatic updates */}
255356
{selfUpdateEnabled && (
256357
<button

apps/web/src/app/(app)/spaces/[id]/page.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { api, API_URL, ApiError } from '@/lib/api';
4040
import { useAuth } from '@/lib/auth';
4141
import { useT } from '@/lib/i18n';
4242
import { Select } from '@/components/ui/Select';
43+
import { FileViewer, type ViewerSource } from '@/components/FileViewer';
4344
import { confirm, choose, prompt, toast } from '@/components/ui/overlays';
4445

4546
export default function SpacePage() {
@@ -57,6 +58,7 @@ export default function SpacePage() {
5758
const [loading, setLoading] = useState(true);
5859
const [uploadPct, setUploadPct] = useState<number | null>(null);
5960
const [showManage, setShowManage] = useState(false);
61+
const [viewing, setViewing] = useState<ViewerSource | null>(null);
6062
const fileInput = useRef<HTMLInputElement>(null);
6163

6264
const canWrite = space ? space.myRole === 'OWNER' || space.myRole === 'EDITOR' : false;
@@ -144,6 +146,28 @@ export default function SpacePage() {
144146
window.open(`${API_URL}/spaces/${spaceId}/files/${f.id}/download`, '_blank');
145147
}
146148

149+
// Open a file in the in-app viewer. Editors additionally get an editable text surface that
150+
// re-uploads under the same name (the upload pipeline versions it in place).
151+
function openFile(f: PublicFile) {
152+
setViewing({
153+
name: f.name,
154+
mime: f.mimeType,
155+
sizeBytes: f.sizeBytes,
156+
url: api.url(`/spaces/${spaceId}/files/${f.id}/download`),
157+
...(canWrite
158+
? {
159+
onSave: async (text: string) => {
160+
const form = new FormData();
161+
form.append('file', new File([text], f.name, { type: f.mimeType || 'text/plain' }));
162+
const q = f.folderId ? `?folderId=${encodeURIComponent(f.folderId)}` : '';
163+
await api.upload(`/spaces/${spaceId}/files${q}`, form);
164+
await Promise.all([loadFiles(), loadSpace()]);
165+
},
166+
}
167+
: {}),
168+
});
169+
}
170+
147171
async function renameFile(f: PublicFile) {
148172
const name = await prompt({ title: t('space.rename'), defaultValue: f.name, confirmLabel: t('space.save') });
149173
if (!name?.trim() || name === f.name) return;
@@ -425,13 +449,13 @@ export default function SpacePage() {
425449
))}
426450
{files.map((f) => (
427451
<div key={f.id} className="row">
428-
<div className="flex min-w-0 items-center gap-3">
452+
<button className="flex min-w-0 items-center gap-3 text-left" onClick={() => openFile(f)} title={t('space.openFile')}>
429453
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-accent-soft text-violet-300"><FileIcon size={16} /></span>
430454
<div className="min-w-0">
431455
<p className="truncate font-medium text-zinc-100">{f.name}</p>
432456
<p className="text-xs text-zinc-500">{formatBytes(f.sizeBytes)}</p>
433457
</div>
434-
</div>
458+
</button>
435459
<div className="flex shrink-0 items-center gap-1">
436460
<button className="rounded-lg p-1.5 text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100" title={t('space.download')} onClick={() => download(f)}><Download size={15} /></button>
437461
{canWrite && (
@@ -445,6 +469,8 @@ export default function SpacePage() {
445469
))}
446470
</div>
447471
)}
472+
473+
{viewing && <FileViewer source={viewing} onClose={() => setViewing(null)} />}
448474
</div>
449475
);
450476
}

0 commit comments

Comments
 (0)