Skip to content

Commit 7b84a0c

Browse files
authored
Support uploading non-image files (PDF, zip, etc.) (#24)
* Support uploading non-image files (PDF, zip, etc.) GitHub's internal attachment endpoint already accepts non-image files; it routes them to a separate storage backend that finalizes at a different path. Use the policy response's asset_upload_url for the finalize PUT instead of the hardcoded /upload/assets/{id} (which 404'd "Invalid Asset" for files), and render a download link [name](url) for non-images vs the image embed ![name](url). - upload: finalize via policy.asset_upload_url (required + root-relative); add renderMarkdown branching on the image/ content-type prefix - generalize "image" -> "file" in CLI help, identifiers, and docs (the gh image command and gh-image repo name are unchanged) - tests: non-image full-flow finalize routing, renderMarkdown table, asset_upload_url validation, and .pdf content-type detection Closes #23 * Fix content_type for text uploads (charset param) and .log GitHub's /upload/policies/assets validates content_type against an allowlist and against the file extension, rejecting mismatches with 422. detectContentType sent Go's mime.TypeByExtension result verbatim, which broke two cases: - Text types (.txt, .md, .csv, .log) resolve to e.g. "text/plain; charset=utf-8"; GitHub's allowlist only accepts the bare media type, so every text upload failed. Strip parameters via mime.ParseMediaType. - .log resolves to text/plain, but GitHub's extension table expects text/x-log and 422s on ".log != text/plain". Add a small override map for extensions where GitHub disagrees with Go's mime table. Verified end-to-end against real GitHub: .log, .txt, .md, .csv, .json, .pdf, .zip, .mp4 all upload. Updated TestDetectContentType to assert parameter stripping and the .log override (incl. case-insensitivity). * Render videos as inline players via bare asset URL GitHub renders a user-attachments video asset as an inline <video> player when its URL appears on its own line; link or embed syntax only produces a link. renderMarkdown now emits the bare URL for video/* content types (images still embed, other files still link). Confirmed end-to-end: the rendered comment HTML for an uploaded .mp4 is a <video controls> element. Updates TestRenderMarkdown (video/mp4, video/quicktime) and the README/ SKILL output descriptions. * Render from GitHub's asset content_type, not the local guess The render form (image embed / video player / download link) was decided from the locally-detected content type threaded through Upload -> finalizeUpload -> renderMarkdown. The policy response already carries the content type GitHub assigned the asset, which is the exact signal GitHub's renderer uses, so finalizeUpload now reads policy.Asset.ContentType directly. This drops the threaded parameter and guarantees the output form matches how GitHub actually displays the asset (e.g. an SVG GitHub treats as a file renders as a link, not a broken embed). requestPolicy now validates asset.content_type is present, since the render decision depends on it. detectContentType is unchanged -- it still supplies the content_type the policy request and S3 upload must send. * Clarify finalize render-decision comment (asset content_type is our echo) * Update banner: 'Upload files to GitHub' (covers non-image uploads)
1 parent 7385c82 commit 7b84a0c

9 files changed

Lines changed: 317 additions & 122 deletions

File tree

README.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<p align="center">
2-
<img src="https://github.com/user-attachments/assets/92463e67-b897-4212-91b4-a4f9b80ec4d4" alt="gh-image banner" width="640">
2+
<img src="https://github.com/user-attachments/assets/d3455b90-f94f-4013-a00a-ebaff090635e" alt="gh-image banner" width="640">
33
</p>
44

55
<p align="center">
6-
<em>Drop images into GitHub issues, PRs, and READMEs, straight from the command line.</em>
6+
<em>Drop images and files into GitHub issues, PRs, and READMEs, straight from the command line.</em>
77
</p>
88

99
<p align="center">
@@ -17,11 +17,14 @@
1717

1818
---
1919

20-
GitHub has no public API for image uploads. The web UI uses an internal endpoint that produces `user-attachments` URLs whose visibility is scoped to the repository they were uploaded to. `gh-image` replicates that flow as a `gh` CLI extension, so you can drop a screenshot into a bug report, README, or Slack thread without leaving the terminaland images on private repos stay private.
20+
GitHub has no public API for the attachment uploads its web UI accepts via drag-and-drop. That internal endpoint produces `user-attachments` URLs whose visibility is scoped to the repository they were uploaded to. `gh-image` replicates that flow as a `gh` CLI extension, so you can drop a screenshot — or any GitHub-supported file like a PDF, zip, or log — into a bug report, README, or Slack thread without leaving the terminal, and uploads on private repos stay private. Images render as inline embeds, videos as inline players, and other files as download links.
2121

2222
```console
2323
$ gh image screenshot.png
2424
![screenshot.png](https://github.com/user-attachments/assets/88f4599a-…-bc24)
25+
26+
$ gh image report.pdf
27+
[report.pdf](https://github.com/user-attachments/files/123456/report.pdf)
2528
```
2629

2730
## Installation
@@ -52,21 +55,25 @@ Requires Go 1.26+.
5255
# Upload an image (infers repo from the current git workspace)
5356
gh image screenshot.png
5457

55-
# Upload multiple images at once
58+
# Upload multiple files at once (images or anything GitHub accepts)
5659
gh image hero.png diagram.png chart.png
5760

61+
# Upload any GitHub-supported file (PDF, zip, log, …) — renders as a download link
62+
gh image report.pdf
63+
5864
# Target a specific repository
5965
gh image screenshot.png --repo owner/repo
6066
```
6167

62-
Each successful upload prints a ready-to-paste markdown reference on its own line:
68+
Each successful upload prints a ready-to-paste reference on its own line — an inline embed for images, a bare URL for videos (which GitHub renders as an inline player), and a download link for other files:
6369

6470
```
6571
![hero.png](https://github.com/user-attachments/assets/…)
66-
![diagram.png](https://github.com/user-attachments/assets/…)
72+
https://github.com/user-attachments/assets/…
73+
[report.pdf](https://github.com/user-attachments/files/…/report.pdf)
6774
```
6875

69-
If any upload fails, the error is printed to stderr and the process exits non-zero — other images in the batch still upload.
76+
If any upload fails, the error is printed to stderr and the process exits non-zero — other files in the batch still upload.
7077

7178
### Pipe directly into an issue, PR, or comment
7279

@@ -84,7 +91,7 @@ Happens consistently after the third click."
8491

8592
## Use with AI agents
8693

87-
`gh-image` is packaged as an [agent skill](https://agentskills.io), so AI coding agents can upload and embed images for you — just ask in natural language, e.g. *"attach this screenshot to the PR"* or *"file an issue and add this image."*
94+
`gh-image` is packaged as an [agent skill](https://agentskills.io), so AI coding agents can upload and embed images or attach files for you — just ask in natural language, e.g. *"attach this screenshot to the PR"* or *"file an issue and attach this log."*
8895

8996
```bash
9097
npx skills add drogers0/gh-image
@@ -165,10 +172,10 @@ jobs:
165172
2. Fetches the target repository's page to obtain an `uploadToken` from the embedded JS payload.
166173
3. Requests an S3 upload policy from `/upload/policies/assets`.
167174
4. Uploads the file directly to S3 using the presigned form fields.
168-
5. Calls back to GitHub to finalize the asset.
169-
6. Prints `![name](url)` to stdout.
175+
5. Calls back to GitHub to finalize the asset, using the finalize endpoint GitHub returns in the policy (`/upload/assets/{id}` for images, `/upload/repository-files/{id}` for other files).
176+
6. Prints the reference to stdout: `![name](url)` for images, the bare URL for videos (GitHub renders it as an inline player), or `[name](url)` for other files.
170177

171-
The final URL is the standard `https://github.com/user-attachments/assets/<uuid>` format — visibility inherits from the target repository, so a private-repo upload requires authentication to view.
178+
The final URL is `https://github.com/user-attachments/assets/<uuid>` for images and `https://github.com/user-attachments/files/<id>/<name>` for other files — visibility inherits from the target repository, so a private-repo upload requires authentication to view.
172179

173180
For the full architecture, see **[documentation/architecture.md](documentation/architecture.md)**. For the reverse-engineered upload protocol, see **[documentation/github-image-upload-flow.md](documentation/github-image-upload-flow.md)**.
174181

banner.png

14.1 KB
Loading

documentation/architecture.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Overview
44

5-
`gh-image` is a Go CLI tool distributed as a `gh` extension. It uploads images to GitHub using the same internal API that the web UI uses when you drag-and-drop or paste an image. 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 image reference to stdout.
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).
66

77
## Project Structure
88

@@ -43,12 +43,12 @@ gh-image/
4343
## CLI Surface
4444

4545
```
46-
gh image [--repo owner/repo] [--token <value>] <image-path>...
46+
gh image [--repo owner/repo] [--token <value>] <file-path>...
4747
gh image extract-token
4848
gh image check-token [--token <value>]
4949
```
5050

51-
- **Default mode** uploads one or more image files and prints markdown references to stdout. Flags may appear before or after positional args; use `--` to pass filenames that begin with `-`.
51+
- **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 `-`.
5252
- **`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.
5353
- **`check-token`** resolves a token using the standard precedence (flag → env → browser) and verifies it against GitHub, printing the authenticated username on success.
5454

@@ -148,9 +148,9 @@ HTTP client plus a `baseURL` (production `https://github.com`); tests point
148148
// production base URL.
149149
func NewClient(sessionCookie *http.Cookie) *Client
150150

151-
// Upload uploads an image file to GitHub and returns the asset URL,
151+
// Upload uploads a file to GitHub and returns the asset URL,
152152
// sanitized filename, and a ready-to-paste markdown reference.
153-
func (c *Client) Upload(owner, repo string, repoID int, imagePath string) (*Result, error)
153+
func (c *Client) Upload(owner, repo string, repoID int, path string) (*Result, error)
154154
```
155155

156156
#### Token Retrieval (`token.go`)
@@ -165,7 +165,7 @@ func (c *Client) getUploadToken(owner, repo string) (string, error)
165165

166166
#### Upload Orchestration (`upload.go`)
167167

168-
Coordinates the full flow for a single image:
168+
Coordinates the full flow for a single file:
169169

170170
```
171171
Get upload token
@@ -179,7 +179,9 @@ uploadToS3() ──→ POST {s3_upload_url}
179179
Multipart form with presigned fields + file
180180
No GitHub auth needed
181181
182-
finalizeUpload() ──→ PUT /upload/assets/{asset_id}
182+
finalizeUpload() ──→ PUT {asset_upload_url}
183+
Path from policy: /upload/assets/{id} (images)
184+
│ or /upload/repository-files/{id} (other files)
183185
Uses asset_upload_authenticity_token from step 1
184186
185187
Returns asset href URL
@@ -210,7 +212,7 @@ Responsibilities:
210212
- **Manual arg parsing** so that flags can appear before or after positional args, with `--` as an explicit terminator for filenames starting with a dash.
211213
- **Subcommand dispatch** for `extract-token` and `check-token`, with validation that disallowed flag combinations are rejected before any work is done.
212214
- **Session resolution** via `resolveSessionCookie`, which applies the flag → env → browser precedence and wraps raw token values into a properly scoped `*http.Cookie`.
213-
- **Multi-image upload loop**: each positional path is uploaded independently. A failure on one image is reported to stderr and the loop continues; the process exits non-zero if any upload failed.
215+
- **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.
214216

215217
## Data Flow
216218

@@ -221,12 +223,12 @@ flowchart TD
221223
222224
Start --> Session
223225
224-
subgraph PerImage ["For each image"]
226+
subgraph PerImage ["For each file"]
225227
direction TB
226228
Token["<b>Fetch uploadToken</b><br/>GET /:owner/:repo"]
227229
Policy["<b>Request Policy</b><br/>POST /upload/policies/assets"]
228230
S3["<b>Upload to S3</b><br/>POST policy.upload_url<br/><i>(no GitHub auth)</i>"]
229-
Finalize["<b>Finalize</b><br/>PUT /upload/assets/:id"]
231+
Finalize["<b>Finalize</b><br/>PUT {asset_upload_url}"]
230232
Print["<b>Print markdown</b><br/>to stdout"]
231233
232234
Token -- "uploadToken" --> Policy
@@ -296,4 +298,4 @@ git push --tags
296298
## Future Considerations
297299

298300
- **Clipboard image support:** Accept image data from clipboard (`gh image paste --repo o/r`) instead of requiring a file path.
299-
- **Token caching:** The `uploadToken` could be cached briefly to avoid fetching the repo page on every upload within a multi-image batch. The presigned S3 policy expires in ~30 minutes, so reuse is safe within that window.
301+
- **Token caching:** The `uploadToken` could be cached briefly to avoid fetching the repo page on every upload within a multi-file batch. The presigned S3 policy expires in ~30 minutes, so reuse is safe within that window.

documentation/github-image-upload-flow.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
## Overview
44

5-
GitHub does not provide a public API for uploading images to issues/PRs. The web UI uses an internal 3-step flow involving GitHub's servers and S3. This document describes exactly how that flow works, reverse-engineered from HAR captures.
5+
GitHub does not provide a public API for uploading attachments (images or files like PDFs and zips) to issues/PRs. The web UI uses an internal 3-step flow involving GitHub's servers and S3. This document describes exactly how that flow works, reverse-engineered from HAR captures. The flow is identical for images and other files; only the finalize path and the resulting URL/markdown differ (noted in Step 3 and Final Result).
66

7-
Images uploaded this way are scoped to the repository's visibility — private repo images require authentication to view (unlike GitHub Release assets, which are always public on public repos).
7+
Attachments uploaded this way are scoped to the repository's visibility — private repo uploads require authentication to view (unlike GitHub Release assets, which are always public on public repos).
88

99
## Prerequisites
1010

@@ -92,7 +92,7 @@ This token serves as the `authenticity_token` for the upload policy request (Ste
9292
```
9393

9494
**Key fields in response:**
95-
- `asset.href` — The final URL where the image will be served
95+
- `asset.href` — The final URL where the attachment will be served
9696
- `asset.id` — Used in the finalize step
9797
- `form` — All fields needed for the S3 upload (presigned)
9898
- `upload_url` — The S3 endpoint to POST to
@@ -131,9 +131,12 @@ This token serves as the `authenticity_token` for the upload policy request (Ste
131131

132132
### Step 3: Finalize the Upload
133133

134-
**Request:** `PUT https://github.com/upload/assets/{asset_id}`
134+
**Request:** `PUT https://github.com{asset_upload_url}`
135135

136-
Where `{asset_id}` is `asset.id` from the Step 1 response.
136+
Where `asset_upload_url` is taken verbatim from the Step 1 response. GitHub routes the
137+
finalize to a different path per file type: `/upload/assets/{id}` for images and
138+
`/upload/repository-files/{id}` for other files (PDF, zip, …). Use the server-provided
139+
path rather than hardcoding one.
137140

138141
**Content-Type:** `multipart/form-data`
139142

@@ -163,14 +166,17 @@ Where `{asset_id}` is `asset.id` from the Step 1 response.
163166

164167
## Final Result
165168

166-
The `href` value is the permanent image URL:
169+
The `href` value is the permanent attachment URL. Its shape depends on the file type:
167170
```
168-
https://github.com/user-attachments/assets/{uuid}
171+
https://github.com/user-attachments/assets/{uuid} # images
172+
https://github.com/user-attachments/files/{id}/{name} # other files (PDF, zip, …)
169173
```
170174

171-
This can be embedded in any GitHub markdown (PR descriptions, issue bodies, comments) as:
175+
It can be referenced in any GitHub markdown (PR descriptions, issue bodies, comments). Images
176+
embed inline; other files render as a download link:
172177
```markdown
173178
![alt text](https://github.com/user-attachments/assets/{uuid})
179+
[report.pdf](https://github.com/user-attachments/files/{id}/report.pdf)
174180
```
175181

176182
## Authentication Summary
@@ -197,7 +203,7 @@ POST /upload/policies/assets (authenticity_token = uploadToken)
197203
└─> asset_upload_authenticity_token (in JSON response)
198204
199205
200-
PUT /upload/assets/{id} (authenticity_token = asset_upload_authenticity_token)
206+
PUT {asset_upload_url} (authenticity_token = asset_upload_authenticity_token)
201207
```
202208

203209
Each step produces the token needed for the next GitHub-authenticated step. The S3 upload (Step 2) uses a self-contained presigned policy and needs no GitHub tokens.

0 commit comments

Comments
 (0)