mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
223 lines
6.5 KiB
Go
223 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
)
|
|
|
|
// shareCmd mints public URLs for synced files: `bdrive share report.html`
|
|
// prints a link anyone can open — no account needed. Links serve the file's
|
|
// latest synced content and live until revoked.
|
|
func shareCmd() *cobra.Command {
|
|
var expires time.Duration
|
|
var list bool
|
|
var revoke string
|
|
c := &cobra.Command{
|
|
Use: "share [file]",
|
|
Short: "Share a synced file publicly by URL",
|
|
Long: `Create a public link for a file in a bdrive project. Anyone with the URL
|
|
can view it — HTML renders as a page, markdown renders like the viewer,
|
|
PDFs open inline — with no account. The link always serves the file's
|
|
latest synced content and lives until revoked (use --expires to limit it).
|
|
|
|
The file must be inside an initialized project and already synced (the
|
|
daemon usually gets it there within seconds of saving).`,
|
|
Example: ` bdrive share wiki/report.html
|
|
bdrive share deck.pdf --expires 168h # link dies after a week
|
|
bdrive share --list # this project's links
|
|
bdrive share --revoke <token-or-url>`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
settings, err := config.LoadSettings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case revoke != "":
|
|
return revokeShare(settings, revoke)
|
|
case list:
|
|
return listShares(settings)
|
|
case len(args) == 0:
|
|
return fmt.Errorf("what to share? bdrive share <file> (or --list / --revoke)")
|
|
}
|
|
|
|
abs, err := filepath.Abs(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root, proj, err := findProject(filepath.Dir(abs))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(root, abs)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
return fmt.Errorf("%s is outside the project at %s", abs, root)
|
|
}
|
|
server, projectID, err := splitHubRemote(proj.Remote)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]string{"path": filepath.ToSlash(rel)}
|
|
if expires > 0 {
|
|
body["expires_in"] = expires.String()
|
|
}
|
|
data, _ := json.Marshal(body)
|
|
resp, err := serverDo(http.MethodPost, server+"/api/p/"+projectID+"/shares", settings.Token, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return fmt.Errorf("%s (if you just saved it, wait a few seconds for the daemon or run `bdrive sync`)", strings.TrimSpace(readBody(resp)))
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return httpBodyError(resp)
|
|
}
|
|
var out struct {
|
|
URL string `json:"url"`
|
|
Expires time.Time `json:"expires"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(out.URL)
|
|
if !out.Expires.IsZero() {
|
|
fmt.Printf(" expires: %s\n", out.Expires.Local().Format(time.RFC1123))
|
|
}
|
|
if u, err := url.Parse(out.URL); err == nil && isPrivateHost(u.Hostname()) {
|
|
fmt.Fprintf(os.Stderr, "note: this link is only reachable where %s is (private address)\n", u.Hostname())
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().DurationVar(&expires, "expires", 0, "make the link expire (e.g. 24h, 168h); default: lives until revoked")
|
|
c.Flags().BoolVar(&list, "list", false, "list this project's share links")
|
|
c.Flags().StringVar(&revoke, "revoke", "", "revoke a share link (token or full URL)")
|
|
return c
|
|
}
|
|
|
|
func listShares(settings config.Settings) error {
|
|
root, proj, err := findProject(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server, projectID, err := splitHubRemote(proj.Remote)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := serverDo(http.MethodGet, server+"/api/p/"+projectID+"/shares", settings.Token, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return httpBodyError(resp)
|
|
}
|
|
var out struct {
|
|
Shares []struct {
|
|
Path string `json:"path"`
|
|
URL string `json:"url"`
|
|
Creator string `json:"creator"`
|
|
Expires time.Time `json:"expires"`
|
|
} `json:"shares"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return err
|
|
}
|
|
if len(out.Shares) == 0 {
|
|
fmt.Printf("no share links for %s (create one with `bdrive share <file>`)\n", root)
|
|
return nil
|
|
}
|
|
for _, s := range out.Shares {
|
|
line := fmt.Sprintf("%s %s", s.URL, s.Path)
|
|
if s.Creator != "" {
|
|
line += " (by " + s.Creator + ")"
|
|
}
|
|
if !s.Expires.IsZero() {
|
|
line += " expires " + s.Expires.Local().Format("2006-01-02 15:04")
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func revokeShare(settings config.Settings, tokenOrURL string) error {
|
|
token := tokenOrURL
|
|
if i := strings.LastIndex(tokenOrURL, "/s/"); i >= 0 {
|
|
token = strings.Trim(tokenOrURL[i+3:], "/")
|
|
}
|
|
_, proj, err := findProject(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server, _, err := splitHubRemote(proj.Remote)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := serverDo(http.MethodDelete, server+"/api/shares/"+url.PathEscape(token), settings.Token, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return httpBodyError(resp)
|
|
}
|
|
fmt.Println("revoked")
|
|
return nil
|
|
}
|
|
|
|
// findProject walks up from dir to the folder holding .bdrive/config.json.
|
|
func findProject(dir string) (string, config.Project, error) {
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return "", config.Project{}, err
|
|
}
|
|
for cur := abs; ; cur = filepath.Dir(cur) {
|
|
if proj, ok, err := config.LoadProject(cur); err != nil {
|
|
return "", proj, err
|
|
} else if ok {
|
|
// keep the registry pointing at the right place
|
|
config.ResolveMount(cur)
|
|
return cur, proj, nil
|
|
}
|
|
if filepath.Dir(cur) == cur {
|
|
return "", config.Project{}, fmt.Errorf("not inside a bdrive project (run `bdrive init` first)")
|
|
}
|
|
}
|
|
}
|
|
|
|
var hubRemoteRe = regexp.MustCompile(`^(https?://[^/]+)/p/(p-[0-9a-f]{8})$`)
|
|
|
|
// splitHubRemote splits an https://host/p/<id> remote into server + project.
|
|
func splitHubRemote(remote string) (string, string, error) {
|
|
m := hubRemoteRe.FindStringSubmatch(remote)
|
|
if m == nil {
|
|
return "", "", fmt.Errorf("this project does not sync through a bdrive server (remote %q); sharing needs a hub", remote)
|
|
}
|
|
return m[1], m[2], nil
|
|
}
|
|
|
|
func readBody(resp *http.Response) string {
|
|
data := make([]byte, 256)
|
|
n, _ := resp.Body.Read(data)
|
|
return string(data[:n])
|
|
}
|
|
|
|
func isPrivateHost(host string) bool {
|
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(host, "10.") || strings.HasPrefix(host, "192.168.") || strings.HasPrefix(host, "172.")
|
|
}
|