2026-07-11 14:41:07 -07:00
|
|
|
|
package webapp
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"net/http"
|
2026-07-12 07:36:37 -07:00
|
|
|
|
"sort"
|
2026-07-11 14:41:07 -07:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
2026-08-11 00:38:44 +09:00
|
|
|
|
"sync/atomic"
|
2026-07-11 14:41:07 -07:00
|
|
|
|
"time"
|
2026-08-03 16:20:51 +09:00
|
|
|
|
|
|
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
2026-07-11 14:41:07 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Read telemetry: who consumes what, aggregated. Together with the write
|
|
|
|
|
|
// history the journals already carry, this completes the read×write matrix —
|
|
|
|
|
|
// heavily-read + long-unwritten is the danger zone an admin should fix first
|
|
|
|
|
|
// (see docs/design/read-heatmap.md).
|
|
|
|
|
|
//
|
|
|
|
|
|
// What counts as a read: viewer file/render/download hits (human), share-link
|
|
|
|
|
|
// hits (share), and agent tool reads reported by syncing devices (agent).
|
|
|
|
|
|
// /store/* sync traffic is replication, not reading, and is never counted;
|
|
|
|
|
|
// history /blob views are spelunking, not consumption, and aren't either.
|
|
|
|
|
|
//
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// One exception to "never an event log": session reads (SessionRead), the
|
|
|
|
|
|
// per-session detail behind a History run card. They are a separate table
|
|
|
|
|
|
// with their own, shorter retention, and they never enter the ledger's
|
|
|
|
|
|
// in-memory bucket map — see SessionReadRepo for why that separation is the
|
|
|
|
|
|
// whole point.
|
|
|
|
|
|
//
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// Privacy: rows are daily aggregation buckets, never an event log. The actor
|
|
|
|
|
|
// column (account email / device id / share token) exists only to count
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// distinct readers and never appears in an API response — with exactly one
|
|
|
|
|
|
// stated exception, ?by=device (handleHeat), which reports the ids of agent
|
|
|
|
|
|
// devices. A device id is not a person and is already visible to every
|
|
|
|
|
|
// project member through History's device join; nothing else in the column —
|
|
|
|
|
|
// no email, no share token, no unowned device id — ever leaves the server.
|
|
|
|
|
|
// The exception is only sound because the ingest path validates the id
|
|
|
|
|
|
// (handleReadReport → ownsDevice): an actor recorded as an agent is shaped
|
|
|
|
|
|
// like a device id and is not one another account is syncing — never an
|
|
|
|
|
|
// arbitrary string, and never someone else's machine. This route also never
|
|
|
|
|
|
// registers a device: registering the id it is about to judge is what turned
|
|
|
|
|
|
// the round-2 check into a one-request speed bump.
|
2026-08-11 04:18:53 +09:00
|
|
|
|
//
|
|
|
|
|
|
// A session id is identity-adjacent and gets the same ruling, written down
|
|
|
|
|
|
// before anything serves it: a session id appears ONLY in History responses,
|
|
|
|
|
|
// on the op that carries it, and as a ?session= filter INPUT. It is never
|
|
|
|
|
|
// enumerated — no "list sessions" response, no session column in /heat's
|
|
|
|
|
|
// output, nothing new in ?by=device. That is sound because the id is already
|
|
|
|
|
|
// visible to every project member inside Op.Note today, so serving it as its
|
|
|
|
|
|
// own field discloses nothing new, while refusing to enumerate keeps /heat
|
|
|
|
|
|
// identity-free exactly as documented above.
|
2026-07-11 14:41:07 -07:00
|
|
|
|
|
|
|
|
|
|
// Read kinds.
|
|
|
|
|
|
const (
|
|
|
|
|
|
ReadKindHuman = "human"
|
|
|
|
|
|
ReadKindAgent = "agent"
|
|
|
|
|
|
ReadKindShare = "share"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ReadStat is one aggregation bucket: reads of one path by one actor on one
|
|
|
|
|
|
// day. Day == "" is the all-time fold that survives retention.
|
|
|
|
|
|
type ReadStat struct {
|
|
|
|
|
|
Project string `json:"project"`
|
|
|
|
|
|
Path string `json:"path"`
|
|
|
|
|
|
Day string `json:"day"` // "2006-01-02" UTC, or "" for all-time
|
|
|
|
|
|
Kind string `json:"kind"`
|
|
|
|
|
|
Actor string `json:"actor"`
|
|
|
|
|
|
Count int64 `json:"count"`
|
|
|
|
|
|
Last time.Time `json:"last"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ReadStatKey identifies one bucket.
|
|
|
|
|
|
type ReadStatKey struct {
|
|
|
|
|
|
Project, Path, Day, Kind, Actor string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s ReadStat) key() ReadStatKey {
|
|
|
|
|
|
return ReadStatKey{s.Project, s.Path, s.Day, s.Kind, s.Actor}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// SessionRead records that one agent session read one path, from one device.
|
|
|
|
|
|
// Not a count and not a bucket: the run card asks "did this session read this
|
|
|
|
|
|
// file?", and one row per (session, device, path) answers it with no
|
|
|
|
|
|
// aggregation. Device is always the hub-validated device the report arrived
|
|
|
|
|
|
// from, never anything the client put in the body.
|
|
|
|
|
|
type SessionRead struct {
|
|
|
|
|
|
Project string `json:"project"`
|
|
|
|
|
|
Session string `json:"session"`
|
|
|
|
|
|
Device string `json:"device"`
|
|
|
|
|
|
Path string `json:"path"`
|
|
|
|
|
|
Last time.Time `json:"last"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type sessionReadKey struct{ Project, Session, Device, Path string }
|
|
|
|
|
|
|
|
|
|
|
|
func (s SessionRead) key() sessionReadKey {
|
|
|
|
|
|
return sessionReadKey{s.Project, s.Session, s.Device, s.Path}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// HeatEntry is the per-path aggregate the heat API returns. Counts only —
|
|
|
|
|
|
// never identities.
|
|
|
|
|
|
type HeatEntry struct {
|
|
|
|
|
|
Human int64 `json:"human,omitempty"`
|
|
|
|
|
|
Agent int64 `json:"agent,omitempty"`
|
|
|
|
|
|
Share int64 `json:"share,omitempty"`
|
|
|
|
|
|
Readers int `json:"readers,omitempty"` // distinct human readers
|
|
|
|
|
|
LastRead time.Time `json:"last_read,omitzero"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
// readDebounce collapses request storms (reloads, render-then-raw double
|
|
|
|
|
|
// fetches) into visits: repeat reads of a path by the same actor within
|
|
|
|
|
|
// the window don't count again.
|
|
|
|
|
|
readDebounce = 10 * time.Minute
|
|
|
|
|
|
// readFlushEvery throttles persistence; dirty buckets ride in memory
|
|
|
|
|
|
// between flushes, so a crash loses at most this much telemetry.
|
|
|
|
|
|
readFlushEvery = 30 * time.Second
|
|
|
|
|
|
// DefaultReadRetentionDays is how long daily buckets keep per-day
|
|
|
|
|
|
// resolution before folding into the all-time row.
|
|
|
|
|
|
DefaultReadRetentionDays = 400
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// DefaultSessionRetentionDays is how long per-session read detail is
|
|
|
|
|
|
// kept. Much shorter than the bucket retention: this is event-shaped
|
|
|
|
|
|
// data whose only consumer is a History run card, and a month covers a
|
|
|
|
|
|
// retro. Rows past it are deleted, not folded — the heat totals were
|
|
|
|
|
|
// never derived from them, so nothing is lost from any count.
|
|
|
|
|
|
DefaultSessionRetentionDays = 30
|
|
|
|
|
|
// sessionPruneEvery throttles the retention delete; it rides the same
|
|
|
|
|
|
// flush the buckets use rather than owning a goroutine.
|
|
|
|
|
|
sessionPruneEvery = time.Hour
|
2026-07-11 14:41:07 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ReadLedger is the in-memory read-telemetry service over a ReadRepo, in the
|
|
|
|
|
|
// mold of DeviceRegistry: reads stay in memory, writes are throttled. There is
|
|
|
|
|
|
// no background goroutine — flushes piggyback on Record calls, and telemetry
|
|
|
|
|
|
// failures never surface to the request that triggered them.
|
|
|
|
|
|
type ReadLedger struct {
|
|
|
|
|
|
repo ReadRepo
|
|
|
|
|
|
retention time.Duration
|
|
|
|
|
|
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// Session-read detail, optional (nil = off) and deliberately outside
|
|
|
|
|
|
// byKey: these rows are never loaded into memory in bulk, so Heat's full
|
|
|
|
|
|
// map scan and the boot load are unaffected by session cardinality. If a
|
|
|
|
|
|
// future change ever moves them into byKey, Heat (below) is what pays.
|
|
|
|
|
|
sessions SessionReadRepo
|
|
|
|
|
|
sessionRetention time.Duration
|
|
|
|
|
|
|
2026-08-11 00:38:44 +09:00
|
|
|
|
// scans counts ShareOpens passes over byKey. Tests assert one per
|
|
|
|
|
|
// project per list render — the "never one scan per share" rule is
|
|
|
|
|
|
// invisible in the response body, so this is the only thing that can
|
|
|
|
|
|
// catch the regression.
|
|
|
|
|
|
scans atomic.Int64
|
|
|
|
|
|
|
2026-08-11 04:18:53 +09:00
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
byKey map[ReadStatKey]ReadStat
|
|
|
|
|
|
dirty map[ReadStatKey]bool
|
|
|
|
|
|
pendingDel []ReadStatKey // retention deletions awaiting a successful flush
|
|
|
|
|
|
seen map[ReadStatKey]time.Time // debounce; Day field unused ("")
|
|
|
|
|
|
pendingSess map[sessionReadKey]SessionRead
|
|
|
|
|
|
lastFlush time.Time
|
|
|
|
|
|
lastSessPrun time.Time
|
|
|
|
|
|
warned bool
|
|
|
|
|
|
sessWarned bool
|
2026-07-11 14:41:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewReadLedger loads the ledger and immediately folds buckets older than the
|
|
|
|
|
|
// retention horizon into their all-time rows. retentionDays <= 0 means the
|
|
|
|
|
|
// default.
|
|
|
|
|
|
func NewReadLedger(repo ReadRepo, retentionDays int) (*ReadLedger, error) {
|
|
|
|
|
|
if retentionDays <= 0 {
|
|
|
|
|
|
retentionDays = DefaultReadRetentionDays
|
|
|
|
|
|
}
|
|
|
|
|
|
l := &ReadLedger{
|
|
|
|
|
|
repo: repo,
|
|
|
|
|
|
retention: time.Duration(retentionDays) * 24 * time.Hour,
|
|
|
|
|
|
byKey: map[ReadStatKey]ReadStat{},
|
|
|
|
|
|
dirty: map[ReadStatKey]bool{},
|
|
|
|
|
|
seen: map[ReadStatKey]time.Time{},
|
|
|
|
|
|
lastFlush: time.Now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
stats, err := repo.Load()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, st := range stats {
|
|
|
|
|
|
l.byKey[st.key()] = st
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fold anything past the retention horizon right away. A failed persist
|
|
|
|
|
|
// is not a boot failure — the fold stays dirty and later flushes retry.
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
l.compactLocked()
|
|
|
|
|
|
if err := l.persistLocked(); err != nil {
|
|
|
|
|
|
log.Printf("beardrive: read telemetry compact failed (will retry): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
l.mu.Unlock()
|
|
|
|
|
|
return l, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OpenReadLedger loads the file-backed ledger at path.
|
|
|
|
|
|
func OpenReadLedger(path string, retentionDays int) (*ReadLedger, error) {
|
|
|
|
|
|
return NewReadLedger(newFileReadRepo(path), retentionDays)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// OpenSessionReadRepo is the file-backed session-read store, for hubs
|
|
|
|
|
|
// running without a MetaStore (the historical JSON-files layout).
|
|
|
|
|
|
func OpenSessionReadRepo(path string) SessionReadRepo { return newFileSessionReadRepo(path) }
|
|
|
|
|
|
|
|
|
|
|
|
// WithSessions turns on per-session read detail (the data behind a History
|
|
|
|
|
|
// run card). Separate from the constructor so every existing caller — and
|
|
|
|
|
|
// every backend that has no session repo — keeps working with it off.
|
|
|
|
|
|
// retentionDays <= 0 means the default.
|
|
|
|
|
|
func (l *ReadLedger) WithSessions(repo SessionReadRepo, retentionDays int) *ReadLedger {
|
|
|
|
|
|
if l == nil || repo == nil {
|
|
|
|
|
|
return l
|
|
|
|
|
|
}
|
|
|
|
|
|
if retentionDays <= 0 {
|
|
|
|
|
|
retentionDays = DefaultSessionRetentionDays
|
|
|
|
|
|
}
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
l.sessions = repo
|
|
|
|
|
|
l.sessionRetention = time.Duration(retentionDays) * 24 * time.Hour
|
|
|
|
|
|
l.pendingSess = map[sessionReadKey]SessionRead{}
|
|
|
|
|
|
return l
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// Record counts one read. Nil-safe and never fails: telemetry must not break
|
|
|
|
|
|
// the page view (or sync cycle) that triggered it.
|
|
|
|
|
|
func (l *ReadLedger) Record(project, path, kind, actor string) {
|
|
|
|
|
|
if l == nil || project == "" || path == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
visit := ReadStatKey{Project: project, Path: path, Kind: kind, Actor: actor}
|
|
|
|
|
|
if t, ok := l.seen[visit]; ok && now.Sub(t) < readDebounce {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
l.seen[visit] = now
|
|
|
|
|
|
key := visit
|
|
|
|
|
|
key.Day = now.Format("2006-01-02")
|
|
|
|
|
|
st := l.byKey[key]
|
|
|
|
|
|
st.Project, st.Path, st.Day, st.Kind, st.Actor = project, path, key.Day, kind, actor
|
|
|
|
|
|
st.Count++
|
|
|
|
|
|
st.Last = now
|
|
|
|
|
|
l.byKey[key] = st
|
|
|
|
|
|
l.dirty[key] = true
|
|
|
|
|
|
if now.Sub(l.lastFlush) >= readFlushEvery {
|
|
|
|
|
|
l.flushLocked()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// RecordSession notes that one agent session read one path from one device.
|
|
|
|
|
|
// Nil-safe, off when no session repo is configured, and — like Record —
|
|
|
|
|
|
// never fails: telemetry must not break the sync cycle that reported it.
|
|
|
|
|
|
// Unlike Record it is NOT debounced: a row is a fact ("this session read this
|
|
|
|
|
|
// file"), not a count, so repeats are the same row rewritten.
|
|
|
|
|
|
func (l *ReadLedger) RecordSession(project, session, device, path string) {
|
|
|
|
|
|
if l == nil || project == "" || session == "" || device == "" || path == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
if l.sessions == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
sr := SessionRead{Project: project, Session: session, Device: device, Path: path, Last: now.UTC()}
|
|
|
|
|
|
l.pendingSess[sr.key()] = sr
|
|
|
|
|
|
// Same throttle the buckets use. Record's own flush check sits behind its
|
|
|
|
|
|
// debounce return, so a report whose buckets are all debounced would
|
|
|
|
|
|
// otherwise leave these rows buffered indefinitely.
|
|
|
|
|
|
if now.Sub(l.lastFlush) >= readFlushEvery {
|
|
|
|
|
|
l.flushLocked()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SessionPaths returns the paths one session read from one device, for the
|
|
|
|
|
|
// History run card. Both the session and the device are required by the
|
|
|
|
|
|
// caller (handleHeat): a session-only lookup would return rows a member
|
|
|
|
|
|
// reported under someone else's session id, which pinning to the validated
|
|
|
|
|
|
// device is what makes harmless.
|
|
|
|
|
|
func (l *ReadLedger) SessionPaths(project, session, device string) []string {
|
|
|
|
|
|
if l == nil || project == "" || session == "" || device == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
repo := l.sessions
|
|
|
|
|
|
// Flush first, so a card opened seconds after a sync sees that sync's
|
|
|
|
|
|
// reads instead of an empty list.
|
|
|
|
|
|
if repo != nil {
|
|
|
|
|
|
l.flushSessionsLocked()
|
|
|
|
|
|
}
|
|
|
|
|
|
l.mu.Unlock()
|
|
|
|
|
|
if repo == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
rows, err := repo.ListBySession(project, session, device)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("beardrive: session reads lookup failed: %v", err)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]string, 0, len(rows))
|
|
|
|
|
|
for _, r := range rows {
|
|
|
|
|
|
out = append(out, r.Path)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// flushSessionsLocked persists buffered session rows and, at most hourly,
|
|
|
|
|
|
// deletes the ones past the session retention. Failures keep the buffer for
|
|
|
|
|
|
// the next attempt and log once — a read_sessions failure must never affect
|
|
|
|
|
|
// read_stats, so this is deliberately separate from persistLocked. Callers
|
|
|
|
|
|
// hold mu.
|
|
|
|
|
|
func (l *ReadLedger) flushSessionsLocked() {
|
|
|
|
|
|
if l.sessions == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(l.pendingSess) > 0 {
|
|
|
|
|
|
batch := make([]SessionRead, 0, len(l.pendingSess))
|
|
|
|
|
|
for _, sr := range l.pendingSess {
|
|
|
|
|
|
batch = append(batch, sr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := l.sessions.PutBatch(batch); err != nil {
|
|
|
|
|
|
if !l.sessWarned {
|
|
|
|
|
|
l.sessWarned = true
|
|
|
|
|
|
log.Printf("beardrive: session read flush failed (will retry): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
l.sessWarned = false
|
|
|
|
|
|
l.pendingSess = map[sessionReadKey]SessionRead{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
if now.Sub(l.lastSessPrun) < sessionPruneEvery {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
l.lastSessPrun = now
|
|
|
|
|
|
if err := l.sessions.PruneBefore(now.UTC().Add(-l.sessionRetention)); err != nil {
|
|
|
|
|
|
log.Printf("beardrive: session read prune failed (will retry): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// Heat aggregates reads per path for one project. since bounds the window
|
|
|
|
|
|
// (zero = all time, including retention folds); prefix "" means the whole
|
|
|
|
|
|
// project, otherwise paths under "<prefix>/".
|
|
|
|
|
|
func (l *ReadLedger) Heat(project, prefix string, since time.Time) map[string]HeatEntry {
|
|
|
|
|
|
if l == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
sinceDay := ""
|
|
|
|
|
|
if !since.IsZero() {
|
|
|
|
|
|
sinceDay = since.UTC().Format("2006-01-02")
|
|
|
|
|
|
}
|
|
|
|
|
|
prefix = strings.TrimSuffix(prefix, "/")
|
|
|
|
|
|
out := map[string]HeatEntry{}
|
|
|
|
|
|
humans := map[string]map[string]bool{} // path → distinct human actors
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
for key, st := range l.byKey {
|
|
|
|
|
|
if key.Project != project {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if prefix != "" && !strings.HasPrefix(key.Path, prefix+"/") {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if key.Day == "" {
|
|
|
|
|
|
if sinceDay != "" {
|
|
|
|
|
|
continue // all-time fold is older than any windowed query
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if key.Day < sinceDay {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
e := out[key.Path]
|
|
|
|
|
|
switch key.Kind {
|
|
|
|
|
|
case ReadKindHuman:
|
|
|
|
|
|
e.Human += st.Count
|
|
|
|
|
|
set := humans[key.Path]
|
|
|
|
|
|
if set == nil {
|
|
|
|
|
|
set = map[string]bool{}
|
|
|
|
|
|
humans[key.Path] = set
|
|
|
|
|
|
}
|
|
|
|
|
|
set[key.Actor] = true
|
|
|
|
|
|
case ReadKindAgent:
|
|
|
|
|
|
e.Agent += st.Count
|
|
|
|
|
|
case ReadKindShare:
|
|
|
|
|
|
e.Share += st.Count
|
|
|
|
|
|
}
|
|
|
|
|
|
if st.Last.After(e.LastRead) {
|
|
|
|
|
|
e.LastRead = st.Last
|
|
|
|
|
|
}
|
|
|
|
|
|
out[key.Path] = e
|
|
|
|
|
|
}
|
|
|
|
|
|
for p, set := range humans {
|
|
|
|
|
|
e := out[p]
|
|
|
|
|
|
e.Readers = len(set)
|
|
|
|
|
|
out[p] = e
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 07:36:37 -07:00
|
|
|
|
// AgentHeat aggregates agent reads per device per top-level folder ("" for
|
|
|
|
|
|
// root files) — the coverage-matrix data. Agent buckets only, by design:
|
|
|
|
|
|
// agent actors are device ids, which history already exposes; human actors
|
|
|
|
|
|
// (emails) must never leave the server, so human/share buckets are not
|
|
|
|
|
|
// consulted at all.
|
|
|
|
|
|
func (l *ReadLedger) AgentHeat(project string, since time.Time) map[string]map[string]int64 {
|
|
|
|
|
|
if l == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
sinceDay := ""
|
|
|
|
|
|
if !since.IsZero() {
|
|
|
|
|
|
sinceDay = since.UTC().Format("2006-01-02")
|
|
|
|
|
|
}
|
|
|
|
|
|
out := map[string]map[string]int64{}
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
for key, st := range l.byKey {
|
|
|
|
|
|
if key.Project != project || key.Kind != ReadKindAgent {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if key.Day == "" {
|
|
|
|
|
|
if sinceDay != "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if key.Day < sinceDay {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
folder := ""
|
|
|
|
|
|
if i := strings.IndexByte(key.Path, '/'); i >= 0 {
|
|
|
|
|
|
folder = key.Path[:i]
|
|
|
|
|
|
}
|
|
|
|
|
|
m := out[key.Actor]
|
|
|
|
|
|
if m == nil {
|
|
|
|
|
|
m = map[string]int64{}
|
|
|
|
|
|
out[key.Actor] = m
|
|
|
|
|
|
}
|
|
|
|
|
|
m[folder] += st.Count
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 00:38:44 +09:00
|
|
|
|
// ShareOpen is share-link consumption for one path: visits, and when.
|
|
|
|
|
|
type ShareOpen struct {
|
|
|
|
|
|
Count int64
|
|
|
|
|
|
Last time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ShareOpens aggregates share-kind reads per path for one project — the
|
|
|
|
|
|
// receipt a person who shared something actually wants. All-time, because a
|
|
|
|
|
|
// link's lifetime is the question a receipt answers.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Share buckets only, and that is what makes Last mean *last opened*:
|
|
|
|
|
|
// HeatEntry.LastRead is cross-kind, so a member viewing the file in the hub
|
|
|
|
|
|
// would otherwise move the "opened through the link" date.
|
|
|
|
|
|
//
|
2026-08-19 12:32:53 -07:00
|
|
|
|
// Counts, never identities — the share actor is token+"/"+IP+"/"+UA hash, a
|
|
|
|
|
|
// public credential joined to a network and a browser, and it must not leave
|
|
|
|
|
|
// the ledger. There is deliberately no distinct-openers field.
|
2026-08-11 00:38:44 +09:00
|
|
|
|
//
|
|
|
|
|
|
// One byKey scan per project, never one per share: callers build this map
|
|
|
|
|
|
// once and index it, because byKey is the full map and a project with 40
|
|
|
|
|
|
// links would otherwise pay 40 full scans per list render.
|
|
|
|
|
|
func (l *ReadLedger) ShareOpens(project string) map[string]ShareOpen {
|
|
|
|
|
|
if l == nil {
|
|
|
|
|
|
return nil // reads disabled: absent, not zero
|
|
|
|
|
|
}
|
|
|
|
|
|
l.scans.Add(1)
|
|
|
|
|
|
out := map[string]ShareOpen{}
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
for key, st := range l.byKey {
|
|
|
|
|
|
if key.Project != project || key.Kind != ReadKindShare {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// No day filter: both the daily buckets and the folded Day == ""
|
|
|
|
|
|
// all-time row count.
|
|
|
|
|
|
e := out[key.Path]
|
|
|
|
|
|
e.Count += st.Count
|
|
|
|
|
|
if st.Last.After(e.Last) {
|
|
|
|
|
|
e.Last = st.Last
|
|
|
|
|
|
}
|
|
|
|
|
|
out[key.Path] = e
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// Close flushes any pending buckets.
|
|
|
|
|
|
func (l *ReadLedger) Close() error {
|
|
|
|
|
|
if l == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
l.flushLocked()
|
|
|
|
|
|
if n := len(l.dirty); n > 0 {
|
|
|
|
|
|
return fmt.Errorf("read ledger: flush failed, %d buckets pending", n)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// flushLocked persists dirty buckets (and, once a day, retention folds),
|
|
|
|
|
|
// pruning the debounce map along the way. Failures keep the buckets dirty for
|
|
|
|
|
|
// the next attempt and log once — telemetry never breaks a request.
|
|
|
|
|
|
func (l *ReadLedger) flushLocked() {
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
l.lastFlush = now
|
|
|
|
|
|
for k, t := range l.seen {
|
|
|
|
|
|
if now.Sub(t) >= readDebounce {
|
|
|
|
|
|
delete(l.seen, k)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
l.compactLocked()
|
|
|
|
|
|
if err := l.persistLocked(); err != nil {
|
|
|
|
|
|
if !l.warned {
|
|
|
|
|
|
l.warned = true
|
|
|
|
|
|
log.Printf("beardrive: read telemetry flush failed (will retry): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
l.warned = false
|
|
|
|
|
|
}
|
2026-08-11 04:18:53 +09:00
|
|
|
|
l.flushSessionsLocked()
|
2026-07-11 14:41:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// compactLocked folds daily buckets older than the retention horizon into
|
|
|
|
|
|
// their all-time rows, queueing the daily rows for deletion. Callers hold mu.
|
|
|
|
|
|
func (l *ReadLedger) compactLocked() {
|
|
|
|
|
|
horizon := time.Now().UTC().Add(-l.retention).Format("2006-01-02")
|
|
|
|
|
|
for key, st := range l.byKey {
|
|
|
|
|
|
if key.Day == "" || key.Day >= horizon {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
fold := key
|
|
|
|
|
|
fold.Day = ""
|
|
|
|
|
|
agg := l.byKey[fold]
|
|
|
|
|
|
agg.Project, agg.Path, agg.Kind, agg.Actor = st.Project, st.Path, st.Kind, st.Actor
|
|
|
|
|
|
agg.Day = ""
|
|
|
|
|
|
agg.Count += st.Count
|
|
|
|
|
|
if st.Last.After(agg.Last) {
|
|
|
|
|
|
agg.Last = st.Last
|
|
|
|
|
|
}
|
|
|
|
|
|
l.byKey[fold] = agg
|
|
|
|
|
|
l.dirty[fold] = true
|
|
|
|
|
|
delete(l.byKey, key)
|
|
|
|
|
|
delete(l.dirty, key)
|
|
|
|
|
|
l.pendingDel = append(l.pendingDel, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// persistLocked writes queued deletions and dirty buckets through the repo.
|
|
|
|
|
|
// Callers hold mu. Both queues survive a failure so the next flush retries —
|
|
|
|
|
|
// dropping a deletion would resurrect folded rows on the next load and
|
|
|
|
|
|
// double-count them.
|
|
|
|
|
|
func (l *ReadLedger) persistLocked() error {
|
|
|
|
|
|
if len(l.pendingDel) > 0 {
|
|
|
|
|
|
if err := l.repo.DeleteBatch(l.pendingDel); err != nil {
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// Same one-transaction problem as the put path below, worse for
|
|
|
|
|
|
// being first: a key the store will never accept parks here
|
|
|
|
|
|
// forever and PutBatch is then never reached at all, so the whole
|
|
|
|
|
|
// hub's telemetry stops persisting. Retry one at a time; if some
|
|
|
|
|
|
// land, the ones that did not are keys this store will never
|
|
|
|
|
|
// accept, so drop them. If none land the store is down —
|
|
|
|
|
|
// transient — and the queue stands for the next flush.
|
|
|
|
|
|
if len(l.pendingDel) == 1 {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
var stuck []ReadStatKey
|
|
|
|
|
|
landed := 0
|
|
|
|
|
|
for _, key := range l.pendingDel {
|
|
|
|
|
|
if l.repo.DeleteBatch([]ReadStatKey{key}) == nil {
|
|
|
|
|
|
landed++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stuck = append(stuck, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if landed == 0 {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, key := range stuck {
|
|
|
|
|
|
log.Printf("beardrive: read telemetry dropped an undeletable bucket (project %s, path %q): %v",
|
|
|
|
|
|
key.Project, key.Path, err)
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
l.pendingDel = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(l.dirty) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
batch := make([]ReadStat, 0, len(l.dirty))
|
|
|
|
|
|
for key := range l.dirty {
|
|
|
|
|
|
batch = append(batch, l.byKey[key])
|
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
err := l.repo.PutBatch(batch)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
l.dirty = map[ReadStatKey]bool{}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(batch) == 1 {
|
2026-07-11 14:41:07 -07:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// One transaction, so one bucket the store refuses takes every other
|
|
|
|
|
|
// bucket down with it — and, because they stay dirty, every bucket the hub
|
|
|
|
|
|
// counts from then on. That is a hub-wide telemetry kill from the lowest
|
|
|
|
|
|
// privilege there is (Postgres rejects a NUL byte in a path; sqlite and
|
|
|
|
|
|
// the file backend store it happily). Retry one at a time: if some land,
|
|
|
|
|
|
// the ones that did not are content this store will never accept, so drop
|
|
|
|
|
|
// them rather than wedge the queue. If none land the store itself is
|
|
|
|
|
|
// down — transient — and everything stays dirty for the next flush.
|
|
|
|
|
|
var stuck []ReadStatKey
|
|
|
|
|
|
landed := 0
|
|
|
|
|
|
for key := range l.dirty {
|
|
|
|
|
|
if l.repo.PutBatch([]ReadStat{l.byKey[key]}) == nil {
|
|
|
|
|
|
delete(l.dirty, key)
|
|
|
|
|
|
landed++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stuck = append(stuck, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if landed == 0 {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, key := range stuck {
|
|
|
|
|
|
log.Printf("beardrive: read telemetry dropped an unstorable bucket (project %s, path %q): %v",
|
|
|
|
|
|
key.Project, key.Path, err)
|
|
|
|
|
|
delete(l.dirty, key)
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// hasControlChars reports whether s carries a C0/C7F control character —
|
|
|
|
|
|
// never legitimate in a path, and fatal to a Postgres text column.
|
|
|
|
|
|
func hasControlChars(s string) bool {
|
|
|
|
|
|
return strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// ---- server integration ----
|
|
|
|
|
|
|
|
|
|
|
|
// ctxProjectKey carries the resolved project id from the proj() route
|
|
|
|
|
|
// resolver to handlers that record reads.
|
|
|
|
|
|
type ctxProjectKey struct{}
|
|
|
|
|
|
|
|
|
|
|
|
func withProjectID(r *http.Request, id string) *http.Request {
|
|
|
|
|
|
return r.WithContext(context.WithValue(r.Context(), ctxProjectKey{}, id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func projectID(r *http.Request) string {
|
|
|
|
|
|
id, _ := r.Context().Value(ctxProjectKey{}).(string)
|
|
|
|
|
|
return id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// recordRead counts a human read of path for the request's project. No-op
|
|
|
|
|
|
// outside hub mode (no project id) or when read tracking is off.
|
|
|
|
|
|
func (s *Server) recordRead(r *http.Request, path string) {
|
|
|
|
|
|
if s.Reads == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
project := projectID(r)
|
|
|
|
|
|
if project == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
actor := s.requestUser(r).Email
|
|
|
|
|
|
if actor == "" {
|
|
|
|
|
|
actor = "anonymous"
|
|
|
|
|
|
}
|
|
|
|
|
|
s.Reads.Record(project, path, ReadKindHuman, actor)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// handleHeat serves per-path read aggregates: ?prefix= bounds to a folder,
|
2026-07-12 07:36:37 -07:00
|
|
|
|
// ?days= bounds the window (default 30, 0 = all time). With ?by=device it
|
|
|
|
|
|
// returns the agent-kind breakdown instead: per device (registry-joined),
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// reads per top-level folder — the one place an actor id is reported, and
|
|
|
|
|
|
// only ever a device the reporting account owned (see the package comment).
|
|
|
|
|
|
// Human and share actors never leave the server in any shape.
|
2026-07-11 14:41:07 -07:00
|
|
|
|
func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if s.Reads == nil {
|
|
|
|
|
|
http.Error(w, "read tracking is not enabled on this server", http.StatusNotFound)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = v
|
|
|
|
|
|
q := r.URL.Query()
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// ?session=&device= is the run-card join: which paths that agent session
|
|
|
|
|
|
// read. Both are required — a session-only query would also return rows a
|
|
|
|
|
|
// member reported under someone else's session id, which pinning the row
|
|
|
|
|
|
// to the reporting device (handleReadReport) is what makes harmless. This
|
|
|
|
|
|
// is a filter INPUT only: nothing here or anywhere else enumerates
|
|
|
|
|
|
// sessions, and the response carries paths, no identities and no counts.
|
|
|
|
|
|
if session := q.Get("session"); session != "" || q.Get("device") != "" {
|
|
|
|
|
|
device := q.Get("device")
|
|
|
|
|
|
if session == "" || device == "" {
|
|
|
|
|
|
http.Error(w, "session and device must be given together", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
paths := s.Reads.SessionPaths(projectID(r), session, device)
|
|
|
|
|
|
if paths == nil {
|
|
|
|
|
|
paths = []string{} // an empty list, never a null the client must special-case
|
|
|
|
|
|
}
|
|
|
|
|
|
writeJSON(w, map[string]any{"paths": paths})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
days := 30
|
|
|
|
|
|
if raw := q.Get("days"); raw != "" {
|
|
|
|
|
|
var err error
|
|
|
|
|
|
if days, err = strconv.Atoi(raw); err != nil || days < 0 {
|
|
|
|
|
|
http.Error(w, "invalid days", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
var since time.Time
|
|
|
|
|
|
if days > 0 {
|
|
|
|
|
|
since = time.Now().UTC().AddDate(0, 0, -days)
|
|
|
|
|
|
}
|
2026-07-12 07:36:37 -07:00
|
|
|
|
switch q.Get("by") {
|
|
|
|
|
|
case "":
|
|
|
|
|
|
case "device":
|
|
|
|
|
|
s.heatByDevice(w, projectID(r), since)
|
|
|
|
|
|
return
|
|
|
|
|
|
default:
|
|
|
|
|
|
http.Error(w, "invalid by (use device)", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
entries := s.Reads.Heat(projectID(r), q.Get("prefix"), since)
|
|
|
|
|
|
out := map[string]any{"entries": entries}
|
|
|
|
|
|
if !since.IsZero() {
|
|
|
|
|
|
out["since"] = since.Format("2006-01-02")
|
|
|
|
|
|
}
|
|
|
|
|
|
writeJSON(w, out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 07:36:37 -07:00
|
|
|
|
// deviceHeat is one row of the ?by=device response.
|
|
|
|
|
|
type deviceHeat struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Name string `json:"name,omitempty"`
|
|
|
|
|
|
OS string `json:"os,omitempty"`
|
|
|
|
|
|
Folders map[string]int64 `json:"folders"`
|
|
|
|
|
|
Total int64 `json:"total"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Server) heatByDevice(w http.ResponseWriter, project string, since time.Time) {
|
|
|
|
|
|
byDevice := s.Reads.AgentHeat(project, since)
|
2026-08-03 16:20:51 +09:00
|
|
|
|
visible := s.deviceVisibleIn(project)
|
2026-07-12 07:36:37 -07:00
|
|
|
|
devices := make([]deviceHeat, 0, len(byDevice))
|
|
|
|
|
|
for id, folders := range byDevice {
|
|
|
|
|
|
d := deviceHeat{ID: id, Folders: folders}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// Scoped join: a device owned by an account outside this project's org
|
|
|
|
|
|
// contributes no name or OS, so heat cannot become a window onto
|
|
|
|
|
|
// another org's machines.
|
|
|
|
|
|
if info, ok := s.Devices.LookupIn(id, visible); ok {
|
2026-07-12 07:36:37 -07:00
|
|
|
|
d.Name, d.OS = info.Name, info.OS
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, n := range folders {
|
|
|
|
|
|
d.Total += n
|
|
|
|
|
|
}
|
|
|
|
|
|
devices = append(devices, d)
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Slice(devices, func(i, j int) bool {
|
|
|
|
|
|
if devices[i].Total != devices[j].Total {
|
|
|
|
|
|
return devices[i].Total > devices[j].Total
|
|
|
|
|
|
}
|
|
|
|
|
|
return devices[i].ID < devices[j].ID
|
|
|
|
|
|
})
|
|
|
|
|
|
out := map[string]any{"devices": devices}
|
|
|
|
|
|
if !since.IsZero() {
|
|
|
|
|
|
out["since"] = since.Format("2006-01-02")
|
|
|
|
|
|
}
|
|
|
|
|
|
writeJSON(w, out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// handleReadReport ingests agent reads from a syncing device: the client's
|
|
|
|
|
|
// read spool, drained best-effort at sync time. Requires a device identity —
|
|
|
|
|
|
// the device id is the actor, so reads count as agent traffic.
|
|
|
|
|
|
func (s *Server) handleReadReport(v *volume, w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if s.Reads == nil {
|
|
|
|
|
|
http.Error(w, "read tracking is not enabled on this server", http.StatusNotFound)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
device := deviceID(r)
|
2026-07-11 14:41:07 -07:00
|
|
|
|
if device == "" {
|
|
|
|
|
|
http.Error(w, "agent read reports need a device identity", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
Reads []struct {
|
|
|
|
|
|
Path string `json:"path"`
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// Session is the agent session the read happened in — a CLIENT
|
|
|
|
|
|
// string, so it is only ever stored alongside the device the hub
|
|
|
|
|
|
// validated below, never on its own. See the row write.
|
|
|
|
|
|
Session string `json:"session,omitempty"`
|
2026-07-11 14:41:07 -07:00
|
|
|
|
// Time is accepted for forward compatibility but buckets use
|
|
|
|
|
|
// server time: client clocks are unreliable and late flushes are
|
|
|
|
|
|
// telemetry noise, not data loss.
|
|
|
|
|
|
Time time.Time `json:"time,omitzero"`
|
|
|
|
|
|
} `json:"reads"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
|
|
|
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(req.Reads) > 4096 {
|
|
|
|
|
|
http.Error(w, "too many reads in one report", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// The device id becomes the actor these buckets are keyed by, and /heat
|
|
|
|
|
|
// reports agent actors — so an unvalidated header would let any member
|
|
|
|
|
|
// plant any string (an id from another org, or an account email) and have
|
|
|
|
|
|
// the hub serve it back to the whole project as a reader. This route
|
|
|
|
|
|
// deliberately does NOT observe the device: registering the id it is about
|
|
|
|
|
|
// to judge is what made the round-2 check a one-request speed bump. Only
|
|
|
|
|
|
// /store/* traffic registers a device.
|
|
|
|
|
|
mine := s.ownsDevice(r, device)
|
|
|
|
|
|
// A reported path is a claim about a file, and the heat map is what the
|
|
|
|
|
|
// Dashboard's reads-x-staleness quadrant is built from — the view an
|
|
|
|
|
|
// operator reads to decide what is stale. Any member with PermRead could
|
|
|
|
|
|
// report any string, so the quadrant was member-writable fiction: a
|
|
|
|
|
|
// "compliance/soc2-evidence-2026.md" nobody ever wrote showed up as read.
|
|
|
|
|
|
// The project's own replayed state is the only thing that can say a path is
|
|
|
|
|
|
// real, and it is right here. A snapshot the store cannot produce records
|
|
|
|
|
|
// nothing this cycle: the client's spool is drained best-effort and retried,
|
|
|
|
|
|
// and telemetry must never fail a request (nor invent one).
|
|
|
|
|
|
snap, err := v.snapshot(r.Context())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
writeJSON(w, map[string]any{"accepted": 0})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
project := projectID(r)
|
|
|
|
|
|
n := 0
|
|
|
|
|
|
for _, e := range req.Reads {
|
2026-08-03 16:20:51 +09:00
|
|
|
|
// A path is a bucket key that reaches the metadata store: a control
|
|
|
|
|
|
// character (a NUL above all) is rejected outright by Postgres, and a
|
|
|
|
|
|
// row the store will never accept has to be refused here rather than
|
|
|
|
|
|
// discovered at flush time.
|
|
|
|
|
|
// journal.SafePath is the rule, in the one place it is defined. This
|
|
|
|
|
|
// was a fourth copy of it, and it disagreed in both directions: it
|
|
|
|
|
|
// accepted "/etc/passwd", "a//b" and "./a", and refused "my..file".
|
|
|
|
|
|
if !journal.SafePath(e.Path) {
|
2026-07-11 14:41:07 -07:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
if !mine {
|
|
|
|
|
|
continue // not this account's device: counted for nobody
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, real := snap.files[e.Path]; !real {
|
|
|
|
|
|
continue // no such file in this project: a read of nothing is not a read
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
s.Reads.Record(project, e.Path, ReadKindAgent, device)
|
2026-08-11 04:18:53 +09:00
|
|
|
|
// The session id is the one field here the hub cannot vouch for: it
|
|
|
|
|
|
// arrives in the body, so any member could report reads naming a
|
|
|
|
|
|
// teammate's session and paint files onto that teammate's run card.
|
|
|
|
|
|
// The row is therefore pinned to `device` — the id ownsDevice just
|
|
|
|
|
|
// validated — and the query side requires BOTH session and device, so
|
|
|
|
|
|
// a forged row can only ever be found under the forger's own device,
|
|
|
|
|
|
// which MayActAs guarantees is never someone else's.
|
|
|
|
|
|
if sess := trimText(e.Session, 128); sess != "" && journal.SafeText(sess) {
|
|
|
|
|
|
s.Reads.RecordSession(project, sess, device, e.Path)
|
|
|
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
n++
|
|
|
|
|
|
}
|
|
|
|
|
|
writeJSON(w, map[string]any{"accepted": n})
|
|
|
|
|
|
}
|