DEV Community

Sarvar Nadaf
Sarvar Nadaf

Posted on

My Dev.to CLI Got Its First Community PR. Image Uploads From Terminal.

Solved via cookie and CSRF reverse engineering

Three weeks after launching devpub, I got a notification I wasn't expecting: a pull request from someone I'd never talked to.

Harish / @harishteens had forked the repo, read the issues, picked one that I'd been putting off for weeks, and built a complete solution. Tests included. Design decisions documented. Edge cases handled.

The feature? devpub upload. The one command that should have existed from day one but couldn't, because the Forem API literally doesn't support it.

pip install devpub==0.3.0
devpub upload cover.png
Enter fullscreen mode Exit fullscreen mode

That one command now gives you this:

  Uploaded: cover.png

              Uploaded Images
┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ File         ┃ URL                                       ┃
┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ cover.png    │ https://dev-to-uploads.s3.amazonaws.com/… │
└──────────────┴──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

No more opening dev.to/new in the browser just to drag an image and copy the URL.


Table of Contents


The problem: no image endpoint in the API

When I launched devpub in v0.1, the goal was to replace the Dev.to web editor entirely. Write locally, push with one command, track analytics. Done.

But there was a gap. Every time I wrote an article with diagrams or a cover image, I had to:

  1. Open dev.to/new in the browser
  2. Click the image upload button
  3. Select the file
  4. Wait for upload
  5. Copy the URL
  6. Paste it into my local markdown
  7. Close the tab

Seven steps for something that should be devpub upload figure.png.

The reason I hadn't built this: the Forem API V1 has no image upload endpoint. It doesn't exist. You can set cover_image in article frontmatter, but only to a URL that already exists somewhere. The API cannot create that URL.

I filed issue #11 with a note saying "this requires reverse-engineering the web editor's upload mechanism" and moved on to other features.

Harish didn't move on. He figured it out.


How devpub upload works

The Dev.to web editor uploads images to POST /image_uploads. It's a multipart form submission, same as any file upload. But it's not authenticated with your API key. It uses your browser session.

# Upload a single image
devpub upload cover.png

# Upload multiple images
devpub upload *.png

# Get ready-to-paste Markdown
devpub upload architecture.png --markdown
# Output: ![architecture](https://dev-to-uploads.s3.amazonaws.com/uploads/...)
Enter fullscreen mode Exit fullscreen mode

