Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
122a5f0
feat: add version history browser (CE3)
Feb 14, 2026
aba18b1
fix: address PR #4 review feedback — 16 comments
Feb 14, 2026
c881431
fix: address PR #4 round-2 review — 5 issues
Feb 14, 2026
3c61794
fix: reset history panel state when switching to new article
Feb 14, 2026
4b4b54d
fix: guard decoded.trailers destructuring in unpublish and revert
Feb 14, 2026
b978367
fix: guard readVersion return trailers with || {}
Feb 14, 2026
916eff5
fix: prevent temp directory leak on upload failure
Feb 14, 2026
1f043e1
chore: update CHANGELOG and bump to v1.0.1
Feb 14, 2026
1e94583
fix: sanitize 500 error responses to prevent internal detail leakage
Feb 14, 2026
f59c79d
perf: use Buffer.concat in readBody instead of string concatenation
Feb 14, 2026
87bbc73
fix: reset stale history state unconditionally in loadArticle
Feb 14, 2026
1e4b845
fix: guard selectVersion against stale async responses
Feb 14, 2026
1d74221
refactor: extract HISTORY_WALK_LIMIT as shared constant
Feb 14, 2026
2b812fd
docs: add inline comment explaining trailer-codec lowercase normaliza…
Feb 14, 2026
63a9f9a
chore: update CHANGELOG and bump to v1.0.2
Feb 14, 2026
90ba0a9
test: add regression test for sendError 500 response sanitization
Feb 14, 2026
966b7b3
fix: clear history DOM alongside state reset in loadArticle
Feb 14, 2026
c086e0c
fix: prevent autosave race and double-prompt in restoreVersion
Feb 14, 2026
806784b
fix: clamp limit internally in getArticleHistory
Feb 14, 2026
f1ab75e
fix: use NaN check instead of || for history limit parsing
Feb 14, 2026
3154631
test: assert response status before calling .json() in server tests
Feb 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to git-cms are documented in this file.

### Added

- **Version History Browser (CE3):** Browse prior versions of an article, preview old content, and restore a selected version as a new draft commit
- `CmsService.getArticleHistory()` — walk parent chain to list version summaries (SHA, title, status, author, date)
- `CmsService.readVersion()` — read full content of a specific commit by SHA
- `CmsService.restoreVersion()` — restore historical content as a new draft with ancestry validation and provenance trailers (`restoredFromSha`, `restoredAt`)
- `GET /api/cms/history`, `GET /api/cms/show-version`, `POST /api/cms/restore` server endpoints
- Admin UI: collapsible history panel with lazy-fetch, version preview, and restore button

