Files
beardrive/internal/webapp/remove.go
T
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

86 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()
s.captureChange(r, "browser", 0, 1)
writeJSON(w, map[string]any{"ok": true, "path": p})
}