The --markdown flag is the one I use most. Write your article with ![](./figures/diagram.png), then run devpub upload figures/*.png --markdown and paste the output directly over your local references.


The authentication problem

Here's why this feature sat undone for three weeks. The /image_uploads endpoint requires:

  1. A _Devto_Forem_Session cookie (your login session)
  2. A CSRF token (anti-forgery protection)
  3. An Origin header matching https://dev.to

Your API key? Useless. This endpoint doesn't accept it.

So devpub needs two extra credentials beyond your API key. They live in .devpub/.env (which devpub init gitignores by default):

DEVPUB_SESSION_COOKIE=_Devto_Forem_Session=abc123...
DEVPUB_CSRF_TOKEN=a1b2c3d4...
Enter fullscreen mode Exit fullscreen mode

If you run devpub upload without setting these, it doesn't just fail with a cryptic error. It shows you exactly where to find them:

╭─ Session Credentials Required ─────────────────────────────╮
│                                                             │
│  devpub upload needs your browser session (not API key).    │
│                                                             │
│  1. Open https://dev.to/new in Chrome                       │
│  2. Press F12 → Application → Cookies → dev.to             │
│  3. Copy _Devto_Forem_Session value                         │
│  4. View page source → find <meta name="csrf-token">       │
│  5. Add both to .devpub/.env                                │
│                                                             │
╰─────────────────────────────────────────────────────────────╯
Enter fullscreen mode Exit fullscreen mode

One deliberate design choice: these are login credentials, not a scoped token. The .env file is gitignored, and when they expire (you'll get a 401 or 403), you just re-copy them. devpub tells you that upfront rather than leaving you to guess why uploads suddenly stopped working.


What Harish built

Harish's PR wasn't a quick hack. It was a proper module with clear boundaries:

File Role
src/devpub/api/uploads.py ImageUploader class with all HTTP logic
src/devpub/cli/images.py Click command + Rich table output
tests/test_uploads.py 29 tests, all HTTP-mocked with respx

Three things stood out in his implementation:

1. Fail before the network. File existence, extension validation, and size check (25 MB limit) all happen before any HTTP request fires. A typo in a filename costs zero network round-trips.

2. Flexible response parsing. Dev.to's upload response isn't documented, so the _extract_url method handles every response shape that's been observed: links.url, image.url, images[0], a raw string. If none match, it raises with the full response body included. A wrong URL silently landing in your article would be worse than a loud error.

3. Cookie flexibility. You can paste the full cookie header (a=1; b=2; _Devto_Forem_Session=xyz) or just the session value. Both work. Because copying one value out of DevTools is easy to get wrong.

The PR description was thorough. Design rationale for keeping ImageUploader separate from DevtoClient (different auth models shouldn't share a class). Explicit call-out that tests pin the request shape, not the live response. A suggestion to smoke-test against a real session before release.

This is what good open source contributions look like. Not just code that works, but code that explains why it works that way.


Other changes in v0.3.0

Beyond the upload feature:

  • Fixed hardcoded User-Agent: Was stuck at devpub/0.1.0. Now reads the actual version from importlib.metadata.
  • Fixed empty API key edge case: DevtoClient(api_key='') used to fall through to environment variables silently. Now it raises.
  • Repo cleanup: Removed personal draft articles from tracking, updated .gitignore for research docs and recordings.

Try it yourself

pip install devpub==0.3.0
Enter fullscreen mode Exit fullscreen mode

For uploads, you need the session credentials (one-time setup):

# Add to .devpub/.env
DEVPUB_SESSION_COOKIE=your_session_cookie_here
DEVPUB_CSRF_TOKEN=your_csrf_token_here
Enter fullscreen mode Exit fullscreen mode

Then:

devpub upload cover.png              # Upload and get URL
devpub upload *.png --markdown       # Ready-to-paste Markdown tags
devpub upload diagram.png --dry-run  # Validate without uploading
Enter fullscreen mode Exit fullscreen mode

Full source: github.com/simplynadaf/devpub


What's next

Two follow-ups that Harish explicitly scoped out of his PR (smart -- ship the primitive first, then build on it):

  1. Auto-rewrite during push: devpub push could detect local image paths like ![](./figures/arch.png), upload them automatically, and rewrite the URLs in-place before publishing.

  2. Cover image shortcut: devpub push -f article.md --cover photo.png to upload the image and set cover_image in frontmatter in one step.

Both become trivial now that the upload primitive exists.


Shoutout

Big thanks to Harish @harishteens for the first external contribution to devpub. The PR was clean, well-tested, and properly documented. If you're looking for an open-source project to contribute to, devpub has open issues ranging from beginner-friendly to architecturally interesting.


What's your image workflow for Dev.to articles? Drag-and-drop in the browser? Hosted on GitHub? Imgur? I'd like to know if devpub upload fills a gap people feel, or if everyone's already solved this differently.


Follow me for more on AWS architecture, DevOps, and AI Infrastructure:
Portfolio | LinkedIn | Dev.to | YouTube | Email | AWS Builder Center | X

Top comments (5)

Collapse
 
sarvar_04 profile image
Sarvar Nadaf

Big thanks to Harish @harishteens for the first external contribution to devpub. The PR was clean, well-tested, and properly documented. If you're looking for an open-source project to contribute to, devpub has open issues ranging from beginner-friendly to architecturally interesting.

Collapse
 
harishteens profile image
Harish

Hey @sarvar_04 Thanks for the mention and the article, super glad!
Also wait for my article on this contribution, there's a surprise for y'all ;)

Collapse
 
sarvar_04 profile image
Sarvar Nadaf

Your Welcome! Super excited to see

Collapse
 
reidmarlow profile image
Reid Marlow

Reverse-engineering the session cookie and CSRF token from the browser editor is the exact same path I had to take for automated comment replies. The Forem V1 API is great for raw article publishing, but the lack of authenticated endpoints for uploads and comments forces you to bridge session state into CLI scripts. Separating credential validation and failing before hitting the network saves so many mysterious 403 debug loops.

Collapse
 
sarvar_04 profile image
Sarvar Nadaf

Exactly, you've described the whole reason uploads.py lives apart from the main API client rather than being another method on it. Two different auth models
(API key vs. browser session + CSRF), so I didn't want to pretend they're the same thing.

The "fail before the network" part was a direct reaction to those 403 debug loops you mention. ensure_session_credentials() checks both the session cookie and the CSRF token are present up front and exits with a "here's exactly where to click in DevTools" message if either is missing, so you never burn a request just to discover a credential wasn't set. And when Dev.to does reject a request, I map 401/403 to "copy a fresh session cookie" and 422 specifically to "your CSRF token is probably stale," because that 422-means-stale-token behavior was the single most confusing thing to figure out.

Comments are the next thing I want to bridge, for the same reason you hit it: no authenticated V1 endpoint, so it's back to session state. Curious how you handled comment replies. Did you post to the editor's endpoint the same way, or find something cleaner? The part I'm not looking forward to is CSRF rotation mid-session.