package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/runbear-io/beardrive/internal/config" "github.com/runbear-io/beardrive/internal/remote" "github.com/runbear-io/beardrive/internal/webapp" ) // webConfig mirrors the serve command's flags so a server can be configured // from a file (bdrive serve -c config.json). Explicitly-passed flags win over // file values. type webConfig struct { Remote string `json:"remote,omitempty"` Dir string `json:"dir,omitempty"` Addr string `json:"addr,omitempty"` Volume string `json:"volume,omitempty"` Refresh string `json:"refresh,omitempty"` // duration, e.g. "10s" Upload *bool `json:"upload,omitempty"` UploadTTL string `json:"upload_ttl,omitempty"` // duration, e.g. "15m" ProjectsDB string `json:"projects_db,omitempty"` // hub project registry path ShareRPM int `json:"share_rpm,omitempty"` // per-IP rate on /s/* (default 120/min) // TrustProxy makes the rate limiters read the client address from // X-Forwarded-For sent by ANY peer. Usually unnecessary: a proxy that // reaches the hub over loopback or a private network is trusted with no // configuration. Set it only for a proxy on a PUBLIC address — on a // directly-reachable hub the header is client-supplied, and trusting it // lets one connection get a fresh bucket per request, which disables the // share limiter and the login brute-force limiter alike. TrustProxy bool `json:"trust_proxy,omitempty"` // Auth tunes the hub's (always-on) authentication; hubs require // sign-in unconditionally, only these knobs are optional. Auth *struct { AllowSignup *bool `json:"allow_signup,omitempty"` // default true UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json AllowedDomains []string `json:"allowed_domains,omitempty"` // signup email must match one (e.g. ["runbear.io"]) RequireVerification *bool `json:"require_verification,omitempty"` // new accounts verify email before activation RequireApproval *bool `json:"require_approval,omitempty"` // new accounts await admin approval Admins []string `json:"admins,omitempty"` // hub admin emails (approve users, govern shares) Brand string `json:"brand,omitempty"` // name shown on the sign-in page BaseURL string `json:"base_url,omitempty"` // public origin used for MAILED links (never the request's Host) SMTP *struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user,omitempty"` Pass string `json:"pass,omitempty"` From string `json:"from,omitempty"` } `json:"smtp,omitempty"` } `json:"auth,omitempty"` // Database selects where hub metadata (accounts, projects, orgs, invites, // shares, devices) lives. Default "file" (JSON under BDRIVE_HOME). Blobs // and journals always stay in the object store, never the database. Database *struct { Driver string `json:"driver,omitempty"` // file (default) | sqlite | postgres DSN string `json:"dsn,omitempty"` // sqlite file path, or a Postgres/Supabase URL } `json:"database,omitempty"` // Reads tunes read telemetry (the heat API): aggregate view counts per // file, split human/agent/share. On by default in hub mode; counts only, // no reader identities in any API. Reads *struct { Enabled *bool `json:"enabled,omitempty"` // default true RetentionDays int `json:"retention_days,omitempty"` // default 400; older days fold into all-time // SessionRetentionDays bounds the per-session read detail behind // History's run cards (which files an agent session read). Default // 30, and deliberately much shorter than RetentionDays: this is // event-shaped rather than aggregate, and rows past it are deleted, // which changes no heat total. SessionRetentionDays int `json:"session_retention_days,omitempty"` } `json:"reads,omitempty"` } func loadWebConfig(path string) (webConfig, error) { var c webConfig data, err := os.ReadFile(path) if err != nil { return c, err } dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(&c); err != nil { return c, fmt.Errorf("parse %s: %w", path, err) } return c, nil } func webCmd() *cobra.Command { var remoteURL, dir, volume string var addr, configPath, projectsDB string var refresh, uploadTTL time.Duration var upload bool var cfg webConfig c := &cobra.Command{ Use: "serve [folder | storage-root-url]", Aliases: []string{"web"}, Short: "Serve the bdrive web server: viewer, uploads, and sync hub", Long: `Serve the bdrive web server: browse folders and files, read rendered markdown (Obsidian-style, including [[wikilinks]]), and download any file. Two modes: - a local folder, served straight from disk (the default — on a mounted folder the daemon keeps it fresh, so this is the simplest viewer); - a hub: point it at an object-storage root and it hosts many projects, each stored under its own prefix, managed by a file-backed project registry. Client devices run "bdrive login " once, then "bdrive init" per project, and sync whole folders through it without ever seeing the storage location or holding cloud credentials. The server is read-only unless --upload is set. With uploads on, content travels directly between clients and the object store through short-lived presigned URLs when the backend supports it (S3, GCS with signing credentials); otherwise it is relayed through this server.`, Example: ` bdrive serve # serve the current directory bdrive serve ./notes # serve a folder bdrive serve -c config.json # everything from a config file bdrive serve s3://bucket/root --upload # multi-project sync hub bdrive web # deprecated alias for "bdrive serve"`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Config file first; flags that were explicitly passed override // its values. if configPath != "" { c, err := loadWebConfig(configPath) if err != nil { return err } cfg = c set := cmd.Flags().Changed if c.Remote != "" && !set("remote") { remoteURL = c.Remote } if c.Dir != "" && !set("dir") { dir = c.Dir } if c.Addr != "" && !set("addr") { addr = c.Addr } if c.Volume != "" && !set("volume") { volume = c.Volume } if c.Upload != nil && !set("upload") { upload = *c.Upload } if c.Refresh != "" && !set("refresh") { d, err := time.ParseDuration(c.Refresh) if err != nil { return fmt.Errorf("config refresh: %w", err) } refresh = d } if c.UploadTTL != "" && !set("upload-ttl") { d, err := time.ParseDuration(c.UploadTTL) if err != nil { return fmt.Errorf("config upload_ttl: %w", err) } uploadTTL = d } if c.ProjectsDB != "" && !set("projects-db") { projectsDB = c.ProjectsDB } } // Positional argument: a URL selects remote mode, anything else // is a folder. With nothing specified, serve the current dir. if remoteURL == "" && dir == "" && len(args) > 0 { if strings.Contains(args[0], "://") { remoteURL = args[0] } else { dir = args[0] } } if remoteURL != "" && dir != "" { return fmt.Errorf("--remote and --dir are mutually exclusive") } if remoteURL == "" && dir == "" { dir = "." } // A config that asks for gating this mode cannot provide is // refused, not silently honoured in part. The whole auth block — // allowed_domains, require_approval, allow_signup, admins — is // built only in the hub branch, so with a `dir` the operator got a // server anyone can read and a config file that said otherwise. // Same posture as ValidateSignupPolicy, which exists for exactly // this and lives inside the branch that never runs here. if dir != "" && cfg.Auth != nil { return fmt.Errorf("the `auth` block configures a hub's sign-in and a `dir` selects " + "the single-volume viewer, which is auth-free by design: remove one of them " + "(use `remote:` to run a hub)") } srv := &webapp.Server{ Refresh: refresh, Upload: webapp.UploadConfig{Enabled: upload, TTL: uploadTTL}, ShareRPM: cfg.ShareRPM, TrustProxy: cfg.TrustProxy, } var display string var meta webapp.MetaStore // hub metadata store; nil means the file backend if dir != "" { // Single-volume viewer over a plain folder. abs, err := filepath.Abs(dir) if err != nil { return err } if fi, err := os.Stat(abs); err != nil || !fi.IsDir() { return fmt.Errorf("%s is not a directory", abs) } srv.Source = &webapp.DirSource{Root: abs} srv.Volume = filepath.Base(abs) display = abs } else { // Hub: many projects on one storage root. be, err := remote.Open(cmd.Context(), remoteURL) if err != nil { return err } defer be.Close() if projectsDB == "" { home, err := config.Home() if err != nil { return err } projectsDB = filepath.Join(home, "projects.json") } // Pick the metadata backend: file (default) or a SQL database // (sqlite locally, Postgres/Supabase in production). if cfg.Database != nil { switch cfg.Database.Driver { case "", "file": case "sqlite", "postgres", "pgx": drv := cfg.Database.Driver if drv == "postgres" { drv = "pgx" } meta, err = webapp.OpenSQLStore(drv, cfg.Database.DSN) if err != nil { return fmt.Errorf("open database: %w", err) } defer meta.Close() default: return fmt.Errorf("unknown database driver %q (use file, sqlite, or postgres)", cfg.Database.Driver) } } var db *webapp.ProjectDB if meta != nil { db, err = webapp.NewProjectDB(meta.Projects()) } else { db, err = webapp.OpenProjectDB(projectsDB) } if err != nil { return fmt.Errorf("open project registry: %w", err) } dev, err := config.LoadDevice() if err != nil { return fmt.Errorf("load device identity: %w", err) } srv.Root = be srv.Projects = db srv.Device = webapp.Identity{ID: dev.ID, Name: dev.Name, Author: dev.Author} // Display name only — and it goes out in /api/config, which is // readable anonymously, so it must not be derived from the // storage URL: on s3://acme-prod-drive that named the bucket // to the whole internet. `--volume` / `volume:` stays the one // way an operator puts a storage-flavoured string on the wire. srv.Volume = "BearDrive" if meta != nil { display = remoteURL } else { display = remoteURL + " (projects: " + projectsDB + ")" } } if volume != "" { srv.Volume = volume } // Hubs always require sign-in: every op needs a real account // behind it (history, device registry). The plain-folder viewer // stays auth-free. if srv.Root != nil { usersDB := "" // Invite-only by default: a fresh hub doesn't accept self-signup // until an admin opts in (with a gate). Owners onboard people // with /join/ invite links. allowSignup := false var mail *webapp.Mailer if cfg.Auth != nil { usersDB = cfg.Auth.UsersDB if cfg.Auth.AllowSignup != nil { allowSignup = *cfg.Auth.AllowSignup } if cfg.Auth.SMTP != nil { mail = &webapp.Mailer{ Host: cfg.Auth.SMTP.Host, Port: cfg.Auth.SMTP.Port, User: cfg.Auth.SMTP.User, Pass: cfg.Auth.SMTP.Pass, From: cfg.Auth.SMTP.From, } } } home, err := config.Home() if err != nil { return err } if usersDB == "" { usersDB = filepath.Join(home, "auth.json") } var auth *webapp.BuiltinAuth if meta != nil { auth, err = webapp.NewBuiltinAuth(meta.Accounts(), allowSignup, mail) } else { auth, err = webapp.OpenBuiltinAuth(usersDB, allowSignup, mail) } if err != nil { return fmt.Errorf("open account registry: %w", err) } if cfg.Auth != nil { auth.AllowedDomains = cfg.Auth.AllowedDomains // Toggles: an explicit config value pins the setting each // boot; otherwise the UI-saved policy (loaded from auth.json) // stands. if cfg.Auth.RequireVerification != nil { auth.RequireVerification = *cfg.Auth.RequireVerification } if cfg.Auth.RequireApproval != nil { auth.RequireApproval = *cfg.Auth.RequireApproval } auth.Brand = cfg.Auth.Brand auth.BaseURL = cfg.Auth.BaseURL if len(cfg.Auth.Admins) > 0 { auth.Admins = make(map[string]bool, len(cfg.Auth.Admins)) for _, e := range cfg.Auth.Admins { auth.Admins[strings.ToLower(strings.TrimSpace(e))] = true } } } // Refuse to boot an incoherent signup posture (open with no // gate, or verification without a mailer) rather than silently // leaving the door open. if err := auth.ValidateSignupPolicy(); err != nil { return err } srv.Auth = auth var orgs *webapp.OrgDB if meta != nil { orgs, err = webapp.NewOrgDB(meta.Orgs()) } else { orgs, err = webapp.OpenOrgDB(filepath.Join(filepath.Dir(projectsDB), "orgs.json")) } if err != nil { return fmt.Errorf("open org registry: %w", err) } srv.Dir = webapp.LocalDirectory{OrgDB: orgs} // Invite links can bootstrap an account on an invite-only hub. auth.InviteValid = orgs.ValidInvite // A hub that predates organizations: sweep its projects into // a default org so it keeps working with zero manual steps. if err := webapp.MigrateOrgs(srv.Projects, orgs, auth.Accounts()); err != nil { return fmt.Errorf("migrate projects into orgs: %w", err) } var devices *webapp.DeviceRegistry if meta != nil { devices, err = webapp.NewDeviceRegistry(meta.Devices()) } else { devices, err = webapp.OpenDeviceRegistry(filepath.Join(filepath.Dir(projectsDB), "devices.json")) } if err != nil { return fmt.Errorf("open device registry: %w", err) } srv.Devices = devices var shares *webapp.ShareDB if meta != nil { shares, err = webapp.NewShareDB(meta.Shares()) } else { shares, err = webapp.OpenShareDB(filepath.Join(filepath.Dir(projectsDB), "shares.json")) } if err != nil { return fmt.Errorf("open share registry: %w", err) } srv.Shares = shares readsOn, retention, sessRetention := true, 0, 0 if cfg.Reads != nil { if cfg.Reads.Enabled != nil { readsOn = *cfg.Reads.Enabled } retention = cfg.Reads.RetentionDays sessRetention = cfg.Reads.SessionRetentionDays } if readsOn { var reads *webapp.ReadLedger var sessions webapp.SessionReadRepo if meta != nil { reads, err = webapp.NewReadLedger(meta.Reads(), retention) sessions = meta.SessionReads() } else { dir := filepath.Dir(projectsDB) reads, err = webapp.OpenReadLedger(filepath.Join(dir, "reads.json"), retention) sessions = webapp.OpenSessionReadRepo(filepath.Join(dir, "sessions.json")) } if err != nil { return fmt.Errorf("open read ledger: %w", err) } reads.WithSessions(sessions, sessRetention) defer reads.Close() srv.Reads = reads } if meta != nil { display += " (db: " + cfg.Database.Driver + ")" } else { display += " (auth: " + usersDB + ")" } } shown := addr if strings.HasPrefix(shown, ":") { shown = "localhost" + shown } fmt.Printf("serving %s\n volume: %s\n url: http://%s\n", display, srv.Volume, shown) return http.ListenAndServe(addr, srv.Handler()) }, } c.Flags().StringVarP(&remoteURL, "remote", "r", "", "remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)") c.Flags().StringVar(&dir, "dir", "", "local folder to serve (default: current directory)") c.Flags().StringVar(&addr, "addr", ":4173", "address to listen on") c.Flags().StringVarP(&volume, "volume", "v", "", "volume display name (default: folder or remote basename)") c.Flags().DurationVar(&refresh, "refresh", 10*time.Second, "how long to cache the file listing") c.Flags().BoolVar(&upload, "upload", false, "allow clients to upload files") c.Flags().DurationVar(&uploadTTL, "upload-ttl", webapp.DefaultUploadTTL, "lifetime of presigned direct-upload URLs") c.Flags().StringVarP(&configPath, "config", "c", "", "JSON config file; explicit flags override its values") c.Flags().StringVar(&projectsDB, "projects-db", "", "hub project registry file (default: $BDRIVE_HOME/projects.json)") return c }