Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Subcommands:
| `pin login [--device]` | Sign in. `--device` for SSH / headless boxes. |
| `pin share <file>` | Upload an HTML or MDX file. Prints the share URL. |
| `pin get <id-or-url>` | Fetch a share's MDX source to stdout. `--html` for the rendered form. |
| `pin publish <id-or-url>` | Make a share publicly viewable (no login) via a capability link. `--ttl <dur>` sets the lifetime (default 7d, max 30d). Prints the public URL. |
| `pin unpublish <token-or-url>` | Revoke a public link before it expires. |
| `pin components` | List the MDX components available in pin, grouped by category. |
| `pin components get <Name>` | Show one component's props + example. |
| `pin components dump` | Print every component's full detail. |
Expand All @@ -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 <token-or-url> # 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.
Expand Down
173 changes: 173 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -82,6 +86,10 @@ Usage:
pin share <file> Upload an HTML or MDX file. Prints the share URL.
pin get <id-or-url> Fetch a share's MDX source to stdout. --html for
the rendered HTML form.
pin publish <id-or-url> Make a share publicly viewable (no login) via a
capability link. --ttl <dur> sets the lifetime
(default 7d, max 30d). Prints the public URL.
pin unpublish <token|url> Revoke a public link before it expires.
pin components List MDX components, grouped by category.
pin components get <Name> Show one component's props + example.
pin components dump Print every component's full detail.
Expand Down Expand Up @@ -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 <id-or-url> [--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 <token-or-url>")
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
Expand Down
26 changes: 25 additions & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
Loading