Files
beardrive/cmd/bdrive/web.go
T
Snow LeeandClaude Fable 5 c0e785a4ba feat: public share links and /beardrive:install for Claude Code teams
Share links (bdrive share <file>, or the web UI's Share button):
- Mint an unguessable public URL (/s/<token>) anyone can open — no
  account. HTML renders as a page, markdown gets a standalone shell,
  PDFs open inline; ?download=1 attaches.
- Sandboxed: /s/* responses carry CSP `sandbox allow-scripts` + nosniff
  and never see auth cookies, so shared content's scripts run in an
  opaque origin and can't touch hub sessions.
- Links serve the file's LATEST synced content and live until revoked;
  --expires makes self-destructing ones; --list/--revoke manage them.
  Re-sharing a file returns the same link. File-backed shares.json.
- CLI resolves the project by walking up to .bdrive/ from the shared
  file, warns when the hub address is private (LAN-only links), and
  hints when the file hasn't synced yet.

/beardrive:install (plugin command) — team onboarding driven by Claude:
- Ensures the bdrive binary, signs in (bdrive login), runs bdrive init
  (whole folder or a shared subfolder like wiki/).
- Asks before appending a CLAUDE.md section that teaches agents to put
  shareable artifacts in the shared folder and mint URLs with bdrive
  share; asks before registering project-level hooks in
  .claude/settings.json: blocking pull at UserPromptSubmit, async push
  on PostToolUse Write|Edit — teammates sync with or without the plugin.
- Fix: the plugin hook script still checked for the old `.bdrive` file
  and was a silent no-op since the directory change; now checks -d.

Tests: share creation gating, public access + sandbox headers, dedupe,
latest-content semantics, revoke, expiry, markdown/download variants,
list filtering, registry persistence. Docs updated (README sharing +
Claude Code sections, SKILL.md, CLAUDE.md).

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

278 lines
9.0 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path"
"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 web command's flags so a server can be configured
// from a file (bdrive web -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
// 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
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"`
}
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: "web [folder | storage-root-url]",
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 <this server>" 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 web # serve the current directory
bdrive web ./notes # serve a folder
bdrive web -c config.json # everything from a config file
bdrive web s3://bucket/root --upload # multi-project sync hub`,
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 = "."
}
srv := &webapp.Server{
Refresh: refresh,
Upload: webapp.UploadConfig{Enabled: upload, TTL: uploadTTL},
}
var display string
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")
}
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}
srv.Volume = volumeName(remoteURL)
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 := ""
allowSignup := true
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")
}
auth, err := webapp.OpenBuiltinAuth(usersDB, allowSignup, mail)
if err != nil {
return fmt.Errorf("open account registry: %w", err)
}
srv.Auth = auth
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
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
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
}
func volumeName(remoteURL string) string {
if u, err := url.Parse(remoteURL); err == nil {
if base := path.Base(strings.Trim(u.Path, "/")); base != "" && base != "." {
return base
}
if u.Host != "" {
return u.Host
}
}
return "beardrive"
}