Skip to content

Commit f370e79

Browse files
authored
Merge pull request #4 from flyingrobots/ce3-version-history
feat: version history browser (CE3)
2 parents 2ed01b4 + 3154631 commit f370e79

7 files changed

Lines changed: 810 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes to git-cms are documented in this file.
66

77
### Added
88

9+
- **Version History Browser (CE3):** Browse prior versions of an article, preview old content, and restore a selected version as a new draft commit
10+
- `CmsService.getArticleHistory()` — walk parent chain to list version summaries (SHA, title, status, author, date)
11+
- `CmsService.readVersion()` — read full content of a specific commit by SHA
12+
- `CmsService.restoreVersion()` — restore historical content as a new draft with ancestry validation and provenance trailers (`restoredFromSha`, `restoredAt`)
13+
- `GET /api/cms/history`, `GET /api/cms/show-version`, `POST /api/cms/restore` server endpoints
14+
- Admin UI: collapsible history panel with lazy-fetch, version preview, and restore button
15+
916
- **Content Identity Policy (M1.1):** Canonical slug validation with NFKC normalization, reserved word rejection, and `CmsValidationError` contract (`ContentIdentityPolicy.js`)
1017
- **State Machine (M1.2):** Explicit draft/published/unpublished/reverted states with enforced transition rules (`ContentStatePolicy.js`)
1118
- **Admin UI overhaul:** Split/edit/preview markdown editor (via `marked`), autosave, toast notifications, skeleton loading, drag-and-drop file uploads, metadata trailer editor, keyboard shortcuts (`Cmd+S`, `Esc`), dark mode token system
@@ -40,6 +47,9 @@ All notable changes to git-cms are documented in this file.
4047
- **(P2) SRI hashes:** Add `integrity` + `crossorigin` to marked and DOMPurify CDN script tags
4148
- **(P2) Null guards:** `revertArticle` and `unpublishArticle` throw `no_draft` when draft ref is missing; `_resolveArticleState` throws `article_not_found` when both draft and published refs are missing
4249
- **(P2) uploadAsset DI guard:** Throw `unsupported_in_di_mode` when `cas`/`vault` are null
50+
- **(P1) Path traversal in upload handler:** Sanitize user-controlled `filename` to `path.basename()` preventing writes outside tmpDir
51+
- **(P1) readVersion lineage scoping:** `readVersion` now validates SHA ancestry (prevents cross-article content leakage)
52+
- **(P1) readVersion published fallback:** `readVersion` checks both draft and published refs (consistent with `getArticleHistory`)
4353
- **(P2) Trailer key casing:** Use camelCase `updatedAt` in `unpublishArticle` and `revertArticle` (was lowercase `updatedat` which broke `renderBadges` lookups); destructure out decoded lowercase key before spreading to avoid `TrailerInvalidError`
4454
- **(P2) XSS in `escAttr`:** Escape single quotes (`'``'`) to prevent injection into single-quoted attributes
4555
- **(P2) Supply-chain hardening:** Vendor Open Props CSS files locally (`public/css/`) instead of `@import` from unpkg, eliminating CDN dependency and SRI gap
@@ -53,5 +63,14 @@ All notable changes to git-cms are documented in this file.
5363
- DI-mode `_updateRef` now performs manual CAS check against `oldSha`
5464
- Server tests assert setup call status codes to surface silent failures
5565
- Vitest exclude glob `test/git-e2e*``test/git-e2e**` to cover future subdirectories
66+
- Admin UI: reset history panel state (versions list, preview, selection) when creating a new article to prevent stale data
67+
- Defensive `|| {}` guard on `decoded.trailers` destructuring in `unpublishArticle` and `revertArticle` (prevents TypeError if trailers is undefined)
68+
- `readVersion` now returns `trailers: decoded.trailers || {}` ensuring callers always receive an object
69+
- Upload handler: moved tmpDir cleanup to `finally` block preventing temp directory leaks on failure
70+
- **(P1) sendError info leak:** 500 responses now return generic 'Internal server error' instead of raw `err.message` (prevents leaking file paths, git subprocess details, or internal state)
71+
- **(P2) readBody O(n²):** `readBody` now accumulates chunks in an array and uses `Buffer.concat` instead of repeated string concatenation
72+
- Admin UI: `loadArticle` unconditionally resets `historyVersions` and `selectedVersion` to prevent stale history state when switching articles with the panel closed
73+
- Admin UI: `selectVersion` guards against out-of-order async responses (prevents stale preview flash from rapid clicks)
74+
- **(P2) walkLimit divergence:** Extracted `HISTORY_WALK_LIMIT` as a shared exported constant used by both `_validateAncestry` and the server's history limit clamp
5675

5776
[Unreleased]: https://github.com/flyingrobots/git-cms/compare/main...git-stunts

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "git-cms",
3-
"version": "1.0.0",
3+
"version": "1.0.2",
44
"description": "A serverless, database-free CMS built on Git plumbing.",
55
"type": "module",
66
"bin": {

public/index.html

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,50 @@
359359
margin-top: var(--size-2);
360360
}
361361

