fix(review): harden photo reorder against data loss + failure-path drift
Addresses ce-code-review findings on the photo-editor media-API work: - P0 (#1): PhotoRenumberer now renumbers EVERY on-disk image, using the client manifest only as preferred ORDER and appending any omitted image at the end. A stale/incomplete `order` (e.g. a second browser tab) previously left an unlisted photo at a target slot for phase-2's rename() to silently overwrite — verified data loss, now impossible. The reorder route inherits the guard; create/reconcile is unchanged. - P2 (#3): unique per-call token in the .reorder-tmp-* name so two concurrent renumbers on one folder can't collide and clobber bytes. - P3 (#7): de-duplicate the manifest so a repeated name can't shift/drop a photo. - P2 (#2): applyReorder + doDelete split the two failure stages — a failed refresh AFTER a committed reorder/delete no longer reverts to a stale or ghost state, it reconciles to disk. A DELETE 404 is treated as success so a retried ghost cell converges. - P2 (#4): both custom routes call requirePermission('api.pages.write') so the GHSA-x7hm API-key scope cap applies (owner already holds it, so the owner-only behaviour is unchanged). - P3 (#8): refresh stale comments (photo-01..NN; drop editLoadPhotos ref). PhotoRenumberer's 7-case unit suite still passes and the data-loss repro now preserves all bytes. Assets rebuilt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -155,7 +155,7 @@ function initPhotoConversion() {
|
||||
// current visual order into a hidden input. Its name is a TOP-LEVEL POST key
|
||||
// ("photo_order", not "data[...]") so Grav's form never captures it into the
|
||||
// page data — the server reads it straight from $_POST and renames the
|
||||
// copied files photo-1..N to match, with nothing leaking into frontmatter.
|
||||
// copied files photo-01..NN to match, with nothing leaking into frontmatter.
|
||||
form.addEventListener('submit', function () {
|
||||
if (!orderPond) return;
|
||||
var files = orderPond.getFiles();
|
||||
@@ -277,7 +277,7 @@ function initPhotoConversion() {
|
||||
if (pond && !pond._heicHooked) {
|
||||
pond._heicHooked = true;
|
||||
// allowReorder: drag thumbnails to set the order. The server
|
||||
// (cache-on-save) renames the copied files photo-1..N in the
|
||||
// (cache-on-save) renames the copied files photo-01..NN in the
|
||||
// submitted order so the published entry honours it (entry media
|
||||
// is filename-ordered; hero = first).
|
||||
pond.setOptions({ beforeAddFile: makeBeforeAddFile(pond), allowReorder: true, itemInsertLocation: 'after' });
|
||||
@@ -745,10 +745,10 @@ function editSetContent(value) {
|
||||
//
|
||||
// The photos/FilePond control is deliberately EXCLUDED: FilePond reads the
|
||||
// disabled state of its underlying input when the form plugin creates it and
|
||||
// never re-enables (removing its browse button, so the owner can't add photos on
|
||||
// edit — U7). Photos are also safe to leave live during the D1 prefill window:
|
||||
// they load additively (editLoadPhotos), not by overwrite, so early interaction
|
||||
// can't be clobbered the way an empty text field could.
|
||||
// never re-enables. In edit mode FilePond is decommissioned anyway — the live
|
||||
// photo editor (initPhotoEditor) hides it and manages add/delete/reorder against
|
||||
// the media API independently of this form's Save — so its disabled state during
|
||||
// the D1 prefill window is irrelevant.
|
||||
function editFormDisabled(form, disabled) {
|
||||
var els = form.querySelectorAll('input, textarea, select, button');
|
||||
Array.prototype.forEach.call(els, function (el) {
|
||||
@@ -951,14 +951,18 @@ function initPhotoEditor(route) {
|
||||
var lastGood = photos.slice();
|
||||
setBusy(true);
|
||||
setStatus('Saving order…');
|
||||
reorder(next)
|
||||
.then(function () { return mediaList(); })
|
||||
.then(function (list) { setStatus(''); render(list); })
|
||||
.catch(function () {
|
||||
setStatus('Couldn’t save the new order — reverted. Try again.', true);
|
||||
render(lastGood); // revert the SortableJS move to last-known-good
|
||||
})
|
||||
.then(function () { setBusy(false); });
|
||||
// Split the two failure stages: a failed SAVE reverts the drag; a failed
|
||||
// REFRESH after a successful save must NOT revert (the order did persist)
|
||||
// — show the saved order and reconcile on the next op.
|
||||
reorder(next).then(function () {
|
||||
return mediaList().then(
|
||||
function (list) { setStatus(''); render(list); },
|
||||
function () { setStatus(''); render(next); } // saved; DOM already shows it
|
||||
);
|
||||
}, function () {
|
||||
setStatus('Couldn’t save the new order — reverted. Try again.', true);
|
||||
render(lastGood); // revert the SortableJS move to last-known-good
|
||||
}).then(function () { setBusy(false); });
|
||||
}
|
||||
|
||||
// ✕ swaps the cell to an inline "Delete? [Confirm] [Cancel]" (option a).
|
||||
@@ -985,17 +989,26 @@ function initPhotoEditor(route) {
|
||||
|
||||
function doDelete(name) {
|
||||
var lastGood = photos.slice();
|
||||
var remaining = photos.filter(function (p) { return p !== name; });
|
||||
setBusy(true);
|
||||
setStatus('Deleting…');
|
||||
apiOk('/api/v1/pages' + route + '/media/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(function (ok) {
|
||||
if (!ok) throw new Error('delete failed');
|
||||
// Renumber the survivors so cover=first stays correct (idempotent).
|
||||
var remaining = photos.filter(function (p) { return p !== name; });
|
||||
return (remaining.length ? reorder(remaining) : Promise.resolve()).then(mediaList);
|
||||
})
|
||||
.then(function (list) { setStatus(''); render(list); })
|
||||
.catch(function () {
|
||||
// A 404 means the file is already gone — treat it as success so retrying
|
||||
// a ghost cell converges instead of looping on "couldn't delete".
|
||||
fetch('/api/v1/pages' + route + '/media/' + encodeURIComponent(name), { credentials: 'include', method: 'DELETE' })
|
||||
.then(function (r) {
|
||||
if (!(r.ok || r.status === 204 || r.status === 404)) throw new Error('delete failed');
|
||||
// Deleted. Renumber survivors (cover=first), then refresh. A failure
|
||||
// AFTER this point must NOT resurrect the deleted photo — show the
|
||||
// survivor set, never lastGood.
|
||||
return (remaining.length ? reorder(remaining) : Promise.resolve()).then(mediaList).then(
|
||||
function (list) { setStatus(''); render(list); },
|
||||
function () {
|
||||
setStatus('Photo deleted, but refreshing the list failed — reload if it looks off.', true);
|
||||
render(remaining);
|
||||
}
|
||||
);
|
||||
}, function () {
|
||||
// The DELETE request itself failed — nothing changed on disk.
|
||||
setStatus('Couldn’t delete that photo. Try again.', true);
|
||||
render(lastGood);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user