- **Content Identity Policy (M1.1):** Canonical slug validation with NFKC normalization, reserved word rejection, and `CmsValidationError` contract (`ContentIdentityPolicy.js`)
- **State Machine (M1.2):** Explicit draft/published/unpublished/reverted states with enforced transition rules (`ContentStatePolicy.js`)
- **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
Expand Down Expand Up @@ -40,6 +47,9 @@ All notable changes to git-cms are documented in this file.
- **(P2) SRI hashes:** Add `integrity` + `crossorigin` to marked and DOMPurify CDN script tags
- **(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
- **(P2) uploadAsset DI guard:** Throw `unsupported_in_di_mode` when `cas`/`vault` are null
- **(P1) Path traversal in upload handler:** Sanitize user-controlled `filename` to `path.basename()` preventing writes outside tmpDir
- **(P1) readVersion lineage scoping:** `readVersion` now validates SHA ancestry (prevents cross-article content leakage)
- **(P1) readVersion published fallback:** `readVersion` checks both draft and published refs (consistent with `getArticleHistory`)
- **(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`
- **(P2) XSS in `escAttr`:** Escape single quotes (`'` → `'`) to prevent injection into single-quoted attributes
- **(P2) Supply-chain hardening:** Vendor Open Props CSS files locally (`public/css/`) instead of `@import` from unpkg, eliminating CDN dependency and SRI gap
Expand All @@ -53,5 +63,14 @@ All notable changes to git-cms are documented in this file.
- DI-mode `_updateRef` now performs manual CAS check against `oldSha`
- Server tests assert setup call status codes to surface silent failures
- Vitest exclude glob `test/git-e2e*` → `test/git-e2e**` to cover future subdirectories
- Admin UI: reset history panel state (versions list, preview, selection) when creating a new article to prevent stale data
- Defensive `|| {}` guard on `decoded.trailers` destructuring in `unpublishArticle` and `revertArticle` (prevents TypeError if trailers is undefined)
- `readVersion` now returns `trailers: decoded.trailers || {}` ensuring callers always receive an object
- Upload handler: moved tmpDir cleanup to `finally` block preventing temp directory leaks on failure
- **(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)
- **(P2) readBody O(n²):** `readBody` now accumulates chunks in an array and uses `Buffer.concat` instead of repeated string concatenation
- Admin UI: `loadArticle` unconditionally resets `historyVersions` and `selectedVersion` to prevent stale history state when switching articles with the panel closed
- Admin UI: `selectVersion` guards against out-of-order async responses (prevents stale preview flash from rapid clicks)
- **(P2) walkLimit divergence:** Extracted `HISTORY_WALK_LIMIT` as a shared exported constant used by both `_validateAncestry` and the server's history limit clamp

[Unreleased]: https://github.com/flyingrobots/git-cms/compare/main...git-stunts
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "git-cms",
"version": "1.0.0",
"version": "1.0.2",
"description": "A serverless, database-free CMS built on Git plumbing.",
"type": "module",
"bin": {
Expand Down
199 changes: 199 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,50 @@
margin-top: var(--size-2);
}

/* ── History Section ── */
#historySection .history-list {
max-height: 240px;
overflow-y: auto;
margin-top: var(--size-2);
display: flex;
flex-direction: column;
gap: 2px;
}
.history-item {
display: flex;
align-items: center;
gap: var(--size-3);
padding: var(--size-2) var(--size-3);
border-radius: var(--radius-2);
cursor: pointer;
transition: background 0.15s;
font-size: var(--font-size-0);
}
.history-item:hover { background: var(--surface-3); }
.history-item.active { background: var(--brand); color: white; }
.history-item .hist-sha { font-family: var(--font-mono); font-size: var(--font-size-00); opacity: 0.7; }
.history-item .hist-title { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-item .hist-date { font-size: var(--font-size-00); opacity: 0.7; white-space: nowrap; }
.history-item .hist-status { font-size: var(--font-size-00); opacity: 0.7; }

#historyPreview {
margin-top: var(--size-3);
border: 1px solid var(--surface-3);
border-radius: var(--radius-2);
padding: var(--size-3);
display: none;
}
#historyPreview .preview-content {
max-height: 300px;
overflow-y: auto;
line-height: 1.7;
}
#historyPreview .preview-actions {
margin-top: var(--size-3);
display: flex;
gap: var(--size-2);
}

/* ── Status Bar ── */
.status-bar {
font-size: var(--font-size-0);
Expand Down Expand Up @@ -457,6 +501,17 @@ <h1>Git CMS</h1>
<button class="btn add-trailer-btn" onclick="UI.addTrailerRow()">+ Add Field</button>
</details>

<details id="historySection">
<summary>Version History</summary>
<div id="historyList" class="history-list"></div>
<div id="historyPreview">
<div id="historyPreviewContent" class="preview-content"></div>
<div class="preview-actions">
<button class="btn btn-primary" id="restoreBtn" onclick="UI.restoreVersion()" disabled>Restore This Version</button>
</div>
</div>
</details>

<div class="asset-section" id="dropZone">
Drop files here or
<label class="btn" style="margin-left:var(--size-2)">
Expand Down Expand Up @@ -490,6 +545,8 @@ <h1>Git CMS</h1>
autosaveTimer: null,
editorMode: 'split',
trailers: {},
historyVersions: [],
selectedVersion: null,
};

/* ── API Layer ── */
Expand Down Expand Up @@ -537,6 +594,30 @@ <h1>Git CMS</h1>
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
},

async history(slug, limit = 50) {
const res = await fetch(`${API_BASE}/history?slug=${encodeURIComponent(slug)}&limit=${limit}`);
if (!res.ok) throw new Error(`History failed: ${res.status}`);
return res.json();
},

async showVersion(slug, sha) {
const res = await fetch(`${API_BASE}/show-version?slug=${encodeURIComponent(slug)}&sha=${encodeURIComponent(sha)}`);
if (!res.ok) throw new Error(`Show version failed: ${res.status}`);
return res.json();
},

async restore({ slug, sha }) {
const res = await fetch(`${API_BASE}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, sha }),
});
let data;
try { data = await res.json(); } catch { data = {}; }
if (!res.ok) throw Object.assign(new Error(data.error || `Restore failed: ${res.status}`), { code: data.code });
return data;
},
};

