mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
225 lines
6.7 KiB
Go
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.")
|
|
}
|