362+
/* ── History Section ── */
363+
#historySection .history-list {
364+
max-height: 240px;
365+
overflow-y: auto;
366+
margin-top: var(--size-2);
367+
display: flex;
368+
flex-direction: column;
369+
gap: 2px;
370+
}
371+
.history-item {
372+
display: flex;
373+
align-items: center;
374+
gap: var(--size-3);
375+
padding: var(--size-2) var(--size-3);
376+
border-radius: var(--radius-2);
377+
cursor: pointer;
378+
transition: background 0.15s;
379+
font-size: var(--font-size-0);
380+
}
381+
.history-item:hover { background: var(--surface-3); }
382+
.history-item.active { background: var(--brand); color: white; }
383+
.history-item .hist-sha { font-family: var(--font-mono); font-size: var(--font-size-00); opacity: 0.7; }
384+
.history-item .hist-title { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
385+
.history-item .hist-date { font-size: var(--font-size-00); opacity: 0.7; white-space: nowrap; }
386+
.history-item .hist-status { font-size: var(--font-size-00); opacity: 0.7; }
387+
388+
#historyPreview {
389+
margin-top: var(--size-3);
390+
border: 1px solid var(--surface-3);
391+
border-radius: var(--radius-2);
392+
padding: var(--size-3);
393+
display: none;
394+
}
395+
#historyPreview .preview-content {
396+
max-height: 300px;
397+
overflow-y: auto;
398+
line-height: 1.7;
399+
}
400+
#historyPreview .preview-actions {
401+
margin-top: var(--size-3);
402+
display: flex;
403+
gap: var(--size-2);
404+
}
405+
362406
/* ── Status Bar ── */
363407
.status-bar {
364408
font-size: var(--font-size-0);
@@ -457,6 +501,17 @@ <h1>Git CMS</h1>
457501
<button class="btn add-trailer-btn" onclick="UI.addTrailerRow()">+ Add Field</button>
458502
</details>
459503

504+
<details id="historySection">
505+
<summary>Version History</summary>
506+
<div id="historyList" class="history-list"></div>
507+
<div id="historyPreview">
508+
<div id="historyPreviewContent" class="preview-content"></div>
509+
<div class="preview-actions">
510+
<button class="btn btn-primary" id="restoreBtn" onclick="UI.restoreVersion()" disabled>Restore This Version</button>
511+
</div>
512+
</div>
513+
</details>
514+
460515
<div class="asset-section" id="dropZone">
461516
Drop files here or
462517
<label class="btn" style="margin-left:var(--size-2)">
@@ -490,6 +545,8 @@ <h1>Git CMS</h1>
490545
autosaveTimer: null,
491546
editorMode: 'split',
492547
trailers: {},
548+
historyVersions: [],
549+
selectedVersion: null,
493550
};
494551

495552
/* ── API Layer ── */
@@ -537,6 +594,30 @@ <h1>Git CMS</h1>
537594
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
538595
return res.json();
539596
},
597+
598+
async history(slug, limit = 50) {
599+
const res = await fetch(`${API_BASE}/history?slug=${encodeURIComponent(slug)}&limit=${limit}`);
600+
if (!res.ok) throw new Error(`History failed: ${res.status}`);
601+
return res.json();
602+
},
603+
604+
async showVersion(slug, sha) {
605+
const res = await fetch(`${API_BASE}/show-version?slug=${encodeURIComponent(slug)}&sha=${encodeURIComponent(sha)}`);
606+
if (!res.ok) throw new Error(`Show version failed: ${res.status}`);
607+
return res.json();
608+
},
609+
610+
async restore({ slug, sha }) {
611+
const res = await fetch(`${API_BASE}/restore`, {
612+
method: 'POST',
613+
headers: { 'Content-Type': 'application/json' },
614+
body: JSON.stringify({ slug, sha }),
615+
});
616+
let data;
617+
try { data = await res.json(); } catch { data = {}; }
618+
if (!res.ok) throw Object.assign(new Error(data.error || `Restore failed: ${res.status}`), { code: data.code });
619+
return data;
620+
},
540621
};
541622

542623
/* ── Toast ── */
@@ -638,6 +719,17 @@ <h1>Git CMS</h1>
638719
updatePreview();
639720
this.highlightActive(slug);
640721

722+
// Reset history state and DOM to prevent stale data from previous article
723+
state.historyVersions = [];
724+
state.selectedVersion = null;
725+
document.getElementById('historyList').innerHTML = '';
726+
document.getElementById('historyPreview').style.display = 'none';
727+
728+
// Refresh history if panel is already open
729+
if (document.getElementById('historySection').open) {
730+
this.fetchHistory();
731+
}
732+
641733
statusEl.textContent = `Loaded ${slug} (${data.sha.slice(0, 7)})`;
642734
} catch (err) {
643735
toast(`Failed to load ${slug}`, 'error');
@@ -656,6 +748,13 @@ <h1>Git CMS</h1>
656748
state.dirty = false;
657749
state.trailers = {};
658750

751+
// Reset history panel to prevent stale data
752+
document.getElementById('historySection').open = false;
753+
document.getElementById('historyList').innerHTML = '';
754+
document.getElementById('historyPreview').style.display = 'none';
755+
state.historyVersions = [];
756+
state.selectedVersion = null;
757+
659758
document.getElementById('slugInput').value = '';
660759
document.getElementById('slugInput').disabled = false;
661760
document.getElementById('titleInput').value = '';
@@ -903,6 +1002,101 @@ <h1>Git CMS</h1>
9031002
this.hideEditor();
9041003
},
9051004

