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>
86 lines
2.7 KiB
Go
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})
|
|
}
|