mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
96 lines
3.1 KiB
Go
96 lines
3.1 KiB
Go
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})
|
||
}
|