mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
History could restore an edit or a deletion, but a file a run CREATED was the one thing it couldn't reverse — the ADDED row said so in copy and offered no button. The missing capability was a hub-written delete op: restore.go only ever journaled puts. POST /api/p/<id>/remove journals exactly one journal.KindDelete op under the hub's own device identity, behind restore's gates (gateUpload, PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume- snapshot existence check so the API 404s on what the tree doesn't show. Commit's journal-append tail moves into RemoteSource.appendOp, which both writes now share — one writer per journal, unchanged. The ADDED-in-a-run row gets an "undo — remove file" control that confirms first (it reaches every synced device), and the DELETED row it leaves behind restores the file with its original bytes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
2.7 KiB
Go
85 lines
2.7 KiB
Go
package webapp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
)
|
|
|
|
// Remove un-creates a file — the other half of restore. Like restore it is a
|
|
// NEW op, never an edit to history: a delete op journaled under this server's
|
|
// own device, which every device then materializes like any other change. The
|
|
// blob stays in the store forever, so the resulting DELETED row restores the
|
|
// file straight back.
|
|
//
|
|
// Removing the offending ops instead is ruled out for the same reasons
|
|
// restore.go gives: it would break one-writer-per-journal, strand peers that
|
|
// already replayed them, and corrupt the push cursor.
|
|
|
|
// Remove appends a delete op for p to this server's own journal. A delete
|
|
// references no content, so there is no blob to push first.
|
|
func (r *RemoteSource) Remove(ctx context.Context, p string, who User, note string) error {
|
|
if r.Device.ID == "" {
|
|
return fmt.Errorf("no device identity configured for uploads")
|
|
}
|
|
return r.appendOp(ctx, journal.Op{
|
|
Kind: journal.KindDelete, Path: p,
|
|
User: who.Email, UserName: who.Name, Note: note,
|
|
})
|
|
}
|
|
|
|
// handleRemove serves POST /api/p/<id>/remove {path}.
|
|
func (s *Server) handleRemove(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"`
|
|
}
|
|
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
|
|
}
|
|
// The volume snapshot is the same map the tree and viewer serve, so the
|
|
// API agrees with what the caller was looking at. A stale-snapshot 404 is
|
|
// a harmless retry; a second, hand-rolled replay could disagree and
|
|
// delete the wrong thing.
|
|
snap, err := v.snapshot(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
if _, ok := snap.files[p]; !ok {
|
|
http.Error(w, "no such file", http.StatusNotFound)
|
|
return
|
|
}
|
|
// A delete stores 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
|
|
}
|
|
if err := rs.Remove(r.Context(), p, s.requestUser(r), "remove "+p); err != nil {
|
|
http.Error(w, fmt.Sprintf("remove: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
s.quota().RecordUsage(org, 0)
|
|
v.invalidate()
|
|
writeJSON(w, map[string]any{"ok": true, "path": p})
|
|
}
|