mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(history): group agent runs and restore any version (BEA-6) BearDrive recorded everything and could restore nothing. Now every version of a file has a Restore button — in the hub's History view and as `bdrive restore` — and the changes one agent run made read as one card instead of N loose rows. Restore is a NEW put op pointing at the old blob: journals are never rewritten, so one-writer-per-journal holds and peers converge on the restore like any other edit. The hub reuses RemoteSource.Commit (the upload commit minus the upload); the CLI writes the bytes into the working folder and lets the ordinary cycle journal them, so the sync engine gains no new write path. Grouping is a pure frontend group-by on (note, device) over the existing /history response — no journal or API change. Known gap, stated in the UI and the docs: nothing in the hub writes a delete op yet, so a file a run *created* cannot be un-created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): don't repeat a run's note on every row in its card UI pass on the real hub: inside a run card the note is the card's header, so printing it again on each row said the same thing N times. The header now carries the note (linkified, so an agent's session link still opens) and the collapse control is its own button rather than the whole header — the link could not live inside a button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
856 lines
28 KiB
Go
856 lines
28 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"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"maps"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
//go:embed 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)
|
|
// ShareRPM is the per-IP request rate on public share links (/s/*);
|
|
// 0 means DefaultShareRPM.
|
|
ShareRPM int
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
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}, 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 volume, creating the (cached)
|
|
// source over the project's storage prefix on first use.
|
|
func (s *Server) projectVolume(id string) (*volume, error) {
|
|
if s.Root == nil || s.Projects == nil {
|
|
return nil, fmt.Errorf("this server does not host projects")
|
|
}
|
|
if !projectIDRe.MatchString(id) {
|
|
return nil, fmt.Errorf("invalid project id %q", id)
|
|
}
|
|
if _, ok := s.Projects.Get(id); !ok {
|
|
return 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},
|
|
refresh: s.Refresh,
|
|
}
|
|
s.vols[id] = v
|
|
}
|
|
return 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
|
|
|
|
upmu sync.Mutex // serializes read-modify-write of our own journal
|
|
}
|
|
|
|
// 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) {
|
|
objs, err := r.Backend.List(ctx, "journal/")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list journals: %w", err)
|
|
}
|
|
var all []journal.Op
|
|
for _, o := range objs {
|
|
if !strings.HasSuffix(o.Key, ".jsonl") {
|
|
continue
|
|
}
|
|
rc, err := r.Backend.Get(ctx, o.Key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch %s: %w", o.Key, err)
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops, err := journal.Parse(data)
|
|
if err != nil {
|
|
continue // corrupt journal; ignore rather than break the view
|
|
}
|
|
all = append(all, ops...)
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error) {
|
|
all, err := r.loadOps(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
journal.Sort(all)
|
|
files := make(map[string]FileInfo)
|
|
for _, op := range all {
|
|
switch op.Kind {
|
|
case journal.KindPut:
|
|
files[op.Path] = FileInfo{
|
|
Blob: op.Blob, Size: op.Size, Time: op.Time,
|
|
Author: op.Author, Device: op.DeviceName,
|
|
}
|
|
case journal.KindDelete:
|
|
delete(files, op.Path)
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func (r *RemoteSource) Open(ctx context.Context, _ string, fi FileInfo) (io.ReadCloser, error) {
|
|
return r.Backend.Get(ctx, "blobs/"+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()
|
|
|
|
// 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")
|
|
v, err := s.projectVolume(id)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
if !s.requirePerm(w, r, id, 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+"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.
|
|
mux.HandleFunc("POST /api/p/{project}/restore", proj(PermWrite, s.handleRestore))
|
|
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 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), "/")
|
|
// 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.
|
|
if strings.HasPrefix(upath, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
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() {
|
|
files.ServeHTTP(w, r) // a real asset
|
|
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},
|
|
}
|
|
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.projectPerm(r, p.ID)
|
|
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.projectPerm(r, p.ID)
|
|
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"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+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)
|
|
}
|
|
} else if !atLeast(s.projectPerm(r, p.ID), 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.projectPerm(r, p.ID)), "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"`
|
|
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, 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 {
|
|
return "", FileInfo{}, http.StatusBadGateway, err
|
|
}
|
|
fi, ok := snap.files[p]
|
|
if !ok {
|
|
return "", FileInfo{}, http.StatusNotFound, fmt.Errorf("no such file: %s", p)
|
|
}
|
|
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
|
|
}
|
|
// 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 {
|
|
http.Error(w, fmt.Sprintf("fetch content: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer rc.Close()
|
|
w.Header().Set("ETag", etag)
|
|
ct := contentType(p)
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Content-Length", fmt.Sprint(fi.Size))
|
|
if attach {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", path.Base(p)))
|
|
} else if strings.HasPrefix(ct, "text/html") || strings.HasPrefix(ct, "image/svg") {
|
|
// Synced HTML (and scriptable SVG) served inline must never run
|
|
// with the hub origin's session — same posture as /s/* share
|
|
// pages: an opaque sandboxed origin that can't touch the API or
|
|
// cookies. The viewer renders these in a sandboxed iframe; direct
|
|
// navigation gets the same wall.
|
|
w.Header().Set("Content-Security-Policy", "sandbox allow-scripts")
|
|
}
|
|
io.Copy(w, rc)
|
|
}
|
|
|
|
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
|
|
}
|
|
s.recordRead(r, p)
|
|
rc, err := v.source.Open(r.Context(), p, fi)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("fetch content: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
src, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
html, err := RenderMarkdown(src)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"path": p, "html": html,
|
|
"size": fi.Size, "time": fi.Time, "author": fi.Author, "device": fi.Device,
|
|
})
|
|
}
|
|
|
|
// 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.Backend.Get(r.Context(), "blobs/"+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
|
|
}
|
|
html, err := RenderMarkdown(src)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"path": r.URL.Query().Get("path"), "html": html, "size": len(src),
|
|
})
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|