/* ── Toast ── */
Expand Down Expand Up @@ -638,6 +719,17 @@ <h1>Git CMS</h1>
updatePreview();
this.highlightActive(slug);

// Reset history state and DOM to prevent stale data from previous article
state.historyVersions = [];
state.selectedVersion = null;
document.getElementById('historyList').innerHTML = '';
document.getElementById('historyPreview').style.display = 'none';

// Refresh history if panel is already open
if (document.getElementById('historySection').open) {
this.fetchHistory();
}

statusEl.textContent = `Loaded ${slug} (${data.sha.slice(0, 7)})`;
} catch (err) {
toast(`Failed to load ${slug}`, 'error');
Expand All @@ -656,6 +748,13 @@ <h1>Git CMS</h1>
state.dirty = false;
state.trailers = {};

// Reset history panel to prevent stale data
document.getElementById('historySection').open = false;
document.getElementById('historyList').innerHTML = '';
document.getElementById('historyPreview').style.display = 'none';
state.historyVersions = [];
state.selectedVersion = null;

document.getElementById('slugInput').value = '';
document.getElementById('slugInput').disabled = false;
document.getElementById('titleInput').value = '';
Expand Down Expand Up @@ -903,6 +1002,101 @@ <h1>Git CMS</h1>
this.hideEditor();
},

/* Version History */
async fetchHistory() {
if (!state.currentSlug) return;
const listEl = document.getElementById('historyList');
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
document.getElementById('historyPreview').style.display = 'none';
state.selectedVersion = null;

try {
const versions = await api.history(state.currentSlug);
state.historyVersions = versions;
listEl.innerHTML = '';

versions.forEach((v, idx) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.idx = idx;

const shaSpan = document.createElement('span');
shaSpan.className = 'hist-sha';
shaSpan.textContent = v.sha.slice(0, 7);

const titleSpan = document.createElement('span');
titleSpan.className = 'hist-title';
titleSpan.textContent = v.title;

const statusSpan = document.createElement('span');
statusSpan.className = 'hist-status';
statusSpan.textContent = v.status;

const dateSpan = document.createElement('span');
dateSpan.className = 'hist-date';
dateSpan.textContent = relTime(v.date);

div.append(shaSpan, titleSpan, statusSpan, dateSpan);
div.onclick = () => this.selectVersion(v.sha, idx);
listEl.appendChild(div);
});
} catch (err) {
toast('Failed to load history', 'error');
listEl.innerHTML = '';
}
},
Comment on lines +1005 to +1047
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

fetchHistory has no staleness guard — late-arriving response from a previous article silently overwrites the current article's history.

You correctly added a staleness guard in selectVersion (line 1064: if (state.selectedVersion?.sha !== sha) return), but fetchHistory has no equivalent. Scenario: panel is open, user clicks article A → fetchHistory fires for A → user quickly clicks article B → loadArticle resets state, fires fetchHistory for B → A's response arrives after B's reset but before B's response → A's versions are rendered and assigned to state.historyVersions for article B.

