Files
beardrive/internal/webapp/history.go
Snow LeeandClaude Fable 5 2d7f2f8bfa feat: auth, move-proof projects, interactive init/login, web history
Authentication (previous phase, now landed together with its follow-ups):
- Email+password+name accounts behind an AuthProvider interface; the OSS
  server ships BuiltinAuth only (file-backed auth.json: bcrypt password
  hashes + SHA-256 token digests, plaintext never stored; server-owned
  /auth/* pages; managed deployments can swap in another provider).
- bdrive login: loopback-callback browser flow (sign-up on the page, the
  terminal finishes itself) with a device-code fallback for headless
  machines; long-lived revocable device tokens in settings.json.
- Password reset via plain SMTP (stdlib) with a log-link fallback when no
  SMTP is configured.

Move-proof projects:
- .bdrive is now a directory; config.json carries a stable mount id.
  The volume store (~/.bdrive/volumes/<mount-id>/) and registry are keyed
  by that id — never the folder path — so renames/moves are free.
- The daemon re-reads the project config each tick and exits cleanly
  (propagating nothing) when its folder vanishes; the registry self-heals
  and the next bdrive command at the new location resumes with zero
  spurious changes.

bdrive init is the front door (mnt/umnt removed; bdrive stop pauses):
- Interactive on a TTY (create new / connect existing project from the
  server's list; whole folder / shared subfolder via the include list),
  full flag bypass (--name/--project/--shared/--yes), never prompts
  without a TTY. Runs the login flow first when there is no session.
  Default server: beardrive.ai (config.DefaultServer).

Web history (revert-ready):
- Hubs now always require auth; journal ops carry the signed-in account
  (user/user_name) alongside the git/OS fallback author.
- File-backed device registry: per-device name, OS, account, and the
  public IP the server observed, joined into history at read time.
- GET /api/p/<id>/history?path=|prefix= (newest first) and
  GET /api/p/<id>/blob?sha= stream any exact version — blobs are retained
  forever, so the next phase's revert is re-putting an old blob.
- UI: History button (file versions or project feed), per-folder history
  shortcut, view/download of any past version.

Tests: auth flows (callback, device-code, reset single-use, persistence,
gating), history API + device registry, folder-move survival, registry
self-heal, ops-carry-account; docs (README/SKILL/CLAUDE) updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 13:12:49 -07:00

124 lines
3.7 KiB
Go

package webapp
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"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.
// HistoryEntry is one change as the history API reports it.
type HistoryEntry struct {
Time string `json:"time"`
Kind string `json:"kind"` // put | 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 DeviceInfo `json:"device"`
Note string `json:"note,omitempty"`
}
// handleHistory serves ?path=<file> (one file's versions) or
// ?prefix=<folder/> (everything underneath, "" = the whole project),
// newest first, at most ?n= entries (default 100).
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
}
}
all, err := rs.loadOps(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
journal.Sort(all)
entries := make([]HistoryEntry, 0, n)
for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first
op := all[i]
switch {
case path != "" && op.Path != path:
continue
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
}
dev, _ := s.Devices.Get(op.Device)
if dev.ID == "" {
dev = DeviceInfo{ID: op.Device, Name: op.DeviceName}
}
entries = append(entries, HistoryEntry{
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind,
Path: op.Path, Size: op.Size, Blob: op.Blob,
User: op.User, UserName: op.UserName, Author: op.Author,
Device: dev, Note: op.Note,
})
}
writeJSON(w, map[string]any{"entries": entries})
}
// 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.Backend.Get(r.Context(), "blobs/"+sha)
if err != nil {
http.Error(w, "no such version", http.StatusNotFound)
return
}
defer rc.Close()
name := r.URL.Query().Get("name")
if name != "" {
w.Header().Set("Content-Type", contentType(name))
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(name)))
}
} else {
w.Header().Set("Content-Type", "application/octet-stream")
}
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)
}