mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
The frontend's PostHog tracker sees everything a person clicks, but a device syncing through /store/* never loads a page — so an agent editing files all day was invisible, and "number of file changes" and "daily active users" both undercounted by however much of the product runs headless. One event, files_changed, from every write door: sync, upload (relay and direct commit), remove, restore. Its distinct_id is the same email analytics.ts identifies with, so a person on a laptop and a browser is one user, and its puts/deletes properties sum to the change count. The count comes from ops the hub has not stored before, not from the request body: a device PUTs its WHOLE journal every cycle, so counting the body would re-report the device's entire history every ten seconds and the metric would climb while nobody edited anything. journalKeepsItsOps already parsed the stored journal for the append-only check and threw the sequence away; it returns storedMax now, so this costs no extra read. Blob PUTs are deliberately not change events — content-addressed storage skips a blob it already holds, so blob writes undercount edits while ops are exact. No SDK: posthog-go would ship a tracker inside every self-hoster's binary, which is the exact thing the frontend avoids by loading posthog-js from a CDN only when a key is configured. Capture is one JSON POST, on its own goroutine, that does nothing when Analytics.Key is empty — an OSS hub still contacts nobody. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
107 lines
3.8 KiB
Go
107 lines
3.8 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 FILE: without this, restore would
|
||
// paste any blob in the store onto any path. "This file" includes the
|
||
// paths it lived at before it moved — otherwise a moved file can never
|
||
// be restored to anything older than its move. Ancestors only: the put
|
||
// is written at the current path, so a descendant's versions are not
|
||
// reachable from here anyway. buildMoveIndex needs journal order.
|
||
journal.Sort(all)
|
||
chain := chainSegments(buildMoveIndex(all), p)
|
||
var found *journal.Op
|
||
for i := range all {
|
||
op := &all[i]
|
||
if op.Kind == journal.KindPut && op.Blob == req.SHA && inSegments(chain, op.Path, op.Time) {
|
||
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()
|
||
// A restore writes a put op like any other edit, so it belongs in the same
|
||
// count. The frontend's `file_restored` says which BUTTON was pressed; this
|
||
// says a file changed.
|
||
s.captureChange(r, "browser", 1, 0)
|
||
writeJSON(w, map[string]any{"ok": true, "blob": req.SHA, "size": found.Size})
|
||
}
|