Files
beardrive/internal/webapp/restore.go
T
d7772a22df fix(webapp): restore is not offered on the version that is already current (BEA-57) (#110)
The newest row for a path IS the file's current content, so its `restore`
button could only ever journal a +0 −0 change — attributed to a real person,
on a real device, replicated to every teammate, in the audit trail the whole
history story depends on. It was also the single most tempting row to click.

One rule, enforced at both ends. handleRestore now 409s when the requested
sha is already the path's head (journal.Replay, the way the CLI already
answers this question), placed after the "no such version of that path" 404
and before CheckWrite so an unknown sha still 404s and a refused restore
records no quota. HistoryView computes each path's head from the loaded
window — entries are strictly newest-first, so a path's first occurrence
decides — and restoreSha returns undefined for bytes that already are the
head, which removes the button, its title and its busy state together.

The rule is content equality, not row index: an older row hand-reverted to
the current bytes is just as much of a no-op, and matching what the server
checks means the UI can never show a button that errors.

A newest DELETE leaves the path out of the replay, so a deleted file still
restores — that is a real change. Confirm-on-restore stays out, deliberately
(HistoryRow.tsx:154): the defect was never "restore should ask", it was
"restore should not be offered where it cannot do anything".

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:06:37 +09:00

96 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webapp
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/runbear-io/beardrive/internal/journal"
)
// Restore puts an old version of a file back — as a NEW op, never by editing
// history. The blob is already in the store (they are retained forever), so
// this is the upload commit minus the upload: find the historical op, journal
// a put pointing at the same blob, done. Every device then converges on it
// like any other change, and the restore is itself restorable.
//
// What it deliberately is not: removing the offending ops. That would break
// one-writer-per-journal, strand peers that already replayed them, and
// corrupt the push cursor.
// handleRestore serves POST /api/p/<id>/restore {path, sha}.
func (s *Server) handleRestore(v *volume, w http.ResponseWriter, r *http.Request) {
up := s.gateUpload(v, w) // a read-only hub stays read-only
if up == nil {
return
}
rs := storeSource(v, w)
if rs == nil {
return
}
var req struct {
Path string `json:"path"`
SHA string `json:"sha"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
p, err := cleanUploadPath(req.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !blobRe.MatchString(req.SHA) {
http.Error(w, "sha must be 64 lowercase hex chars", http.StatusBadRequest)
return
}
all, err := rs.loadOps(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// The sha must be a version OF THIS PATH: without this, restore would
// paste any blob in the store onto any path.
var found *journal.Op
for i := range all {
if op := &all[i]; op.Kind == journal.KindPut && op.Path == p && op.Blob == req.SHA {
found = op
break
}
}
if found == nil {
http.Error(w, "no such version of that file", http.StatusNotFound)
return
}
// A version that is already the file's content is not a change: writing it
// would put a +0 0 row in every teammate's history. journal.Replay sorts
// internally, so this works on the unsorted slice loadOps returns — and a
// deleted path has no state at all, so restoring it back still goes through.
if journal.Replay(all)[p].Blob == req.SHA {
http.Error(w, "that version is already the current content of "+p, http.StatusConflict)
return
}
// The blob is already stored, so a restore adds no bytes — but an org
// whose plan is blocked must still be blocked from writing.
org := s.orgOf(r.PathValue("project"))
if err := s.quota().CheckWrite(org, 0); err != nil {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
note := fmt.Sprintf("restore %s@%s", p, req.SHA[:8])
// Size comes from the historical op, never from the request body.
if err := rs.Commit(r.Context(), p, req.SHA, found.Size, s.requestUser(r), note); err != nil {
code := http.StatusBadGateway
if err == errBlobMissing {
code = http.StatusConflict
}
http.Error(w, fmt.Sprintf("restore: %v", err), code)
return
}
s.quota().RecordUsage(org, 0)
v.invalidate()
writeJSON(w, map[string]any{"ok": true, "blob": req.SHA, "size": found.Size})
}