Files
beardrive/internal/webapp/history.go
T
e19fec0534 feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)
There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:52:20 +09:00

352 lines
12 KiB
Go

package webapp
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// History is read straight from the journals: every put/delete ever made,
// newest first, with the account that made it and what the server knows
// about the device it came from. Content is content-addressed and retained
// forever, so each entry links to its exact version — the groundwork for
// the revert/rollback phase, where restoring is just writing an old blob
// back as a new op.
// historyDevice is the slice of the device registry history is allowed to
// report: who/what made the change, not where they connected from. The
// registry keeps observing and persisting the IP (devices.go) — it just
// doesn't ride along here, where every project member reads it. Mirrors
// heatByDevice (reads.go).
type historyDevice struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
}
// HistoryEntry is one change as the history API reports it.
type HistoryEntry struct {
Time string `json:"time"`
Kind string `json:"kind"` // add | edit | delete
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
User string `json:"user,omitempty"`
UserName string `json:"user_name,omitempty"`
Author string `json:"author,omitempty"` // offline/git fallback identity
Device historyDevice `json:"device"`
Note string `json:"note,omitempty"`
}
// histLess is the display order of the history feed: newest wall-clock time
// first, ties in reverse journal order. Journal order is causal, not
// chronological (see journal.Less) — this is the one place that reconciles
// the two, and the cursor skips with the same function the feed sorts with,
// so paging can never disagree with what a reader sees. journal.Less is a
// total order and (device, seq) is unique per op, so histLess is total too:
// no stable sort needed, and equal timestamps come back in the same order
// on every request.
func histLess(a, b journal.Op) bool {
if !a.Time.Equal(b.Time) {
return a.Time.After(b.Time)
}
return journal.Less(b, a)
}
// histCursor is the ordering tuple of the last entry of a page — everything
// histLess reads, and nothing else. It rides the wire base64'd so a client
// treats it as opaque: HistoryEntry.time is formatted to whole seconds and
// carries no lamport/seq, so a client-computed cursor would be lossy across
// same-second ops.
type histCursor struct {
// RFC3339Nano, not UnixNano: Op.Time is unvalidated peer JSON and
// UnixNano is undefined outside [1678, 2262], so a date of 2300 read back
// as 1715 — the skip loop then walked past every entry and returned a
// clean end of feed. One journal push hid the whole audit trail past page
// one from every other member.
T string `json:"t"` // op time, RFC3339Nano
L int64 `json:"l"` // lamport
S int64 `json:"s"` // per-device seq
D string `json:"d"` // device
}
func encodeCursor(op journal.Op) string {
b, err := json.Marshal(histCursor{T: op.Time.UTC().Format(time.RFC3339Nano), L: op.Lamport, S: op.Seq, D: op.Device})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(b)
}
// decodeCursor rebuilds the four ordering fields into a bare Op — JSON
// rather than a delimited string, so a device id can never collide with a
// separator.
func decodeCursor(s string) (journal.Op, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return journal.Op{}, err
}
var c histCursor
if err := json.Unmarshal(raw, &c); err != nil {
return journal.Op{}, err
}
ts, err := time.Parse(time.RFC3339Nano, c.T)
if err != nil {
return journal.Op{}, err
}
return journal.Op{Time: ts.UTC(), Lamport: c.L, Seq: c.S, Device: c.D}, nil
}
// parseHistTime accepts RFC3339 or a bare YYYY-MM-DD (UTC). A bare date is
// the start of that day; `end` bumps it by 24h so ?until=<day> includes the
// whole day — the bound is then exclusive on both parses, which is what an
// inclusive "until this date" means once you stop thinking in instants.
func parseHistTime(s string, end bool) (time.Time, bool) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
if end {
t = t.Add(time.Nanosecond) // RFC3339 bound is inclusive to the second
}
return t.UTC(), true
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false
}
if end {
t = t.AddDate(0, 0, 1)
}
return t.UTC(), true
}
// handleHistory serves ?path=<file> (one file's versions) or
// ?prefix=<folder/> (everything underneath, "" = the whole project),
// newest first by wall-clock time, at most ?n= entries (default 100).
//
// Reader filters — ?q= (case-insensitive substring of the path), ?user=
// (exact account), ?since=/?until= (UTC bounds, inclusive at both ends) —
// compose with each other and with path/prefix. They are applied in the same
// walk as path/prefix, i.e. BEFORE the sort and the cursor skip, so
// next_cursor keeps meaning "the next matching entry" and paging under a
// filter needs no new machinery.
//
// Paging: the response carries next_cursor when more entries exist, and
// ?cursor= resumes just past the entry it was minted from — so history older
// than one page is reachable, and the UI can tell whether it is hiding
// anything. ?n= alone returns exactly the entries it always did.
//
// A cursor is a position in an ordering, not a snapshot: an offline device
// that pushes mid-scroll lands ops in the middle of the feed by timestamp,
// and the reader sees them on refresh rather than mid-page. Deliberate —
// pinning the feed to a read time means server-side state for the life of a
// scroll.
func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
q := r.URL.Query()
path, prefix := q.Get("path"), q.Get("prefix")
if path != "" && q.Has("prefix") {
http.Error(w, "use ?path= or ?prefix=, not both", http.StatusBadRequest)
return
}
n := 100
if raw := q.Get("n"); raw != "" {
var err error
if n, err = strconv.Atoi(raw); err != nil || n < 1 {
http.Error(w, "invalid n", http.StatusBadRequest)
return
}
}
needle := strings.ToLower(q.Get("q")) // lowered once, not per op
user := q.Get("user")
var since, until time.Time
// since > until is not an error: it means "nothing", which is what it returns.
for _, b := range []struct {
name string
end bool
into *time.Time
}{{"since", false, &since}, {"until", true, &until}} {
raw := q.Get(b.name)
if raw == "" {
continue
}
t, ok := parseHistTime(raw, b.end)
if !ok {
http.Error(w, "invalid "+b.name, http.StatusBadRequest)
return
}
*b.into = t
}
all, err := rs.loadSourcedOps(r.Context())
if err != nil {
storageErr(w, http.StatusBadGateway, "history is temporarily unavailable", err)
return
}
sort.SliceStable(all, func(i, j int) bool { return journal.Less(all[i].Op, all[j].Op) })
// A put is an "add" when the path didn't exist just before it (first
// version, or first after a delete), an "edit" otherwise. Existence is
// replayed over ALL ops in journal order, before any path/prefix filter,
// so a filtered view classifies the same as the full feed.
kinds := make([]string, len(all))
exists := make(map[string]bool, len(all))
for i, so := range all {
op := so.Op
switch {
case op.Kind == journal.KindDelete:
kinds[i] = "delete"
exists[op.Path] = false
case exists[op.Path]:
kinds[i] = "edit"
default:
kinds[i] = "add"
exists[op.Path] = true
}
}
type timed struct {
entry HistoryEntry
op journal.Op
}
visible := s.deviceVisibleIn(projectID(r))
// A file that moved keeps its past — under its old path. ?path= resolves
// through the move chain so the feed for docs/a.md includes the versions
// written while it was a.md. Each hop is time-bounded (see segment), so
// an unrelated NEW a.md created after the move does not leak in. `all`
// is already sorted by journal.Less, so this costs no extra I/O.
var chain []segment
if path != "" {
ops := make([]journal.Op, len(all))
for i, sop := range all {
ops[i] = sop.Op
}
chain = chainSegments(buildMoveIndex(ops), path)
}
matched := make([]timed, 0, len(all))
for i, sop := range all {
op := sop.Op
switch {
case path != "" && !inSegments(chain, op.Path, op.Time):
continue
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
case needle != "" && !strings.Contains(strings.ToLower(op.Path), needle):
continue
case user != "" && op.User != user:
continue
case !since.IsZero() && op.Time.Before(since):
continue
case !until.IsZero() && !op.Time.Before(until):
continue
}
// Attribution comes from the journal the op was READ from, never from
// its own Device field: that field is arbitrary JSON any member with
// write access can put in their own journal, so trusting it printed
// another org's machine name into this feed and answered "does this
// device id exist on the hub?" for anyone who asked. The registry join
// is scoped the same way heat's is.
dev := historyDevice{ID: sop.From}
if op.Device == sop.From {
dev.Name = op.DeviceName // the journal's owner describing itself
}
if info, ok := s.Devices.LookupIn(sop.From, visible); ok && info.ID != "" {
dev = historyDevice{ID: info.ID, Name: info.Name, OS: info.OS}
}
matched = append(matched, timed{HistoryEntry{
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i],
Path: op.Path, Size: op.Size, Blob: op.Blob,
User: op.User, UserName: op.UserName, Author: op.Author,
Device: dev, Note: op.Note,
}, op})
}
// Truncation happens AFTER the sort: cutting during the walk above would
// pick the n highest-Lamport entries and merely display them in time order.
sort.Slice(matched, func(a, b int) bool { return histLess(matched[a].op, matched[b].op) })
if raw := q.Get("cursor"); raw != "" {
cur, err := decodeCursor(raw)
if err != nil {
http.Error(w, "invalid cursor", http.StatusBadRequest)
return
}
i := 0
for i < len(matched) && !histLess(cur, matched[i].op) { // skip to just past it
i++
}
matched = matched[i:]
}
// Exact, with no over-fetch probe: every op is already in memory, so
// "there is more" is a length check and the last page simply omits the key.
var next string
if len(matched) > n {
next = encodeCursor(matched[n-1].op)
matched = matched[:n]
}
entries := make([]HistoryEntry, len(matched))
for i, m := range matched {
entries[i] = m.entry
}
out := map[string]any{"entries": entries}
if next != "" {
out["next_cursor"] = next
}
writeJSON(w, out)
}
// handleBlob streams one exact version by content hash — view or download
// any point in a file's history.
func (s *Server) handleBlob(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
sha := r.URL.Query().Get("sha")
if !blobRe.MatchString(sha) {
http.Error(w, "invalid sha", http.StatusBadRequest)
return
}
rc, err := rs.OpenBlob(r.Context(), sha)
if err != nil {
http.Error(w, "no such version", http.StatusNotFound)
return
}
defer rc.Close()
name := r.URL.Query().Get("name")
if name != "" {
ct := contentType(name)
// Same wall and the same inert declaration as the live-file door: a
// past version is the same bytes, so the two must never differ.
w.Header().Set("Content-Type", inlineType(ct))
w.Header().Set("X-Content-Type-Options", "nosniff")
sandboxInline(w, ct)
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(name)))
}
} else {
// Same door, same stored bytes, two lines down — and it did not get the
// header the arm above did. The rule is "nosniff on every door that
// streams stored bytes", so it goes on unconditionally: a declared
// octet-stream a browser is free to sniff is not a wall.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
}
io.Copy(w, rc)
}
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "/", "-")
return strings.Map(func(r rune) rune {
if r < 32 || r == '"' {
return '-'
}
return r
}, name)
}