You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: add download subcommand for user-attachments URLs (#42)
Adds `gh image download` to fetch attachments back out of GitHub, closing the
half of the attachment problem the tool did not cover.
An attachment URL answers with a 302 to a presigned storage URL, so the flow has
two legs with opposite credential requirements: the first needs a credential,
the second must carry none at all — an Authorization header on the S3 bucket is
rejected with a 400, and that failure is invisible on the other storage host.
Neither client follows redirects; the redirect is classified explicitly so a
login interstitial or error page can never be written to disk as a plausible
attachment.
The resolve leg takes the same two routes as upload: the gh CLI's bearer token
first, the browser session as fallback, so a run that stays on the fast path
never touches the cookie store. The routing is simpler than upload's — the
bearer upload endpoint accepts only a narrow set of content types, while one
credential reaches every attachment on the way back out, so a single rejection
turns the fast route off for the rest of the run rather than being remembered
per content type. A 404 is what triggers the fallback, since GitHub answers the
same way for an absent asset and for one the credential cannot read; any other
status is surfaced as-is, because it says nothing about the credential.
Output follows curl's conventions:
gh image download <url>... derived names in the cwd
gh image download --output-dir <dir> ... derived names in a directory
gh image download --output <file> <url> an exact path
gh image download --output - <url> stream to stdout
Existing files are overwritten, as curl -O does; --no-clobber suffixes .1, .2
instead. Filenames come from the URL rather than any response header: /files/
URLs carry their name and GitHub validates it, while /assets/ URLs carry only a
uuid, so the extension comes from the presigned path. The destination is opened
only after the fetch returns 200, so a failed request leaves no 0-byte file, and
a partial write is removed rather than left behind.
Protocol notes are in documentation/github-attachment-download-flow.md.
# Or send a single attachment somewhere specific — `-` for stdout
98
+
gh image download <url> --output <file>
99
+
```
100
+
101
+
Existing files are overwritten unless `--no-clobber` is passed, which writes `name.1`, `name.2` instead. As with upload, a failed URL is reported to stderr and the process exits non-zero — the rest of the batch still downloads.
102
+
91
103
### Pipe directly into an issue, PR, or comment
92
104
93
105
From inside the repo's working directory, both `gh image` and `gh issue create` infer the target repository automatically:
@@ -151,7 +163,7 @@ A <code>demo-videos</code> skill publishes the <b>README demo reels</b> —
151
163
152
164
## Authentication
153
165
154
-
`gh-image` authenticates with credentials you already have — **nothing to provision, no OAuth scopes to configure**. Images and video going to a repository you can push to are uploaded with your `gh` CLI token; everything else — other file types, and repositories you cannot push to — falls back to your existing GitHub session, read as the `user_session` cookie from your browser's encrypted cookie store.
166
+
`gh-image` authenticates with credentials you already have — **nothing to provision, no OAuth scopes to configure**. Images and video going to a repository you can push to are uploaded with your `gh` CLI token; everything else — other file types, and repositories you cannot push to — falls back to your existing GitHub session, read as the `user_session` cookie from your browser's encrypted cookie store. Downloads take the same two routes: the `gh` token first, your browser session as fallback.
Copy file name to clipboardExpand all lines: SECURITY.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,7 +2,7 @@
2
2
3
3
## What this tool handles
4
4
5
-
`gh-image` reads your GitHub `user_session` cookie from your browser's encrypted cookie store (or from an explicit token source) and uses it to authenticate against GitHub's internal image upload API.
5
+
`gh-image` reads your GitHub `user_session` cookie from your browser's encrypted cookie store (or from an explicit token source) and uses it to authenticate against GitHub's internal attachment APIs.
6
6
7
7
The cookie grants **full account access** — equivalent to your GitHub password, and not scoped like a personal access token. See the [Authentication](README.md#authentication) section of the README for full details on how the cookie is sourced and used.
Copy file name to clipboardExpand all lines: documentation/architecture.md
+41-5Lines changed: 41 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,7 +2,7 @@
2
2
3
3
## Overview
4
4
5
-
`gh-image` is a Go CLI tool distributed as a `gh` extension. It uploads files — images and other GitHub-supported attachments like PDFs and zips — to GitHub using the same internal API that the web UI uses when you drag-and-drop or paste an attachment. The tool resolves a GitHub session (from a flag, env var, or browser cookie store), negotiates upload tokens, performs an S3 presigned upload, then prints the resulting markdown reference to stdout (an image embed for images, a download link for other files).
5
+
`gh-image` is a Go CLI tool distributed as a `gh` extension. It uploads and downloads files — images and other GitHub-supported attachments like PDFs and zips — to GitHub using the same internal API that the web UI uses when you drag-and-drop or paste an attachment. The tool resolves a GitHub session (from a flag, env var, or browser cookie store), negotiates upload tokens, performs an S3 presigned upload, then prints the resulting markdown reference to stdout (an image embed for images, a download link for other files).
-**Default mode** uploads one or more files and prints markdown references to stdout. Flags may appear before or after positional args; use `--` to pass filenames that begin with `-`.
57
+
-**`download`** fetches one or more `user-attachments` URLs. With no output flag each lands in the current directory under a name derived from the URL; `--output-dir` picks a different directory, `--output <file>` an exact path, and `--output -` streams to stdout. Existing files are overwritten unless `--no-clobber` is given, which suffixes `.1`, `.2` instead.
52
58
-**`extract-token`** reads the session cookie from the browser and prints the raw token value to stdout (status info to stderr). Useful for piping into CI secrets.
53
59
-**`check-token`** resolves a token using the standard precedence (flag → env → browser) and verifies it against GitHub, printing the authenticated username on success.
54
60
@@ -200,7 +206,31 @@ finalizeUpload() ──→ PUT {asset_upload_url}
200
206
201
207
Handles the multipart form construction for the S3 presigned upload. Separated from the main orchestration because the S3 request has different requirements (no cookies, no GitHub headers, just the presigned form fields and file data).
202
208
203
-
### 7. CLI Entrypoint (`main.go`)
209
+
### 7. Download Flow (`internal/download/`)
210
+
211
+
Implements the download protocol documented in [github-attachment-download-flow.md](github-attachment-download-flow.md). A `GET` on the attachment URL answers with a `302` to a presigned storage URL, so there are two legs with **opposite** credential requirements: the first needs the session cookie pair, the second must carry none at all — an `Authorization` header on the S3 bucket is rejected with a 400, and the presigned URL is its own capability.
212
+
213
+
```go
214
+
// NewClient builds both HTTP clients from the session cookie.
215
+
funcNewClient(sessionCookie *http.Cookie) *Client
216
+
217
+
// Save resolves ref and writes it, returning the path written.
218
+
func (c *Client) Save(ref Ref, dest Dest) (string, error)
219
+
220
+
// Stream resolves ref and writes its bytes to w (the --output - path).
221
+
func (c *Client) Stream(ref Ref, w io.Writer) (int64, error)
222
+
```
223
+
224
+
**Key implementation details:**
225
+
226
+
- **Two credential routes, bearer first.** The `gh` token is tried before the browser session, so a run that stays on the fast path never touches the cookie store and never prompts for it. A `404` is what triggers the fallback, and it is deliberately ambiguous — GitHub answers the same way for an absent asset and for one the credential cannot read — so the session is tried once before the run reports failure. Any other status is surfaced as-is rather than retried, since it says nothing about the credential. An explicit `--token` or `GH_SESSION_TOKEN` pins the run to the session route.
227
+
- **Neither client follows redirects.** The resolve leg classifies its own redirect; the fetch leg must not hop onward past that classification.
228
+
- **A `302` is classified in a fixed order:** a `/login` target *on github.com* means the session is stale; a target carrying `X-Amz-Signature` is the asset; anything else is a hard error. The order matters — checking the signature first would report an expired session as an unusable-target error. The host check matters too: matching `/login` by path alone would let an unrelated host claim the credential is stale.
229
+
- **The destination is opened only after the fetch returns 200**, so a failed request leaves no 0-byte file. A partial write is removed rather than left in place.
230
+
- **Filenames come from the URL, never from a response header.** `/files/<id>/<name>` carries its name and GitHub validates it; `/assets/<uuid>` carries none, so the extension is taken from the presigned path. `filepath.Base` at the join keeps a traversal-shaped name inside the destination.
231
+
- **Timeouts mirror the upload side:** 30s for the header-only resolve leg, 120s for the transfer, matching `s3.go`.
232
+
233
+
### 8. CLI Entrypoint (`main.go`)
204
234
205
235
`main()` is a one-line entrypoint that delegates to a testable
206
236
`run(args []string, stdout, stderr io.Writer, deps) int`: it returns an exit code
@@ -215,6 +245,7 @@ Responsibilities:
215
245
- **Subcommand dispatch** for `extract-token` and `check-token`, with validation that disallowed flag combinations are rejected before any work is done.
216
246
- **Session resolution** via `resolveSessionCookie`, which applies the flag → env → browser precedence and wraps raw token values into a properly scoped `*http.Cookie`.
217
247
- **Multi-file upload loop**: each positional path is uploaded independently. A failure on one file is reported to stderr and the loop continues; the process exits non-zero if any upload failed.
248
+
- **Download loop**: same contract for URLs. All flag validation happens before any request, so a bad combination fails immediately. `--output -` streams to `run`'s injected stdout writer rather than `os.Stdout`, keeping the path testable.
| `check-token` validation | Same `user_session` pair | Same precedence as upload |
291
+
| Download resolve leg (fast) | `Authorization: Bearer` | `gh auth token`, skipped when a session token is named |
292
+
| Download resolve leg (fallback) | `user_session` + `__Host-user_session_same_site` cookies | `--token` flag, `GH_SESSION_TOKEN`, or browser cookie DB |
293
+
| Download fetch leg | **None** | Presigned URL from the resolve leg |
294
+
295
+
Download uses the same two routes as upload, for the same reason: the `gh` token avoids the browser entirely when it works. The routing is simpler here, though. Upload has to remember rejections *per content type*, because the bearer upload endpoint accepts a narrow set; download has no such split — one credential reaches every attachment — so a single rejection turns the fast route off for the rest of the run.
260
296
261
-
The session-cookie path and the `gh` CLI auth path are independent. The cookie provides a browser-equivalent session for the undocumented upload API, while `gh` handles the standard REST API used only for looking up the numeric repository ID.
297
+
The session-cookie path and the `gh` CLI auth path are independent. The cookie provides a browser-equivalent session for the undocumented attachment APIs, while `gh` handles the standard REST API used only for looking up the numeric repository ID.
0 commit comments