1005+
/* Version History */
1006+
async fetchHistory() {
1007+
if (!state.currentSlug) return;
1008+
const listEl = document.getElementById('historyList');
1009+
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
1010+
document.getElementById('historyPreview').style.display = 'none';
1011+
state.selectedVersion = null;
1012+
1013+
try {
1014+
const versions = await api.history(state.currentSlug);
1015+
state.historyVersions = versions;
1016+
listEl.innerHTML = '';
1017+
1018+
versions.forEach((v, idx) => {
1019+
const div = document.createElement('div');
1020+
div.className = 'history-item';
1021+
div.dataset.idx = idx;
1022+
1023+
const shaSpan = document.createElement('span');
1024+
shaSpan.className = 'hist-sha';
1025+
shaSpan.textContent = v.sha.slice(0, 7);
1026+
1027+
const titleSpan = document.createElement('span');
1028+
titleSpan.className = 'hist-title';
1029+
titleSpan.textContent = v.title;
1030+
1031+
const statusSpan = document.createElement('span');
1032+
statusSpan.className = 'hist-status';
1033+
statusSpan.textContent = v.status;
1034+
1035+
const dateSpan = document.createElement('span');
1036+
dateSpan.className = 'hist-date';
1037+
dateSpan.textContent = relTime(v.date);
1038+
1039+
div.append(shaSpan, titleSpan, statusSpan, dateSpan);
1040+
div.onclick = () => this.selectVersion(v.sha, idx);
1041+
listEl.appendChild(div);
1042+
});
1043+
} catch (err) {
1044+
toast('Failed to load history', 'error');
1045+
listEl.innerHTML = '';
1046+
}
1047+
},
1048+
1049+
async selectVersion(sha, idx) {
1050+
state.selectedVersion = { sha, idx };
1051+
document.querySelectorAll('.history-item').forEach((el, i) => {
1052+
el.classList.toggle('active', i === idx);
1053+
});
1054+
1055+
const previewEl = document.getElementById('historyPreview');
1056+
const contentEl = document.getElementById('historyPreviewContent');
1057+
const restoreBtn = document.getElementById('restoreBtn');
1058+
1059+
previewEl.style.display = 'block';
1060+
contentEl.innerHTML = '<div class="skeleton" style="height:4em"></div>';
1061+
1062+
try {
1063+
const data = await api.showVersion(state.currentSlug, sha);
1064+
if (state.selectedVersion?.sha !== sha) return;
1065+
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(data.body || ''));
1066+
// Disable restore for current version (idx 0)
1067+
restoreBtn.disabled = idx === 0;
1068+
} catch (err) {
1069+
if (state.selectedVersion?.sha !== sha) return;
1070+
contentEl.textContent = 'Failed to load version content';
1071+
restoreBtn.disabled = true;
1072+
}
1073+
},
1074+
1075+
async restoreVersion() {
1076+
if (!state.selectedVersion || !state.currentSlug || state.saving) return;
1077+
const { sha } = state.selectedVersion;
1078+
if (!confirm(`Restore version ${sha.slice(0, 7)}? This creates a new draft with the old content.`)) return;
1079+
1080+
// Prevent autosave from racing the restore and suppress
1081+
// the redundant "unsaved changes" prompt in loadArticle.
1082+
this.clearAutosave();
1083+
state.dirty = false;
1084+
1085+
try {
1086+
await api.restore({ slug: state.currentSlug, sha });
1087+
toast('Version restored', 'success');
1088+
// Close history section and reload article
1089+
document.getElementById('historySection').open = false;
1090+
await this.loadArticle(state.currentSlug);
1091+
} catch (err) {
1092+
if (err.code === 'invalid_state_transition') {
1093+
toast('Cannot restore: unpublish the article first', 'error');
1094+
} else {
1095+
toast('Restore failed: ' + err.message, 'error');
1096+
}
1097+
}
1098+
},
1099+
9061100
escAttr(s) {
9071101
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/'/g, '&#39;');
9081102
},
@@ -967,6 +1161,11 @@ <h1>Git CMS</h1>
9671161
this.value = '';
9681162
});
9691163

1164+
// History section toggle — lazy-fetch on expand
1165+
document.getElementById('historySection').addEventListener('toggle', (e) => {
1166+
if (e.target.open) UI.fetchHistory();
1167+
});
1168+
9701169
/* ── Init ── */
9711170
UI.fetchList();
9721171
</script>

0 commit comments

Comments
 (0)