🐛 Proposed fix: capture slug at call time, bail if stale
       async fetchHistory() {
         if (!state.currentSlug) return;
+        const slug = state.currentSlug;
         const listEl = document.getElementById('historyList');
         listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
         document.getElementById('historyPreview').style.display = 'none';
         state.selectedVersion = null;

         try {
-          const versions = await api.history(state.currentSlug);
+          const versions = await api.history(slug);
+          // Guard: article may have changed during fetch
+          if (state.currentSlug !== slug) return;
           state.historyVersions = versions;
           listEl.innerHTML = '';

           versions.forEach((v, idx) => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/* Version History */
async fetchHistory() {
if (!state.currentSlug) return;
const listEl = document.getElementById('historyList');
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
document.getElementById('historyPreview').style.display = 'none';
state.selectedVersion = null;
try {
const versions = await api.history(state.currentSlug);
state.historyVersions = versions;
listEl.innerHTML = '';
versions.forEach((v, idx) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.idx = idx;
const shaSpan = document.createElement('span');
shaSpan.className = 'hist-sha';
shaSpan.textContent = v.sha.slice(0, 7);
const titleSpan = document.createElement('span');
titleSpan.className = 'hist-title';
titleSpan.textContent = v.title;
const statusSpan = document.createElement('span');
statusSpan.className = 'hist-status';
statusSpan.textContent = v.status;
const dateSpan = document.createElement('span');
dateSpan.className = 'hist-date';
dateSpan.textContent = relTime(v.date);
div.append(shaSpan, titleSpan, statusSpan, dateSpan);
div.onclick = () => this.selectVersion(v.sha, idx);
listEl.appendChild(div);
});
} catch (err) {
toast('Failed to load history', 'error');
listEl.innerHTML = '';
}
},
/* Version History */
async fetchHistory() {
if (!state.currentSlug) return;
const slug = state.currentSlug;
const listEl = document.getElementById('historyList');
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
document.getElementById('historyPreview').style.display = 'none';
state.selectedVersion = null;
try {
const versions = await api.history(slug);
// Guard: article may have changed during fetch
if (state.currentSlug !== slug) return;
state.historyVersions = versions;
listEl.innerHTML = '';
versions.forEach((v, idx) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.idx = idx;
const shaSpan = document.createElement('span');
shaSpan.className = 'hist-sha';
shaSpan.textContent = v.sha.slice(0, 7);
const titleSpan = document.createElement('span');
titleSpan.className = 'hist-title';
titleSpan.textContent = v.title;
const statusSpan = document.createElement('span');
statusSpan.className = 'hist-status';
statusSpan.textContent = v.status;
const dateSpan = document.createElement('span');
dateSpan.className = 'hist-date';
dateSpan.textContent = relTime(v.date);
div.append(shaSpan, titleSpan, statusSpan, dateSpan);
div.onclick = () => this.selectVersion(v.sha, idx);
listEl.appendChild(div);
});
} catch (err) {
toast('Failed to load history', 'error');
listEl.innerHTML = '';
}
},
🤖 Prompt for AI Agents
In `@public/index.html` around lines 1005 - 1047, fetchHistory can be overwritten
by a late response from a previous slug; capture the slug at the start (e.g.
const slug = state.currentSlug) and after awaiting api.history(because versions
may return late) bail if state.currentSlug !== slug before mutating
state.historyVersions, rendering listEl, or touching state.selectedVersion;
implement the same staleness-guard pattern used in selectVersion so only the
most-recent fetch updates the DOM and state.


async selectVersion(sha, idx) {
state.selectedVersion = { sha, idx };
document.querySelectorAll('.history-item').forEach((el, i) => {
el.classList.toggle('active', i === idx);
});

const previewEl = document.getElementById('historyPreview');
const contentEl = document.getElementById('historyPreviewContent');
const restoreBtn = document.getElementById('restoreBtn');

previewEl.style.display = 'block';
contentEl.innerHTML = '<div class="skeleton" style="height:4em"></div>';

try {
const data = await api.showVersion(state.currentSlug, sha);
if (state.selectedVersion?.sha !== sha) return;
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(data.body || ''));
// Disable restore for current version (idx 0)
restoreBtn.disabled = idx === 0;
} catch (err) {
if (state.selectedVersion?.sha !== sha) return;
contentEl.textContent = 'Failed to load version content';
restoreBtn.disabled = true;
}
},

