mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
124 lines
3.7 KiB
Go
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)
|
|
}
|