Files
beardrive/cmd/bdrive/share.go
T
fd4f5c7964 feat(onboarding): folder name follows the project name, and project ids are UUIDs (#89)
The paste prompt now carries the project's name so an agent recommends a
folder of that name; with no project at all the recommendation is `shared/`
(and `bdrive init shared` names the new project after the folder), replacing
the old `wiki/` default.

New project ids are UUIDs instead of `p-` + 8 hex chars. The route validator
still accepts the legacy shape — ids are permanent — and the client-side URL
parsers (remote/http.go, bdrive share) now only check the shape of a URL
segment, leaving the hub as the single authority on which ids are valid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:42:07 +09:00

225 lines
6.7 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)")
}
}
}
// Same loose id shape as remote/http.go: UUID today, legacy `p-xxxxxxxx` on
// older hubs — the hub validates, this only splits the URL.
var hubRemoteRe = regexp.MustCompile(`^(https?://[^/]+)/p/([A-Za-z0-9._-]{4,64})$`)
// 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.")
}