Files
4f04b78c71 feat(webapp): frontmatter moves to a collapsible side panel (BEA-154) (#187)
A doc's YAML frontmatter rendered as a table pinned to the top of the
reading column, so on anything with more than two or three keys the
document itself started below the fold. It is a panel beside the prose
now — a sticky rail on a wide window, a closed disclosure above the body
on anything narrower — and the reading column starts with the document.

The table was built on the server and handed to the client inside one HTML
string, so this is not a CSS change: markdown.go splits the parse
(frontmatterPairs) from the markup, /api/render gains an ordered
`frontmatter` field, and the viewer switches to RenderMarkdownPairs.

RenderMarkdown keeps its exact output — it is the public share page, and
every /s/ link ever minted serves it. shares_test now pins that, because
nothing else would have failed if a later cleanup moved shares.go onto the
pairs path.

Values cross the wire as literal text plus a `code` flag rather than
pre-escaped HTML, so the panel is ordinary React text nodes and never
touches dangerouslySetInnerHTML: "a value containing markup renders as
text" holds by construction.

The rail's breakpoint is 1400px, not the 1180px the plan named — 768 of
prose + 28 + 240 of rail needs 1036px of column, and at 1280 the reading
measure lost 110px, which is the squeeze the panel exists to avoid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:55:29 -07:00

1606 lines
62 KiB
Go

// Package webapp serves the bdrive web server: a browsable web view of
// synced files (file tree reconstructed from the journals, rendered
// markdown, downloads), browser uploads, and — in hub mode — the sync API
// that lets storage-blind client devices sync whole projects through this
// server.
//
// Two modes:
//
// - single-volume: Source is set (a DirSource for a plain folder, or a
// RemoteSource in tests); the classic viewer.
// - hub: Root + Projects are set; the server hosts many projects, each a
// volume stored under <root>/<project-id>/ in the object store, managed
// by a file-backed project registry.
//
// The client — browser or syncing device — is deliberately told nothing
// about the storage: no remote URL, bucket, or credentials ever appear in an
// API response.
package webapp
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"maps"
"mime"
"net/http"
"os"
"path"
"slices"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/secrets"
"github.com/runbear-io/beardrive/internal/templates"
)
// all:, not a bare `static` — a bare embed silently skips every file whose
// name begins with `_` or `.`, and Vite names shared chunks after the module
// they came from (`_commonjsHelpers-<hash>.js` is the first one this build
// produces). The miss is invisible at build time and total at runtime: the
// chunk 404s, the SPA fallback answers with index.html, and the browser
// refuses the whole entry over its MIME type — a blank app, not a degraded one.
//
//go:embed all:static
var staticFiles embed.FS
// Source supplies the file set and content of one volume. Implementations:
// RemoteSource (a beardrive remote) and DirSource (a plain local folder).
type Source interface {
Files(ctx context.Context) (map[string]FileInfo, error)
Open(ctx context.Context, path string, fi FileInfo) (io.ReadCloser, error)
}
// Server renders volumes as a website and, in hub mode, brokers sync for
// client devices.
type Server struct {
// Single-volume mode: serve exactly this source.
Source Source
Volume string // display only
// Hub mode (when Root is set): many projects on one storage root.
Root remote.Backend
Projects *ProjectDB
// Device identifies this server in ops it journals for browser uploads.
Device Identity
Refresh time.Duration
Upload UploadConfig
// Auth, when set, gates the whole API behind sign-in. Nil means the
// historical trusted-network behavior: no accounts, everyone welcome.
Auth AuthProvider
// Devices, when set, records what the server observes about syncing
// devices (name, OS, public IP, last activity) for history.
Devices *DeviceRegistry
// Shares, when set, enables public share links (/s/<token>).
Shares *ShareDB
// Reads, when set, aggregates read telemetry (viewer, share, and agent
// reads) for the heat API. Nil means read tracking is off.
Reads *ReadLedger
// Dir, when set, walls projects off by organization membership and owns
// every org read and write the hub performs. LocalDirectory is the
// built-in implementation; a managed deployment supplies its own so that
// orgs come from the same place identities do. Nil means single-volume
// mode: no orgs, every authenticated request passes.
Dir Directory
// Quota, when set, enforces plan limits (managed deployments). Nil
// means UnlimitedQuota: the open-source server never says no.
Quota QuotaProvider
// Billing, when set, surfaces a billing entry in the frontend's account
// menu: the billing page URL plus the signed-in user's current plan name
// (/api/config `billing`). The OSS hub has no billing; managed
// deployments plug this in. Nil — or ok=false for a user with no org —
// hides the entry. The mirror of the Quota seam: Quota enforces the
// plan, Billing displays it.
Billing func(email string) (plan, url string, ok bool)
// Analytics, when its Key is set, tells the frontend to load PostHog
// (/api/config `analytics`). The third managed-deployment seam beside
// Quota and Billing, and deliberately server-supplied rather than
// bundled: with no key the OSS frontend ships no analytics code and
// makes no third-party request, so a self-hosted hub cannot phone home
// even by accident.
Analytics AnalyticsConfig
// ShareRPM is the per-IP request rate on public share links (/s/*);
// 0 means DefaultShareRPM.
ShareRPM int
// TrustProxy honors X-Forwarded-For from ANY peer. Only needed for a
// proxy on a public address: a proxy on loopback or a private network is
// already trusted without it (see clientIP). Setting it on a directly-
// reachable hub lets any client pick its own rate-limit bucket.
TrustProxy bool
xffWarnOnce sync.Once
// unboundOnce logs, at most once, that this hub refused a journal write
// while holding no device binding at all — the signature of a provider that
// never calls the binder. See ownJournal.
unboundOnce sync.Once
shareLimOnce sync.Once
shareLim *rateLimiter
authLimOnce sync.Once
authLim *rateLimiter
volOnce sync.Once
vol *volume
volsMu sync.Mutex
vols map[string]*volume // hub mode: per-project, keyed by project id
resMu sync.Mutex
grants []grant // outstanding presigned upload reservations (reserve.go)
// joinMu serializes invite redemption: the seat check reads the member
// count and the join adds to it, and two clicks on the same link at the
// same moment would otherwise both see the last seat free.
// ponytail: one hub-wide lock; redemption is a rare, human-paced request —
// make it per-org if that ever stops being true.
joinMu sync.Mutex
}
// UploadConfig controls whether and how clients may write.
type UploadConfig struct {
Enabled bool
// TTL bounds the lifetime of presigned direct-upload URLs.
TTL time.Duration
}
// AnalyticsConfig points the frontend at a PostHog project. The key is a
// public write-only project token, not a credential — it is served to signed-
// out visitors too, because the app shell loads before login.
type AnalyticsConfig struct {
Key string // PostHog project key; empty disables analytics entirely
Host string // PostHog API host; empty means DefaultAnalyticsHost
}
// DefaultAnalyticsHost is PostHog's US cloud ingestion host.
const DefaultAnalyticsHost = "https://us.i.posthog.com"
// Endpoint is Host with the default applied. Exported because the same
// config drives more than the app shell in a managed deployment (the cloud
// module's marketing pages render their own loader from it).
func (a AnalyticsConfig) Endpoint() string {
if a.Host != "" {
return a.Host
}
return DefaultAnalyticsHost
}
// DefaultUploadTTL is used when UploadConfig.TTL is unset: long enough for a
// slow upload, short enough that a leaked URL goes stale quickly.
const DefaultUploadTTL = 15 * time.Minute
func (c UploadConfig) ttl() time.Duration {
if c.TTL > 0 {
return c.TTL
}
return DefaultUploadTTL
}
// FileInfo is the resolved state of one path: content identity (Blob doubles
// as the ETag), plus provenance where the source knows it.
type FileInfo struct {
Blob string
Size int64
Time time.Time
// User/UserName are the signed-in account behind the change; Author is
// the git/OS identity an offline device falls back to. History renders
// the account and falls back to Author, so the viewer needs all three
// to give the same answer — see whoChanged() in the frontend.
User string
UserName string
Author string
Device string
}
// volume is one browsable/syncable file set: a source plus its snapshot
// cache. File listings are cached for refresh between fetches; if the source
// becomes unreachable, the last good snapshot keeps being served.
type volume struct {
source Source
refresh time.Duration
mu sync.Mutex
snap *snapshot
at time.Time
}
type snapshot struct {
files map[string]FileInfo
// moves is the derived rename index (see moves.go), cached with the
// listing it was replayed alongside. Nil for a source that has no
// journals to derive it from — every resolver then answers "not found",
// so the DirSource exclusion falls out instead of needing a rule.
moves moveIndex
}
// MoveSource is a Source that can also report where its files came from.
// Optional, like Uploader: implementing it keeps the replay ONE pass, so the
// move index costs no extra journal read.
type MoveSource interface {
FilesWithMoves(context.Context) (map[string]FileInfo, moveIndex, error)
}
func (v *volume) snapshot(ctx context.Context) (*snapshot, error) {
v.mu.Lock()
defer v.mu.Unlock()
if v.snap != nil && time.Since(v.at) < v.refresh {
return v.snap, nil
}
var (
files map[string]FileInfo
moves moveIndex
err error
)
if ms, ok := v.source.(MoveSource); ok {
files, moves, err = ms.FilesWithMoves(ctx)
} else {
files, err = v.source.Files(ctx)
}
if err != nil {
if v.snap != nil {
return v.snap, nil // serve stale rather than fail
}
return nil, err
}
v.snap, v.at = &snapshot{files: files, moves: moves}, time.Now()
return v.snap, nil
}
// invalidate forces the next snapshot to refetch, so an upload shows up in
// the tree immediately instead of after refresh.
func (v *volume) invalidate() {
v.mu.Lock()
v.at = time.Time{}
v.mu.Unlock()
}
func (v *volume) uploader() Uploader {
u, _ := v.source.(Uploader)
return u
}
// single returns the single-volume mode volume.
func (s *Server) single() *volume {
s.volOnce.Do(func() {
s.vol = &volume{source: s.Source, refresh: s.Refresh}
})
return s.vol
}
// projectVolume resolves a project id to its record and its volume, creating
// the (cached) source over the project's storage prefix on first use. It
// returns the Project so the permission check does not have to resolve the id
// a second time — see projectPermOf.
func (s *Server) projectVolume(id string) (Project, *volume, error) {
if s.Root == nil || s.Projects == nil {
return Project{}, nil, fmt.Errorf("this server does not host projects")
}
if !projectIDRe.MatchString(id) {
return Project{}, nil, fmt.Errorf("invalid project id %q", id)
}
p, ok := s.Projects.Get(id)
if !ok {
return Project{}, nil, fmt.Errorf("no such project %q", id)
}
s.volsMu.Lock()
defer s.volsMu.Unlock()
if s.vols == nil {
s.vols = make(map[string]*volume)
}
v, ok := s.vols[id]
if !ok {
v = &volume{
source: &RemoteSource{
Backend: remote.Prefixed(s.Root, id), Device: s.Device,
// The real TTL the presign doors hand out, so verify seals a
// blob no earlier than the last URL for it can expire.
PresignTTL: s.Upload.ttl(),
},
refresh: s.Refresh,
}
s.vols[id] = v
}
return p, v, nil
}
// RemoteSource reads a beardrive remote: it fetches every journal and folds the
// ops into the current volume state (same total order as journal.Replay,
// but keeping author/device/time of the winning op per path). With Device set
// it also accepts uploads, journaled under that identity.
type RemoteSource struct {
Backend remote.Backend
// Device identifies this server in ops it journals for uploads. Required
// for uploads; irrelevant for reading.
Device Identity
// PresignTTL is the lifetime the hub gives a presigned upload URL. It is
// how long a blob stays writable by anyone but the hub, and therefore when
// verify may stop re-hashing it. Zero means DefaultUploadTTL — set it to
// the server's real UploadConfig.ttl(), or a longer configured TTL would
// seal an object that can still change.
PresignTTL time.Duration
upmu sync.Mutex // serializes read-modify-write of our own journal
// sealed holds the blobs this process has verified AND proved immutable.
// See verify.
sealed sync.Map // sha (string) → struct{}
jmu sync.Mutex // guards jcache and jbytes
jcache map[string]cachedJournal // "journal/<dev>.jsonl" → parsed ops
jbytes int64 // raw journal bytes currently cached
}
// cachedJournal is one journal's parsed ops plus the (size, modified) that
// proves the parse is still current. See loadSourcedOps.
type cachedJournal struct {
size int64
mod time.Time
bytes int64 // raw bytes this entry stands for, for the ceiling
ops []journal.Op
}
// journalFetchConcurrency bounds the parallel journal fetches on a cold load.
// A cache can never help a first request, and that loop is one serial round
// trip to S3 per device that has ever synced the project.
const journalFetchConcurrency = 8
// ponytail: a per-project raw-byte cap with all-or-nothing eviction, so the
// real ceiling is maxCachedJournalBytes x the number of projects a hub has
// touched since start — s.vols never evicts. Closing it properly means one
// budget shared across RemoteSources with LRU, or reading only the appended
// tail of each journal, which makes the cache small in the first place.
const maxCachedJournalBytes = 64 << 20
// OpenBlob is the one way a blob's bytes leave the hub, and on a hub whose
// storage can presign it is also the only place left that can tell a content
// address the truth. handleStorePut hashes what it relays — but a presigned
// PUT writes straight into the object store, so those bytes were never
// examined by anything: any device with write permission could store arbitrary
// content under a sha256 it chose, and the viewer, share links, history and
// every peer would then serve it.
//
// Skipped entirely on a backend that cannot presign, where the write path
// already checked. It used to be once per blob per process, on the premise
// that blobs are immutable — which is false on the hub that needs the check:
// SignPut hands out a URL that stays valid for its whole TTL and an object
// store accepts every PUT to it, not the first. So uploading the honest bytes,
// letting one reader populate the cache, and then replaying the same URL with
// hostile bytes served them under the reviewed sha to the viewer, history,
// share links and every syncing device.
//
// Verifying on EVERY read closed that, and cost every S3/GCS hub 2x object-
// store egress and a serialized full-object hash before the reader's first
// byte — on every viewer open, render, download and /s/* hit. The cache is
// back, keyed on the one thing that makes the premise TRUE rather than assumed:
// see verify.
func (r *RemoteSource) OpenBlob(ctx context.Context, sha string) (io.ReadCloser, error) {
if !blobRe.MatchString(sha) {
return nil, fmt.Errorf("invalid content reference")
}
if err := r.verify(ctx, sha); err == nil {
if rc, err := r.Backend.Get(ctx, "blobs/"+sha); err == nil {
return rc, nil
}
}
// No whole blob (or one that fails verification): the content may exist
// only as chunks + a manifest (delta sync, docs/delta-sync-prd.md).
// Reassembly is hash-verified against the sha requested, so this arm is
// exactly as strong as verify — including when blobs/<sha> holds bytes
// that do NOT hash to sha, where the backfill below heals it.
return r.reassemble(ctx, sha)
}
// maxReassembleBytes caps what one reassembly may spool. It mirrors `bdrive
// import`'s maxImportBlob (256 << 20): comfortably above the device-side
// materialization ceiling (syncer's maxPullBytes, 100 MiB), so nothing a
// device can hold is refused here, while a hostile manifest cannot spool
// gigabytes through the temp dir per read. The spool lands in os.TempDir —
// on a tmpfs deployment that is RAM, so this constant is also the per-request
// memory bound; set TMPDIR to disk if that matters.
const maxReassembleBytes = 256 << 20
// reassemble serves a blob that exists only as chunks: fetch the manifest
// keyed by the file's sha, concatenate its chunks into a spool while hashing,
// refuse the lot unless the result hashes to the sha requested, then backfill
// blobs/<sha> so the next read is a plain Get. The spool is unavoidable — the
// promise "bytes hash to the key" cannot be made about a stream whose response
// has already started — and self-limiting: upgraded clients fetch chunks and
// verify locally, so only legacy readers ever pay it, once per blob.
func (r *RemoteSource) reassemble(ctx context.Context, sha string) (io.ReadCloser, error) {
mrc, err := r.Backend.Get(ctx, "manifests/"+sha)
if err != nil {
return nil, err // no manifest either: the object genuinely is not there
}
var man struct {
Chunks []struct {
H string `json:"h"`
N int64 `json:"n"`
} `json:"chunks"`
}
derr := json.NewDecoder(io.LimitReader(mrc, 8<<20)).Decode(&man)
mrc.Close()
if derr != nil {
return nil, fmt.Errorf("unreadable manifest for %s: %w", sha, derr)
}
// A manifest is member-written and cannot be hash-checked at ingest, so
// its declared total is the one number a hostile member controls: listing
// one real 4 MiB chunk 100k times would spool ~400 GB through the temp
// dir (RAM, on a tmpfs deployment) per legacy read. The sum is enforced
// here and the per-chunk copy below verifies declared == actual, so the
// declared total IS the spool bound. An honest manifest past the cap is
// refused the same way — the hub will not assemble what a device would
// not accept either (the client's own ceiling is maxPullBytes).
var declared int64
for _, c := range man.Chunks {
if c.N < 0 || declared > maxReassembleBytes-c.N {
return nil, fmt.Errorf("manifest for %s declares more than %d bytes", sha, int64(maxReassembleBytes))
}
declared += c.N
}
tmp, err := os.CreateTemp("", ".bdrive-reassemble-*")
if err != nil {
return nil, err
}
os.Remove(tmp.Name()) // serve from the fd; nothing to clean up on any path
h := sha256.New()
w := io.MultiWriter(tmp, h)
for _, c := range man.Chunks {
if !blobRe.MatchString(c.H) {
tmp.Close()
return nil, fmt.Errorf("manifest for %s names an invalid chunk", sha)
}
crc, err := r.Backend.Get(ctx, "chunks/"+c.H)
if err != nil {
tmp.Close()
return nil, err
}
// Bounded by the DECLARED size, which the cap above already summed —
// a stored object longer than its manifest entry (a replayed presign)
// must not turn the cap into fiction. Short or long, the mismatch
// fails here; equal-but-wrong bytes fail the whole-file hash below.
n, cerr := io.Copy(w, io.LimitReader(crc, c.N+1))
crc.Close()
if cerr != nil {
tmp.Close()
return nil, cerr
}
if n != c.N {
tmp.Close()
return nil, fmt.Errorf("chunk %s of %s is not its declared size", c.H[:12], sha)
}
}
if hex.EncodeToString(h.Sum(nil)) != sha {
tmp.Close()
return nil, fmt.Errorf("manifest for %s does not reassemble to its key", sha)
}
size, err := tmp.Seek(0, io.SeekEnd)
if err == nil {
_, err = tmp.Seek(0, io.SeekStart)
}
if err != nil {
tmp.Close()
return nil, err
}
// Backfill so the next read is a plain blobs/ Get. Best-effort: a failed
// write must never fail the read that triggered it. The object is fresh,
// so verify will re-hash it until it seals — same as any new upload.
if err := r.Backend.Put(ctx, "blobs/"+sha, tmp, size); err != nil {
log.Printf("beardrive: backfill of reassembled blob %s failed: %v", sha, err)
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
tmp.Close()
return nil, err
}
return tmp, nil
}
// verify re-hashes a stored blob, unless this process has already proved that
// nobody but the hub can write it any more.
//
// The proof is the presign TTL and the fact that BOTH presign doors —
// handleStoreSign and handleUploadInit — refuse to sign a key that already
// exists. So every presigned URL a blob ever gets was minted BEFORE its first
// PUT, and expires at mint+TTL, which is earlier than firstPUT+TTL. Once the
// stored object is older than the TTL, no live URL for it can exist and none
// will ever be minted again: the hub is the only writer left, and the hub
// hashes what it relays. That is when the object really is immutable, and only
// then is the verification cached — for the life of the process, keyed on the
// sha, no expiry needed.
//
// The age is read AFTER the hash on purpose. A replay lands a NEW object with
// a new modification time, so an object that was rewritten mid-check reads as
// seconds old and is not sealed.
//
// Two premises this rests on, both true today and both worth breaking loudly:
// blobs are never deleted (remote.Backend has no delete at all — history keeps
// every version forever), and PresignTTL is the real TTL the doors use. A
// backend that does not report Modified never seals, which is the safe answer.
//
// ponytail: per-process, so the first read of each blob after a restart still
// pays the full hash. Persisting it needs somewhere to record "the hub has
// seen these bytes", which is a metadata-store change for a cost paid once.
func (r *RemoteSource) verify(ctx context.Context, sha string) error {
if _, canSign := r.Backend.(remote.PutSigner); !canSign {
return nil
}
if _, ok := r.sealed.Load(sha); ok {
return nil
}
rc, err := r.Backend.Get(ctx, "blobs/"+sha)
if err != nil {
return err
}
h := sha256.New()
_, err = io.Copy(h, rc)
rc.Close()
if err != nil {
return err
}
if hex.EncodeToString(h.Sum(nil)) != sha {
return fmt.Errorf("stored content does not hash to its key")
}
// sealAfter, not presignTTL: o.Modified is the STORAGE service's clock and
// time.Since is the hub's. A hub whose clock runs ahead of storage would
// otherwise overstate the object's age and seal it while a minted URL is
// still live — after which a replay is served from cache for the life of
// the process.
if o, ok, err := r.blobStat(ctx, sha); err == nil && ok &&
!o.Modified.IsZero() && time.Since(o.Modified) > r.sealAfter() {
r.sealed.Store(sha, struct{}{})
}
return nil
}
// sealAfter is how old a stored blob must be before its verification may be
// cached: the presign TTL plus an allowance for clock skew between the hub and
// the object store. The allowance is the whole point — the correctness
// argument is "no live URL can exist any more", and that is a claim about time
// measured on two machines the hub cannot reconcile.
//
// Waiting longer costs only a few extra hashes on a blob younger than this,
// which is the behavior the check had for every blob anyway.
//
// ponytail: a fixed allowance, so it is a bound and not a proof — a hub whose
// clock runs more than an hour ahead of its object store can still seal early.
// Closing it properly means measuring the age on ONE clock (record the hub time
// of the first verification, seal on a later one that finds Modified
// unchanged), which costs a second map and never seals on a first read.
func (r *RemoteSource) sealAfter() time.Duration {
const skewAllowance = time.Hour
return r.presignTTL() + skewAllowance
}
// Identity is the device identity uploads are journaled under.
type Identity struct {
ID, Name, Author string
}
// loadOps fetches and parses every journal on the remote.
func (r *RemoteSource) loadOps(ctx context.Context) ([]journal.Op, error) {
sourced, err := r.loadSourcedOps(ctx)
if err != nil {
return nil, err
}
all := make([]journal.Op, len(sourced))
for i, s := range sourced {
all[i] = s.Op
}
return all, nil
}
// sourcedOp is an op plus the device whose journal it was actually read from.
// Everything inside an op — including its Device field — is JSON the pusher
// chose; the journal KEY is the one part the hub binds to the pushing device
// (store.go's ownJournal), so From is the only trustworthy attribution.
type sourcedOp struct {
Op journal.Op
From string
}
// loadSourcedOps folds every journal on the remote into one slice of ops. It
// is the funnel every reader goes through — history, folder listings, restore,
// and the hub's own appendOp — so re-downloading and re-parsing every journal
// here was the whole cost of a history page, paid again per "load more".
//
// Journals only ever GROW: a device appends only to its own (the one-writer
// invariant) and the hub's appendOp rewrites its key with strictly more bytes.
// Nothing shrinks or rewrites one in place, so the (Size, Modified) that List
// already reports proves a parse we still hold is current — no staleness
// window, no time-based expiry, and no new Backend method. The List is the one
// round trip every request still pays.
//
// No singleflight: two requests that miss the same journal at the same moment
// both fetch it, which is what every request did before this existed.
func (r *RemoteSource) loadSourcedOps(ctx context.Context) ([]sourcedOp, error) {
objs, err := r.Backend.List(ctx, "journal/")
if err != nil {
return nil, fmt.Errorf("list journals: %w", err)
}
keep := make([]remote.Object, 0, len(objs))
for _, o := range objs {
if strings.HasSuffix(o.Key, ".jsonl") {
keep = append(keep, o)
}
}
parsed := make([][]journal.Op, len(keep))
var misses []int
r.jmu.Lock()
// Pruning here rather than after the fetch: a journal that VANISHED is a
// pass with no misses at all, so a prune on the store path would never see
// it and the entry would stay resident for the life of the process.
live := make(map[string]bool, len(keep))
for i, o := range keep {
live[o.Key] = true
if c, ok := r.jcache[o.Key]; ok && c.size == o.Size && c.mod.Equal(o.Modified) {
parsed[i] = c.ops
continue
}
misses = append(misses, i)
}
for k, c := range r.jcache {
if !live[k] {
r.jbytes -= c.bytes
delete(r.jcache, k)
}
}
r.jmu.Unlock()
if len(misses) > 0 {
sizes := make([]int64, len(keep))
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(journalFetchConcurrency)
for _, i := range misses {
g.Go(func() error {
rc, err := r.Backend.Get(gctx, keep[i].Key)
if err != nil {
return fmt.Errorf("fetch %s: %w", keep[i].Key, err)
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return err
}
sizes[i] = int64(len(data))
// A corrupt journal is ignored rather than breaking the view —
// and cached as zero ops, or it is re-downloaded on every
// request for as long as it stays corrupt.
if ops, err := journal.Parse(data); err == nil {
parsed[i] = ops
}
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
r.cacheJournals(keep, misses, parsed, sizes)
}
// A fresh outer slice on every call: handleHistory and Files both sort what
// they are handed, and the cached ops must never be the thing they reorder.
// journal.Op is all value fields, so appending one copies it.
var all []sourcedOp
for i, o := range keep {
from := strings.TrimSuffix(strings.TrimPrefix(o.Key, "journal/"), ".jsonl")
for _, op := range parsed[i] {
all = append(all, sourcedOp{Op: op, From: from})
}
}
return all, nil
}
// cacheJournals records what the fetch pass parsed, and enforces the byte
// ceiling by dropping everything — a cold pass costs what every pass cost
// before this cache existed, so the fallback is today's behavior, not an error.
func (r *RemoteSource) cacheJournals(keep []remote.Object, misses []int, parsed [][]journal.Op, sizes []int64) {
r.jmu.Lock()
defer r.jmu.Unlock()
if r.jcache == nil {
r.jcache = make(map[string]cachedJournal, len(keep))
}
for _, i := range misses {
if c, ok := r.jcache[keep[i].Key]; ok {
r.jbytes -= c.bytes
}
r.jcache[keep[i].Key] = cachedJournal{
size: keep[i].Size, mod: keep[i].Modified, bytes: sizes[i], ops: parsed[i],
}
r.jbytes += sizes[i]
}
if r.jbytes > maxCachedJournalBytes {
r.jcache, r.jbytes = nil, 0
}
}
func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error) {
files, _, err := r.FilesWithMoves(ctx)
return files, err
}
// FilesWithMoves is the replay, plus the rename index derived from the same
// sorted ops — one pass, so the index rides in the cached snapshot.
func (r *RemoteSource) FilesWithMoves(ctx context.Context) (map[string]FileInfo, moveIndex, error) {
all, err := r.loadOps(ctx)
if err != nil {
return nil, nil, err
}
journal.Sort(all)
files := make(map[string]FileInfo)
for _, op := range all {
switch op.Kind {
case journal.KindPut:
// A journal is arbitrary JSONL a device pushed: Blob is a storage
// key suffix ("blobs/"+Blob), not a checked field, so anything but
// a bare sha256 is a path the writer chose — another project's
// prefix, or out of the storage root entirely. Same rule as every
// ?sha= route. An op that fails it is ignored (the path keeps its
// previous version) rather than treated as a delete.
if !blobRe.MatchString(op.Blob) {
continue
}
files[op.Path] = FileInfo{
Blob: op.Blob, Size: op.Size, Time: op.Time,
User: op.User, UserName: op.UserName,
Author: op.Author, Device: op.DeviceName,
}
case journal.KindDelete:
delete(files, op.Path)
}
}
return files, buildMoveIndex(all), nil
}
func (r *RemoteSource) Open(ctx context.Context, _ string, fi FileInfo) (io.ReadCloser, error) {
// Files already drops ops with a bogus Blob; re-checked in OpenBlob because
// that is where the key is built, and a FileInfo can reach it from anywhere.
return r.OpenBlob(ctx, fi.Blob)
}
// Handler returns the HTTP handler: /api/* plus the embedded frontend.
func (s *Server) Handler() http.Handler {
static, err := fs.Sub(staticFiles, "static")
if err != nil {
panic(err) // embedded FS; cannot fail at runtime
}
mux := http.NewServeMux()
// One account-removal path, one cleanup. Everything downstream of it is
// keyed by email, so removal has to take the org role, the project grants
// and (through membership) the share links with it.
if a, ok := s.Auth.(*BuiltinAuth); ok && a.Offboard == nil {
a.Offboard = s.offboard
}
// A device identity is bound to an account when its token is minted, and
// nowhere else. Wired here rather than at startup because the fixtures (and
// a hub rebuilt from its repos) assemble Auth and Devices independently.
//
// EVERY provider, not `if a, ok := s.Auth.(*BuiltinAuth); ok`. ownJournal
// refuses a journal write for any provider, and the type assertion meant a
// hub running a managed provider enforced a gate nothing could ever satisfy:
// no device bound, every push 403 forever, while login and permissions read
// perfectly healthy. See AuthProvider.UseDeviceBinder.
if s.Auth != nil {
s.Auth.UseDeviceBinder(s.bindDevice)
}
// Volume resolution per route family: fixed single volume, or by
// project id in hub mode. One handler implementation serves both.
// Single-volume mode has no per-project permissions, so it ignores the
// declared level; hub mode enforces it.
single := func(_ string, h func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.Source == nil {
http.Error(w, "this server hosts projects; use /api/p/<project-id>/...", http.StatusNotFound)
return
}
h(s.single(), w, r)
}
}
proj := func(level string, h func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
p, v, err := s.projectVolume(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// p, not id: the resolver has already read the registry once for
// this request and re-reading it is the hub's per-request cost.
if !s.requirePermOn(w, r, p, level) {
return
}
// Read recording (and anything else downstream) finds the project
// id in the context; permission has already passed at this point.
h(v, w, withProjectID(r, id))
}
}
mux.HandleFunc("GET /api/config", s.handleConfig)
mux.HandleFunc("GET /api/projects", s.handleProjectList)
mux.HandleFunc("POST /api/projects", s.handleProjectCreate)
mux.HandleFunc("GET /api/projects/{project}", s.handleProjectGet)
for prefix, resolve := range map[string]func(string, func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc{
"/api/": single,
"/api/p/{project}/": proj,
} {
mux.HandleFunc("GET "+prefix+"tree", resolve(PermRead, s.handleTree))
mux.HandleFunc("GET "+prefix+"resolve", resolve(PermRead, s.handleResolve))
mux.HandleFunc("GET "+prefix+"file", resolve(PermRead, s.handleFile))
mux.HandleFunc("GET "+prefix+"download", resolve(PermRead, s.handleDownload))
mux.HandleFunc("GET "+prefix+"render", resolve(PermRead, s.handleRender))
mux.HandleFunc("POST "+prefix+"upload/init", resolve(PermWrite, s.handleUploadInit))
mux.HandleFunc("PUT "+prefix+"upload/content", resolve(PermWrite, s.handleUploadContent))
mux.HandleFunc("POST "+prefix+"upload/commit", resolve(PermWrite, s.handleUploadCommit))
}
mux.HandleFunc("GET /api/orgs", s.handleOrgList)
mux.HandleFunc("PATCH /api/orgs/{org}", s.handleOrgRename)
mux.HandleFunc("POST /api/orgs/{org}/invites", s.handleInviteCreate)
mux.HandleFunc("GET /api/orgs/{org}/invites", s.handleInviteList)
mux.HandleFunc("DELETE /api/orgs/{org}/invites/{token}", s.handleInviteRevoke)
mux.HandleFunc("PATCH /api/orgs/{org}/members/{email}", s.handleMemberUpdate)
mux.HandleFunc("DELETE /api/orgs/{org}/members/{email}", s.handleMemberRemove)
mux.HandleFunc("GET /api/orgs/{org}/shares", s.handleOrgShares)
mux.HandleFunc("POST /api/invites/{token}", s.handleInviteAccept)
mux.HandleFunc("PATCH /api/projects/{project}", s.handleProjectUpdate)
mux.HandleFunc("DELETE /api/projects/{project}", s.handleProjectDelete)
mux.HandleFunc("GET /api/admin/policy", s.handleAdminPolicy)
mux.HandleFunc("POST /api/admin/policy", s.handleAdminPolicy)
mux.HandleFunc("GET /api/admin/pending", s.handleAdminPending)
mux.HandleFunc("POST /api/admin/pending/{id}/approve", s.handleAdminApprove)
mux.HandleFunc("POST /api/admin/pending/{id}/deny", s.handleAdminDeny)
mux.HandleFunc("GET /api/p/{project}/history", proj(PermRead, s.handleHistory))
mux.HandleFunc("GET /api/p/{project}/blob", proj(PermRead, s.handleBlob))
// Restore needs a journal to look the version up in, so it exists only
// per project — never on the single-volume (DirSource) prefix. Remove
// writes to that same journal, so it lives here too.
mux.HandleFunc("POST /api/p/{project}/restore", proj(PermWrite, s.handleRestore))
mux.HandleFunc("POST /api/p/{project}/remove", proj(PermWrite, s.handleRemove))
// The run-wide form of the two above: one journal write that puts every
// path an agent run touched back where it was.
mux.HandleFunc("POST /api/p/{project}/undo-run", proj(PermWrite, s.handleUndoRun))
mux.HandleFunc("GET /api/p/{project}/heat", proj(PermRead, s.handleHeat))
mux.HandleFunc("POST /api/p/{project}/reads", proj(PermRead, s.handleReadReport))
mux.HandleFunc("POST /api/p/{project}/shares", proj(PermWrite, s.handleShareCreate))
mux.HandleFunc("GET /api/p/{project}/shares", proj(PermRead, s.handleShareList))
mux.HandleFunc("PATCH /api/shares/{token}", s.handleShareExpiry)
mux.HandleFunc("DELETE /api/shares/{token}", s.handleShareRevoke)
mux.HandleFunc("GET /s/{token}", s.handleShared)
mux.HandleFunc("GET /api/p/{project}/permissions", s.handleProjectPerms)
mux.HandleFunc("PUT /api/p/{project}/permissions", s.handleProjectPermDefault)
mux.HandleFunc("PUT /api/p/{project}/permissions/{email}", s.handleProjectPermSet)
mux.HandleFunc("DELETE /api/p/{project}/permissions/{email}", s.handleProjectPermClear)
// The sync (store) API only exists per project: hub mode is what
// storage-blind devices sync through. Reading the store is how a
// pull-only (read) device stays current; writing needs write.
mux.HandleFunc("GET /api/p/{project}/store/list", proj(PermRead, s.handleStoreList))
mux.HandleFunc("GET /api/p/{project}/store/object", proj(PermRead, s.handleStoreGet))
mux.HandleFunc("GET /api/p/{project}/store/exists", proj(PermRead, s.handleStoreExists))
mux.HandleFunc("POST /api/p/{project}/store/sign", proj(PermWrite, s.handleStoreSign))
mux.HandleFunc("PUT /api/p/{project}/store/object", proj(PermWrite, s.handleStorePut))
mux.Handle("GET /", s.frontend(static))
if s.Auth != nil {
s.Auth.Register(mux)
}
return s.rateLimitAuth(s.authGate(mux))
}
// frontend serves the embedded single-page app. Real asset files (app.js,
// style.css) are served directly; every other GET that isn't an API, auth,
// or share route — or, on a hub, a root-level path shaped like a file —
// returns index.html, so client-side routes like /<project-id>/<path> and
// /join/<token> survive a deep link or refresh.
func (s *Server) frontend(static fs.FS) http.HandlerFunc {
files := http.FileServerFS(static)
index, _ := fs.ReadFile(static, "index.html")
return func(w http.ResponseWriter, r *http.Request) {
upath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// This document carries the session cookie and drives share creation,
// permission edits and project deletion, so it must not be framed by
// another origin or MIME-sniffed. /s/* sets its own sandbox CSP and
// never reaches here.
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
// Vite emits content-hashed filenames under assets/, safe to cache
// forever. Everything else (index.html above all) must revalidate:
// embedded files carry no modtime, so without no-cache browsers
// cache heuristically and users see a stale frontend after upgrades.
// Set on the real asset only, below: deciding on the URL prefix meant
// a MISS under assets/ answered the app shell marked immutable for a
// year, so a shared cache pinned index.html at an asset URL forever.
w.Header().Set("Cache-Control", "no-cache")
// Reserved prefixes that fell through to the catch-all are genuine
// 404s — don't mask a mistyped API/auth/share URL with the app shell.
if strings.HasPrefix(upath, "api/") || strings.HasPrefix(upath, "auth/") || strings.HasPrefix(upath, "s/") {
http.NotFound(w, r)
return
}
// A hub whose organizations live elsewhere has no org page to show:
// send the browser where they are actually administered rather than
// painting a console whose every control would 409. The account menu
// already links to the same place; this covers bookmarks, history, and
// hand-typed URLs, which are the paths a link cannot reach.
if id, ok := strings.CutPrefix(upath, "orgs/"); ok && s.Dir != nil {
if u := s.Dir.ManageURL(id); !strings.HasPrefix(u, "/") {
http.Redirect(w, r, u, http.StatusFound)
return
}
}
if upath != "" && upath != "index.html" {
if f, err := static.Open(upath); err == nil {
fi, statErr := f.Stat()
f.Close()
if statErr == nil && !fi.IsDir() {
if strings.HasPrefix(upath, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
// The /s/ share page runs under `sandbox allow-scripts`, so
// its origin is opaque: a module script and every import()
// it makes are fetched with `Origin: null` and blocked
// without this. That is how share-mermaid.js and mermaid's
// chunks reach a share page at all.
//
// On the real asset ONLY, never on the index.html fallback
// below: these files are already public, unauthenticated
// and cookie-less, and `*` forbids credentialed requests by
// definition, so this grants a cross-origin reader nothing
// it could not already fetch in no-cors mode.
w.Header().Set("Access-Control-Allow-Origin", "*")
files.ServeHTTP(w, r) // a real asset
return
}
}
}
// No embedded asset matched, so a root-level dotted path is a request
// for a file that does not exist, not a client route: in hub mode the
// first segment is a project id (projectIDRe: UUID or p-xxxxxxxx) or a
// reserved word (orgs/, billing/, join/), none of which contain a dot.
// Answering the shell made /llms.txt, /robots.txt and every mistyped
// root file look like they exist — a soft 200 of login HTML to any
// crawler probing a conventional path. Deeper dots (/<id>/notes/a.md)
// are real client routes and untouched. index.html is excluded because
// the asset block above skips it deliberately and it must keep
// answering the shell.
if s.Root != nil && upath != "index.html" &&
!strings.Contains(upath, "/") && strings.Contains(upath, ".") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(index)
}
}
// handleConfig tells the client how this server is configured. Deliberately
// nothing about the storage backend.
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
mode := "volume"
if s.Root != nil {
mode = "hub"
}
auth := map[string]any{"enabled": s.Auth != nil}
if s.Auth != nil {
auth["cli_login"] = s.Auth.CLILoginPath()
}
// Tell the frontend whether self-signup is offered and whether the
// signed-in user is a hub admin, so it can hide the "Sign up" link and
// show the admin surfaces. Never leak more than these booleans.
me := s.requestUser(r)
brand := ""
if a, ok := s.Auth.(AccountApprover); ok {
// Only a hub that owns its accounts can offer self-signup or an admin
// queue; one whose identities come from elsewhere offers neither.
auth["allow_signup"] = a.Policy().AllowSignup
auth["admin"] = me.Admin
}
if b, ok := s.Auth.(Brander); ok {
brand = b.Branding()
}
// No fallback: the volume is a storage basename, not a brand. An
// unconfigured brand stays empty and each app picks its own default
// (hub: "BearDrive", volume mode: the folder name).
out := map[string]any{
"mode": mode,
"volume": s.Volume,
"brand": brand,
"upload": map[string]any{
"enabled": s.Upload.Enabled,
},
"auth": auth,
"reads": map[string]any{"enabled": s.Reads != nil},
// The starting structures the create dialog offers. Served rather
// than hardcoded in the frontend so a hub that ships another one
// needs no frontend change.
"templates": templates.List(),
}
// Outside a managed deployment this block is absent and the frontend
// never loads a tracker. Outside the `me` check on purpose: a hub with
// auth off has no signed-in user and should still be measurable.
// Note the funnel gap this leaves — /auth/* is server-rendered HTML
// (authlocal.go authPage) with no analytics, so a visitor is counted on
// the marketing page and again once the app boots, but the signup page
// itself reports nothing. Same origin means the anonymous id survives
// the round trip, so attribution holds; only signup-page drop-off is
// invisible. Wire authPage up if that becomes the question.
if s.Analytics.Key != "" {
out["analytics"] = map[string]string{"key": s.Analytics.Key, "host": s.Analytics.Endpoint()}
}
if me.Email != "" {
out["me"] = map[string]string{"email": me.Email, "name": me.Name}
if s.Billing != nil {
if plan, url, ok := s.Billing(me.Email); ok {
out["billing"] = map[string]string{"plan": plan, "url": url}
}
}
}
writeJSON(w, out)
}
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
if s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
// Each row carries the caller's own level, so the frontend can hide write
// affordances without a second fetch per project on every render.
visible := []projectView{}
for _, p := range s.Projects.List() {
perm := s.projectPermOf(r, p)
if !atLeast(perm, PermRead) {
continue
}
visible = append(visible, projectJSON(p, perm))
}
writeJSON(w, map[string]any{"projects": visible})
}
// projectJSON renders a project for the API with the caller's effective level.
// projectView is a Project plus the caller's own effective level on it.
// It embeds rather than re-listing fields on purpose: hand-listing them means
// every new Project field silently fails to reach the client until someone
// remembers to add it here.
type projectView struct {
Project
Perm string `json:"perm"`
}
func projectJSON(p Project, perm string) projectView {
// The grant list and the default belong to /api/p/{id}/permissions, which
// has its own gate; they'd be noise on every row of every project list.
p.Perms, p.Default = nil, ""
return projectView{p, perm}
}
func (s *Server) handleProjectGet(w http.ResponseWriter, r *http.Request) {
if s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
p, ok := s.Projects.Get(r.PathValue("project"))
perm := s.projectPermOf(r, p)
if !ok || !atLeast(perm, PermRead) {
http.Error(w, "no such project", http.StatusNotFound)
return
}
writeJSON(w, projectJSON(p, perm))
}
// handleProjectCreate creates a project by name, or returns the existing one
// with that name (create-or-join). Creating is a write, so it follows the
// upload setting.
func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
if s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
if !s.Upload.Enabled {
http.Error(w, "this server is read-only; projects cannot be created", http.StatusForbidden)
return
}
var req struct {
Name string `json:"name"`
Org string `json:"org,omitempty"`
// Template is the starting structure to seed, "" for an empty
// project (the historical behavior).
Template string `json:"template,omitempty"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
// Resolve the template before anything is created, so an unknown name
// leaves no project behind.
var tpl templates.Template
if req.Template != "" {
var err error
if tpl, err = templates.Get(req.Template); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
org, err := s.orgForCreate(r, req.Org)
if err != nil {
if errors.Is(err, ErrManagedElsewhere) {
// A user with no organization on a hub that cannot create one:
// send them where organizations actually come from, rather than a
// 403 naming an org that does not exist.
s.writeDirErr(w, "", err)
return
}
http.Error(w, err.Error(), http.StatusForbidden)
return
}
p, created, err := s.Projects.GetOrCreate(req.Name, org)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if created {
// The creator is the project's first admin. Both writes are
// best-effort in the sense that a failure leaves a usable project
// governed by org owners — but report it rather than lie.
me := normEmail(s.requestUser(r).Email)
if me != "" {
if err := s.Projects.SetCreator(p.ID, me); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// An org owner is already implicitly admin; an explicit grant on
// one is refused elsewhere, so don't write one here either.
if s.Dir == nil || org == "" || s.Dir.Role(org, me) != RoleOwner {
if err := s.Projects.SetPerm(p.ID, me, PermAdmin); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
p, _ = s.Projects.Get(p.ID)
}
if tpl.Name != "" {
// Seeding failure leaves a real, usable project holding part of a
// template. Say so rather than reporting success; there is no
// rollback, and deleting a project over a storage hiccup is worse
// than an honest error.
if err := s.seedTemplate(r.Context(), p.ID, tpl, s.requestUser(r)); err != nil {
http.Error(w, fmt.Sprintf("project %s was created, but seeding the %s template failed: %v",
p.Name, tpl.Name, err), http.StatusBadGateway)
return
}
if err := s.Projects.SetTemplate(p.ID, tpl.Name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
p, _ = s.Projects.Get(p.ID)
}
} else if !atLeast(s.projectPermOf(r, p), PermRead) {
// GetOrCreate is create-or-join by name: without this, POSTing the
// name of a project you've been cut off from would hand back its id.
http.Error(w, permDenied(PermRead), http.StatusForbidden)
return
}
writeJSON(w, map[string]any{"project": projectJSON(p, s.projectPermOf(r, p)), "created": created})
}
// orgForCreate resolves which org a new project lands in: the explicitly
// requested one (must be a membership), else the caller's only org, else —
// for an account in no org yet — a fresh org named after the account, so
// nobody is ever blocked from starting to sync. Orgs disabled → "".
func (s *Server) orgForCreate(r *http.Request, requested string) (string, error) {
if s.Dir == nil || s.Auth == nil {
return "", nil
}
me := s.requestUser(r)
if requested != "" {
if s.Dir.Role(requested, me.Email) == "" {
return "", fmt.Errorf("you are not a member of organization %q", requested)
}
return requested, nil
}
mine := s.Dir.OrgsFor(me.Email)
if len(mine) > 0 {
return mine[0].ID, nil
}
name := me.Name
if name == "" {
name = strings.SplitN(me.Email, "@", 2)[0]
}
o, err := s.Dir.Create(name, me.Email)
if err != nil {
return "", err
}
return o.ID, nil
}
// Node is one entry of the file tree returned by the tree endpoint.
type Node struct {
Name string `json:"name"`
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
Time time.Time `json:"time,omitzero"`
// Same three-field "who" shape as HistoryEntry (history.go), so the
// frontend has one attribution helper for every surface.
User string `json:"user,omitempty"`
UserName string `json:"user_name,omitempty"`
Author string `json:"author,omitempty"`
Device string `json:"device,omitempty"`
Children []*Node `json:"children,omitempty"`
}
func (s *Server) handleTree(v *volume, w http.ResponseWriter, r *http.Request) {
snap, err := v.snapshot(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, buildTree(snap.files))
}
func buildTree(files map[string]FileInfo) *Node {
root := &Node{Name: "/", Dir: true}
dirs := map[string]*Node{"": root}
for _, p := range slices.Sorted(maps.Keys(files)) {
fi := files[p]
parent := root
segs := strings.Split(p, "/")
for i := 0; i < len(segs)-1; i++ {
dp := strings.Join(segs[:i+1], "/")
n, ok := dirs[dp]
if !ok {
n = &Node{Name: segs[i], Path: dp, Dir: true}
dirs[dp] = n
parent.Children = append(parent.Children, n)
}
parent = n
}
parent.Children = append(parent.Children, &Node{
Name: segs[len(segs)-1], Path: p,
Size: fi.Size, Time: fi.Time,
User: fi.User, UserName: fi.UserName, Author: fi.Author, Device: fi.Device,
})
}
sortTree(root)
return root
}
func sortTree(n *Node) {
sort.SliceStable(n.Children, func(i, j int) bool {
a, b := n.Children[i], n.Children[j]
if a.Dir != b.Dir {
return a.Dir // folders first, like Obsidian
}
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
})
for _, c := range n.Children {
if c.Dir {
sortTree(c)
}
}
}
// lookup resolves ?path= against the volume's current snapshot.
func lookup(v *volume, r *http.Request) (string, FileInfo, int, error) {
p := r.URL.Query().Get("path")
if p == "" {
return "", FileInfo{}, http.StatusBadRequest, fmt.Errorf("missing ?path=")
}
snap, err := v.snapshot(r.Context())
if err != nil {
log.Printf("beardrive: read project snapshot: %v", err)
return "", FileInfo{}, http.StatusBadGateway, fmt.Errorf("content temporarily unavailable")
}
fi, ok := snap.files[p]
if !ok {
// The address is empty — but the file may have moved out of it. A
// LIVE path always wins, which falls out of the ordering: the
// snapshot hit above returns first, so nothing redirects while
// something still answers at the old address.
to, moved := resolveForward(snap.moves, snap.files, p)
if !moved {
return "", FileInfo{}, http.StatusNotFound, fmt.Errorf("no such file: %s", p)
}
// The canonical path is what gets returned, so the read is recorded
// against it (heat doesn't split across old and new) and the render
// payload names it.
p, fi = to, snap.files[to]
}
return p, fi, 0, nil
}
func (s *Server) serveBlob(v *volume, w http.ResponseWriter, r *http.Request, attach bool) {
p, fi, code, err := lookup(v, r)
if err != nil {
http.Error(w, err.Error(), code)
return
}
setCanonical(w, r, p)
// Count the read before the ETag check: a 304 render is still a person
// reading the file, and skipping it would undercount the hottest pages.
s.recordRead(r, p)
etag := `"` + fi.Blob + `"`
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
rc, err := v.source.Open(r.Context(), p, fi)
if err != nil {
storageErr(w, http.StatusBadGateway, "content temporarily unavailable", err)
return
}
defer rc.Close()
w.Header().Set("ETag", etag)
ct := contentType(p)
w.Header().Set("Content-Type", ct)
setContentLength(w, rc)
// nosniff on both branches, the sandbox CSP only on the inline one: an
// attachment is not rendered, and TestInlineHTMLIsSandboxed pins that
// /download answers with a disposition INSTEAD of a CSP.
w.Header().Set("X-Content-Type-Options", "nosniff")
if attach {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", path.Base(p)))
} else {
w.Header().Set("Content-Type", inlineType(ct))
sandboxInline(w, ct)
}
io.Copy(w, rc)
}
// setContentLength promises a body length only when the thing about to be
// streamed can be measured. FileInfo.Size comes off a journal op — JSON a
// client pushed — so echoing it made the hub promise a length it had no way
// to keep: a padded or truncated response for every download of that file,
// declared by anyone who can push a journal. When the source cannot measure
// (an object store's response body), no header goes out and net/http streams
// chunked, which is a slightly worse progress bar and a true one.
func setContentLength(w http.ResponseWriter, rc io.Reader) {
switch v := rc.(type) {
case interface{ Stat() (fs.FileInfo, error) }: // *os.File: file:// backend, DirSource
if fi, err := v.Stat(); err == nil && fi.Mode().IsRegular() {
w.Header().Set("Content-Length", fmt.Sprint(fi.Size()))
}
case interface{ Size() int64 }: // GCS *storage.Reader, bytes.Reader
w.Header().Set("Content-Length", fmt.Sprint(v.Size()))
}
}
func (s *Server) handleFile(v *volume, w http.ResponseWriter, r *http.Request) {
s.serveBlob(v, w, r, false)
}
func (s *Server) handleDownload(v *volume, w http.ResponseWriter, r *http.Request) {
s.serveBlob(v, w, r, true)
}
func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request) {
if sha := r.URL.Query().Get("sha"); sha != "" {
s.renderVersion(v, w, r, sha)
return
}
p, fi, code, err := lookup(v, r)
if err != nil {
http.Error(w, err.Error(), code)
return
}
setCanonical(w, r, p)
s.recordRead(r, p)
rc, err := v.source.Open(r.Context(), p, fi)
if err != nil {
storageErr(w, http.StatusBadGateway, "content temporarily unavailable", err)
return
}
src, err := io.ReadAll(rc)
rc.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
pairs, html, err := RenderMarkdownPairs(src)
if err != nil {
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
return
}
doc := map[string]any{
"path": p, "html": html,
"size": fi.Size, "time": fi.Time, "author": fi.Author, "device": fi.Device,
}
// Frontmatter travels as data, not as a table baked into html — the
// viewer lays it out in a side panel. Omitted when the document has
// none, so the client shows no panel rather than an empty one.
if len(pairs) > 0 {
doc["frontmatter"] = pairs
}
// Omitted rather than sent empty, so a journal from before accounts
// existed still renders its Author instead of a blank attribution.
if fi.User != "" {
doc["user"] = fi.User
}
if fi.UserName != "" {
doc["user_name"] = fi.UserName
}
if f := renderFindings(src); len(f) > 0 {
doc["findings"] = f
}
writeJSON(w, doc)
}
// renderFindings is the share gate's credential scan on the path every file
// takes. The gate could name the rule and the line well enough to refuse to
// publish a file while the viewer rendered the same key as ordinary prose
// (BEA-147), so the render response carries the finding too — advisory only,
// nothing is blocked and nothing is redacted, since a member who can open the
// file could already read the key.
//
// Rule ids and line numbers only. The matched text must never reach a
// response body; see the doc comment on secrets.Scan.
//
// The cap is a slice rather than the LimitReader the two streaming callers
// use: the render path already holds the whole file, because that is what
// RenderMarkdown needs. Same ScanLimit either way, so the badge and the share
// dialog can never disagree about the same file.
func renderFindings(src []byte) []secrets.Finding {
if len(src) > secrets.ScanLimit {
src = src[:secrets.ScanLimit]
}
return secrets.Scan(src)
}
// renderVersion renders one exact past version by content hash — the
// markdown counterpart of /blob?sha=, so opening an old .md from history
// shows a rendered page instead of raw source. Provenance is not returned:
// the caller already has the history entry it clicked. Viewing history is
// never a read (see the read-heat invariant), so nothing is recorded.
func (s *Server) renderVersion(v *volume, w http.ResponseWriter, r *http.Request, sha string) {
if !blobRe.MatchString(sha) {
http.Error(w, "invalid sha", http.StatusBadRequest)
return
}
rs := storeSource(v, w)
if rs == nil {
return
}
rc, err := rs.OpenBlob(r.Context(), sha)
if err != nil {
http.Error(w, "no such version", http.StatusNotFound)
return
}
src, err := io.ReadAll(rc)
rc.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
pairs, html, err := RenderMarkdownPairs(src)
if err != nil {
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
return
}
doc := map[string]any{
"path": r.URL.Query().Get("path"), "html": html, "size": len(src),
}
if len(pairs) > 0 {
doc["frontmatter"] = pairs
}
// The history view goes through this same endpoint, so scanning here too
// is what stops the badge vanishing the moment you click into history on
// the very file it was warning about.
if f := renderFindings(src); len(f) > 0 {
doc["findings"] = f
}
writeJSON(w, doc)
}
// inlineMarkup reports whether a Content-Type names something the browser
// parses as a DOCUMENT in a top-level navigation, which is what makes it a
// script-execution vehicle on whatever origin served it.
//
// It is deliberately a property and not a list of extensions. The list was the
// bug: it named text/html, image/svg and *xhtml*, and the whole XML family sat
// outside it while having exactly the property — an XML document carries its
// own `<?xml-stylesheet type="text/xsl"?>`, the browser applies the XSLT (the
// stylesheet is same-origin, the attacker uploads it to the same project) and
// renders the result, which is HTML, in the hub's origin with the reader's
// session. Anything that parses as markup belongs here; when in doubt, add it.
func inlineMarkup(ct string) bool {
ct = strings.ToLower(ct)
for _, m := range []string{"text/html", "xhtml", "svg", "/xml", "+xml"} {
if strings.Contains(ct, m) {
return true
}
}
return false
}
// inlineType is the Content-Type the hub is willing to have a browser PARSE
// when it serves stored bytes inline.
//
// The XML family is declared inert. The sandbox CSP below already removes its
// capability — but it removes it by making the document render as nothing at
// all (the stylesheet an XML document names is sandboxed too, so the XSLT
// never runs and there is no document), and "you see nothing" is a poor answer
// for a reader who clicked a .xml. Declaring it text is both the stronger
// answer — it needs no CSP support in the browser, and nothing parses a
// document — and the more useful one: the reader sees the source.
//
// HTML, XHTML and SVG keep their real type. The app has always served them,
// and for them the sandbox is a complete wall rather than a blank page.
func inlineType(ct string) string {
l := strings.ToLower(ct)
if inlineMarkup(l) && !strings.Contains(l, "html") && !strings.Contains(l, "svg") {
return "text/plain; charset=utf-8"
}
return ct
}
// sandboxInline walls off markup the hub serves from its own origin: synced
// HTML (any flavour), scriptable SVG and the XML family run in an opaque
// sandboxed origin — same posture as /s/* share pages — so they can never
// touch the API or the reader's session cookie. Every route that streams
// stored bytes inline calls this: the live file (serveBlob) and any past
// version (history's handleBlob), which serve identical content and must not
// differ in their wall.
// It also stamps nosniff on every response it sees. The wall above keys off
// the Content-Type the hub declared; without nosniff a browser is free to
// sniff attacker-written bytes into a document type the hub never named, which
// is the same capability arriving through a door the CSP never opened.
func sandboxInline(w http.ResponseWriter, ct string) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if inlineMarkup(ct) {
w.Header().Set("Content-Security-Policy", "sandbox allow-scripts")
}
}
func contentType(p string) string {
switch strings.ToLower(path.Ext(p)) {
case ".md", ".markdown":
return "text/markdown; charset=utf-8"
case ".txt", ".log", ".go", ".py", ".js", ".ts", ".sh", ".yaml", ".yml", ".toml", ".csv":
return "text/plain; charset=utf-8"
case ".json":
return "application/json"
}
if t := mime.TypeByExtension(path.Ext(p)); t != "" {
return t
}
return "application/octet-stream"
}
// storageErr answers a failed storage operation. The detail goes to the log,
// never to the client: an object-store error names the hub's absolute path
// (or, on S3, its bucket and key), which no project member has any business
// learning from a missing file.
func storageErr(w http.ResponseWriter, code int, msg string, err error) {
log.Printf("beardrive: %s: %v", msg, err)
http.Error(w, msg, code)
}
func writeJSON(w http.ResponseWriter, v any) {
writeJSONStatus(w, http.StatusOK, v)
}
// writeJSONStatus is writeJSON for the answers a client has to read the body
// of — a 409 whose findings the CLI and the browser both decode.
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}