Files
6f0f474903 feat(hub): count file changes and headless users server-side (#164)
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>
2026-08-13 14:50:25 -07:00

107 lines
3.8 KiB
Go
Raw Permalink 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 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})
}