async restoreVersion() {
if (!state.selectedVersion || !state.currentSlug || state.saving) return;
const { sha } = state.selectedVersion;
if (!confirm(`Restore version ${sha.slice(0, 7)}? This creates a new draft with the old content.`)) return;

// Prevent autosave from racing the restore and suppress
// the redundant "unsaved changes" prompt in loadArticle.
this.clearAutosave();
state.dirty = false;

try {
await api.restore({ slug: state.currentSlug, sha });
toast('Version restored', 'success');
// Close history section and reload article
document.getElementById('historySection').open = false;
await this.loadArticle(state.currentSlug);
} catch (err) {
if (err.code === 'invalid_state_transition') {
toast('Cannot restore: unpublish the article first', 'error');
} else {
toast('Restore failed: ' + err.message, 'error');
}
}
},
Comment on lines 1075 to 1098
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

restoreVersion races with the autosave timer — the timer is not cleared before the async restore call.

The state.saving guard at line 1076 blocks restore while a save is in-flight, but the reverse isn't protected. Between the confirm() returning and loadArticle completing (which calls clearAutosave), the 3-second autosave timer can fire. Since restoreVersion never sets state.saving = true, the autosave's save() call proceeds and races the restore — both attempt to CAS-update the same draft ref, and the loser gets a confusing CAS conflict error.

Additionally, if the user had unsaved edits, loadArticle (line 1085) prompts again with "You have unsaved changes. Discard them?" — right after they already confirmed the restore. If they cancel this second dialog, the editor shows stale pre-restore content while the server already has the restored version.

Fix both by clearing autosave and resetting dirty state before the async work:

Proposed fix
       async restoreVersion() {
         if (!state.selectedVersion || !state.currentSlug || state.saving) return;
         const { sha } = state.selectedVersion;
         if (!confirm(`Restore version ${sha.slice(0, 7)}? This creates a new draft with the old content.`)) return;

+        // Prevent autosave from racing the restore and suppress
+        // the redundant "unsaved changes" prompt in loadArticle.
+        UI.clearAutosave();
+        state.dirty = false;
+
         try {
           await api.restore({ slug: state.currentSlug, sha });
           toast('Version restored', 'success');
           document.getElementById('historySection').open = false;
           await this.loadArticle(state.currentSlug);
         } catch (err) {
🤖 Prompt for AI Agents
In `@public/index.html` around lines 1075 - 1093, In restoreVersion, before any
awaits, clear the autosave timer and reset the editor dirty/saving flags to
prevent the autosave save() from racing the restore and from re-prompting;
specifically, call clearAutosave() (the existing autosave cleanup) and set
state.dirty = false and set state.saving = true immediately after the confirm
and before calling api.restore({ slug: state.currentSlug, sha }), then in the
try/finally restore state.saving = false (and restart autosave as appropriate)
so loadArticle() won't trigger a discard prompt or collide with an in-flight
autosave.


escAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/'/g, '&#39;');
},
Expand Down Expand Up @@ -967,6 +1161,11 @@ <h1>Git CMS</h1>
this.value = '';
});

// History section toggle — lazy-fetch on expand
document.getElementById('historySection').addEventListener('toggle', (e) => {
if (e.target.open) UI.fetchHistory();
});

/* ── Init ── */
UI.fetchList();
</script>
Expand Down
Loading