diff --git a/README.md b/README.md index c56471b..c0892b5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Subcommands: | `pin login [--device]` | Sign in. `--device` for SSH / headless boxes. | | `pin share ` | Upload an HTML or MDX file. Prints the share URL. | | `pin get ` | Fetch a share's MDX source to stdout. `--html` for the rendered form. | +| `pin publish ` | Make a share publicly viewable (no login) via a capability link. `--ttl ` sets the lifetime (default 7d, max 30d). Prints the public URL. | +| `pin unpublish ` | Revoke a public link before it expires. | | `pin components` | List the MDX components available in pin, grouped by category. | | `pin components get ` | Show one component's props + example. | | `pin components dump` | Print every component's full detail. | @@ -40,6 +42,18 @@ Environment variables: Credentials are stored in your OS keychain (macOS Keychain / Windows Credential Manager / libsecret on Linux) with a 0600 file fallback at `~/.config/pin/credentials.json` for headless boxes. +### Public sharing + +Shares are private by default — only `@bitcomplete.io` accounts can open `https://pin.bitcomplete.dev/p/{id}`. To share a specific artifact with someone outside the org, `pin publish` mints a login-free capability link: + +```sh +pin publish 01HX... # → https://pin.bitcomplete.dev/public/p/01HX...?token=... +pin publish 01HX... --ttl 24h # default 7 days, max 30 +pin unpublish # kill the link early +``` + +This is a deliberate, separate step — you can't publish by accident. The link works for anyone who has it (no login) until it expires or you revoke it; anyone without the exact token gets a plain `404`. + ## How auth works `pin login` does an OAuth 2.1 PKCE loopback flow against the pin server (which in turn handles Google SSO for the human). `pin login --device` falls back to the RFC 8628 device-code flow when no local browser is available. diff --git a/main.go b/main.go index dfcfa4b..d99c5dc 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,10 @@ func main() { os.Exit(runShare(os.Args[2:])) case "get": os.Exit(runGet(os.Args[2:])) + case "publish": + os.Exit(runPublish(os.Args[2:])) + case "unpublish": + os.Exit(runUnpublish(os.Args[2:])) case "components": os.Exit(runComponents(os.Args[2:])) case "whoami": @@ -82,6 +86,10 @@ Usage: pin share Upload an HTML or MDX file. Prints the share URL. pin get Fetch a share's MDX source to stdout. --html for the rendered HTML form. + pin publish Make a share publicly viewable (no login) via a + capability link. --ttl sets the lifetime + (default 7d, max 30d). Prints the public URL. + pin unpublish Revoke a public link before it expires. pin components List MDX components, grouped by category. pin components get Show one component's props + example. pin components dump Print every component's full detail. @@ -545,6 +553,171 @@ func runGet(args []string) int { return 0 } +// ----- publish / unpublish ----- + +// runPublish flips an existing share public by minting a capability +// token, then prints a login-free URL. This is the deliberate "safety +// off" action — invoking it is the conscious step; the server still +// requires the explicit confirm sentinel we send below. +func runPublish(args []string) int { + var idArg, ttl string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--ttl": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "pin publish: --ttl needs a value, e.g. --ttl 24h") + return 2 + } + i++ + ttl = args[i] + case strings.HasPrefix(a, "--ttl="): + ttl = strings.TrimPrefix(a, "--ttl=") + case strings.HasPrefix(a, "-"): + fmt.Fprintf(os.Stderr, "pin publish: unknown flag %q\n", a) + return 2 + case idArg == "": + idArg = a + default: + fmt.Fprintln(os.Stderr, "pin publish: too many arguments") + return 2 + } + } + if idArg == "" { + fmt.Fprintln(os.Stderr, "usage: pin publish [--ttl 7d]") + return 2 + } + + id, hostFromURL := parseShareRef(idArg) + if id == "" { + fmt.Fprintf(os.Stderr, "pin publish: not a share id or URL: %q\n", idArg) + return 2 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + c, err := loadCreds() + if err != nil { + fmt.Fprintf(os.Stderr, "pin publish: not logged in. Run `pin login`.\n") + return 1 + } + c, err = ensureFreshAccess(ctx, c) + if err != nil { + fmt.Fprintf(os.Stderr, "pin publish: refresh: %v\n", err) + return 1 + } + + base := hostFromURL + if base == "" { + base = host() + } + reqBody := map[string]string{"confirm": "publish-public"} + if ttl != "" { + reqBody["ttl"] = ttl + } + body, _ := json.Marshal(reqBody) + + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/pins/"+id+"/public", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AccessToken) + req.Header.Set("X-Agent", agent()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "pin publish: %v\n", err) + return 1 + } + defer resp.Body.Close() + rbody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + fmt.Fprintf(os.Stderr, "pin publish: http %d: %s\n", resp.StatusCode, strings.TrimSpace(string(rbody))) + return 1 + } + var out struct { + PublicURL string `json:"public_url"` + ExpiresAt string `json:"expires_at"` + TTLDays int `json:"ttl_days"` + } + if err := json.Unmarshal(rbody, &out); err != nil { + fmt.Fprintf(os.Stderr, "pin publish: parse: %v\nbody: %s\n", err, rbody) + return 1 + } + // Warn loudly on stderr; the URL itself stays the only thing on stdout + // so it's safe to pipe/capture. + fmt.Fprintf(os.Stderr, "⚠ public — anyone with this link can view it, no login. Expires %s (%dd). Revoke with `pin unpublish`.\n", out.ExpiresAt, out.TTLDays) + fmt.Println(out.PublicURL) + return 0 +} + +// runUnpublish revokes a public link. Accepts the raw token or the full +// public URL (from which it lifts the ?token= value). +func runUnpublish(args []string) int { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: pin unpublish ") + return 2 + } + token, hostFromURL := parsePublicRef(args[0]) + if token == "" { + fmt.Fprintf(os.Stderr, "pin unpublish: no token in %q\n", args[0]) + return 2 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + c, err := loadCreds() + if err != nil { + fmt.Fprintf(os.Stderr, "pin unpublish: not logged in. Run `pin login`.\n") + return 1 + } + c, err = ensureFreshAccess(ctx, c) + if err != nil { + fmt.Fprintf(os.Stderr, "pin unpublish: refresh: %v\n", err) + return 1 + } + + base := hostFromURL + if base == "" { + base = host() + } + body, _ := json.Marshal(map[string]string{"token": token}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/pins/public/revoke", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AccessToken) + req.Header.Set("X-Agent", agent()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "pin unpublish: %v\n", err) + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + rbody, _ := io.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "pin unpublish: http %d: %s\n", resp.StatusCode, strings.TrimSpace(string(rbody))) + return 1 + } + fmt.Fprintln(os.Stderr, "revoked — the public link no longer works") + return 0 +} + +// parsePublicRef extracts the capability token from either a bare token +// or a full public URL like +// "https://pin.bitcomplete.dev/public/p/{id}?token=…". Returns (token, +// host) where host is "" unless a URL was passed. +func parsePublicRef(ref string) (token, hostFromURL string) { + ref = strings.TrimSpace(ref) + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { + u, err := url.Parse(ref) + if err != nil || u.Host == "" { + return "", "" + } + return u.Query().Get("token"), u.Scheme + "://" + u.Host + } + return ref, "" +} + // parseShareRef accepts a bare ULID, a "p/{id}" path, or a full URL // like "https://pin.bitcomplete.dev/p/{id}". Returns (id, host) where // host is "" unless a URL was passed (in which case it overrides diff --git a/main_test.go b/main_test.go index 07d613d..730d6f7 100644 --- a/main_test.go +++ b/main_test.go @@ -19,7 +19,7 @@ func TestParseShareRef(t *testing.T) { // Garbage paths return empty id. {"", "", ""}, {"https://pin.bitcomplete.dev/", "", ""}, - {"01HX YZ", "", ""}, // space in id + {"01HX YZ", "", ""}, // space in id {"foo/bar/01HXYZ", "", ""}, } for _, tc := range cases { @@ -30,3 +30,27 @@ func TestParseShareRef(t *testing.T) { } } } + +func TestParsePublicRef(t *testing.T) { + cases := []struct { + in string + wantToken string + wantHost string + }{ + // A bare token passes through, no host. + {"abc123_tok", "abc123_tok", ""}, + {" abc123_tok ", "abc123_tok", ""}, + // Full public URL → token lifted from the query, host preserved. + {"https://pin.bitcomplete.dev/public/p/01HXYZ?token=abc123", "abc123", "https://pin.bitcomplete.dev"}, + {"http://localhost:8080/public/p/01HXYZ?token=xy_z-9", "xy_z-9", "http://localhost:8080"}, + // URL with no token → empty token (caller errors out). + {"https://pin.bitcomplete.dev/public/p/01HXYZ", "", "https://pin.bitcomplete.dev"}, + } + for _, tc := range cases { + gotTok, gotHost := parsePublicRef(tc.in) + if gotTok != tc.wantToken || gotHost != tc.wantHost { + t.Errorf("parsePublicRef(%q) = (%q, %q), want (%q, %q)", + tc.in, gotTok, gotHost, tc.wantToken, tc.wantHost) + } + } +}