img-hosting 完整教學

用一個 Worker、一顆 API_KEY、Cloudflare R2 + D1 + Access 做一個只有自己能寫、任何人能讀的 Imgur-shaped 圖床。 多送你:bash CLI 跟 Claude Code skill 各一份。


1. 專案定位

1.1 一句話介紹

img-hosting = 私人版 Imgur,跑在 Cloudflare 邊緣。對外 API 對齊 Imgur,所以你既有的客戶端、瀏覽器擴充、AI agent 都能直接接;對內全套都你自己的:圖檔在你的 R2 bucket、metadata 在你的 D1 database、寫入只認你的 API_KEY 或 Cloudflare Access 帳號。

1.2 為什麼出現?

作者 林學廷 (htlin222) 是醫師 / 工程師,常需要在文件、簡報、blog 中插圖。市面選項痛點:

  • Imgur free tier — 公開、廣告、有政策變動風險
  • GitHub README image asset — 不能直接 hot-link(要 fork repo / 上 issue)
  • Self-hosted MinIO / 自架 server — VPS 成本 + 維運
  • S3 + CloudFront — AWS 帳單心電圖

img-hostingCloudflare Workers + R2 + D1 全 serverless 路線:

  • 零起跳成本:Workers free tier 10 萬 req/day、R2 10GB 存儲、D1 5GB 任你用
  • 5 分鐘部署make install + wrangler deploy
  • 無冷啟動:邊緣 V8 isolate
  • 可選 OAuth gate:用 Cloudflare Access 把 web UI 包進 Zero Trust,不用 Auth0 / Cognito

1.3 三個介面對應三個使用情境


flowchart TD
    A[使用者瀏覽器] -->|"OAuth Access
JWT"| B[Worker /
web UI] C[CLI / agent] -->|"Bearer
API_KEY"| D[Worker /3/image] E[任何人] -->|public read| F[Worker /i/:id.png] B --> G[R2 bucket] D --> G F --> G B --> H[D1 metadata] D --> H F --> H

1.4 適合誰?

角色用法
個人寫作者 / 部落客取代 Imgur 公開上傳,建私人圖床貼 markdown
AI agent 使用者裝 Claude Code skill,agent 自己上傳產生圖、把 URL 嵌回文件
小團隊 / 醫療研究Cloudflare Access + Google/GitHub OAuth,限制 email domain
Cloudflare 學習者完整示範 Workers + R2 + D1 + Access + Image Transform 五合一

1.5 數字快照(2026-05-19)

  • 📦 1 個 repo / 4 stars / 1 fork(誕生 1 日,極早期)
  • 🧪 20 vitest tests passing
  • 📜 TypeScript 1,207 LOC(13 個 .ts source files)
  • 🔒 MIT 授權,含商用
  • 📊 雙語 README(English + 繁體中文)

2. 安裝指南

2.1 前置需求

  • Cloudflare 帳號(免費 plan OK)
  • Node ≥ 20 + pnpm (brew install pnpmcorepack enable)
  • curl(CLI 使用,macOS / Linux 內建)
  • 自有網域 + 託管到 Cloudflare僅在想開 Access OAuth 時必要;只用 API + workers.dev 子網域則可省)

2.2 7 步安裝流程


flowchart LR
    A[1. clone + install] --> B[2. CF resources
create R2 + D1] B --> C[3. set API_KEY
wrangler secret] C --> D[4. first deploy] D --> E[5. install CLI + skill] E --> F[6. optional
custom domain + Access] F --> G[7. local dev]

2.3 Step 1:clone + 安裝

1git clone https://github.com/htlin222/img-hosting.git
2cd img-hosting
3make install              # 等於 pnpm install
4cp wrangler.toml.example wrangler.toml
5cp .dev.vars.example .dev.vars
6cp .env.example .env

2.4 Step 2:建 R2 + D1

 1# R2 bucket
 2pnpm wrangler r2 bucket create img-hosting
 3
 4# D1 database
 5pnpm wrangler d1 create img-hosting
 6# 印出 database_id - 複製貼到 wrangler.toml 的 database_id = "" 處
 7
 8# Schema apply
 9pnpm wrangler d1 execute IMG_DB --remote --file=./schema.sql
10# 或 make db-remote

2.5 Step 3:設 API_KEY

1# 隨機產 64 位 hex
2openssl rand -hex 32 | wrangler secret put API_KEY
3# 同一把 key 也寫到本地 .dev.vars 與 .env,給 CLI / dev server 用

2.6 Step 4:首次 deploy

1make deploy
2# 拿到 https://img-hosting.<your-subdomain>.workers.dev
3# 測試
4curl https://img-hosting.<your-subdomain>.workers.dev/healthz
5# {"ok":true}
6curl -X POST https://img-hosting.<your-subdomain>.workers.dev/3/image
7# {"error":"unauthorized"}  ← 401 預期

2.7 Step 5:裝 CLI + Claude skill

1make install-cli          # symlink img-hosting/bin/img-hosting -> ~/bin/img-hosting
2make install-skill        # symlink img-hosting/ -> ~/.claude/skills/img-hosting
3
4# 確認
5img-hosting --help
6img-hosting upload some.png
7# https://img-hosting.<sub>.workers.dev/i/Dwql3jX.png

2.8 Step 6(選用):自有網域 + Cloudflare Access

1# 1. 在 wrangler.toml 設 PUBLIC_BASE_URL = "https://i.yourdomain.com"
2# 2. CF dashboard: Workers > Custom Domains 加上 i.yourdomain.com
3# 3. CF dashboard: Zero Trust > Access > Applications 建一個 self-hosted app
4#    - hostname: upload-image.yourdomain.com
5#    - policy: emails ending @yourdomain.com
6# 4. 拿到 AUD tag,填到 wrangler.toml [vars]
7#    ACCESS_TEAM = "your-team"
8#    ACCESS_AUD = "<aud-uuid>"
9# 5. make deploy

之後瀏覽器打 upload-image.yourdomain.com 會跳 Google / GitHub / email magic link 登入,web UI 才開放上傳。

2.9 Step 7:本地 dev

1make dev                  # wrangler dev on :8787
2# 或
3make test                 # vitest run(20 個 test 全綠才 deploy)

3. 核心架構解析

3.1 三層架構


flowchart TB
    subgraph Edge[Cloudflare Edge]
        UI[ui.ts
單頁 HTML] IMG[images.ts
/3/image POST/DELETE] ACC[account.ts
/3/account/* GET] SRV[serve.ts
/i/:id.ext GET] AUTH[auth.ts
Bearer + Access JWT] RL[ratelimit.ts
RL binding] SNIFF[sniff.ts
magic byte detect] end UI --> AUTH IMG --> AUTH ACC --> AUTH AUTH --> RL IMG --> SNIFF SRV --> R2 IMG --> R2[R2 bucket
IMG_BUCKET] IMG --> D1[D1 database
IMG_DB] SRV --> D1 ACC --> D1

3.2 13 個 source file

FileLOC職責
index.ts49Hono app 組裝,/healthz + /whoami + route mount
auth.ts64Bearer + Access dual auth;timing-safe equal
access.ts104Cloudflare Access JWT verify (JWKS)
images.ts205POST/DELETE /3/image 與 raw / multipart / base64 解析
account.ts77GET /3/account/me/images(list / count / by-id)
serve.ts88GET /i/:id.ext 公開讀 + image transform
ui.ts398內嵌 HTML / CSS / JS(drag-drop + dark mode + paste from clipboard)
sniff.ts97Magic byte 判 mime(PNG/JPEG/GIF/WebP),擋偽造副檔名
ratelimit.ts25per-key 或 per-IP 速率限制
resize.ts48解析 ?w= ?h= ?fit= ?q= → Cloudflare Image Transformations
ids.ts21短 ID 產生 + delete-hash + SHA-256
response.ts16ok / fail helpers(對齊 Imgur shape)
env.ts15Env 型別宣告

3.3 三條 auth 路徑


sequenceDiagram
    participant U as User
    participant W as Worker

    rect rgba(200,255,200,0.3)
    Note over U,W: 路徑 A: Web UI (preferred)
    U->>W: GET upload-image.example.com
    W->>U: Redirect to Access OAuth
    U->>W: Returns with Cf-Access-Jwt-Assertion
    W->>W: verifyAccessJwt(jwt, team, aud)
    W->>U: 200 + identity (email/sub)
    end

    rect rgba(200,200,255,0.3)
    Note over U,W: 路徑 B: CLI / agent
    U->>W: POST /3/image\nAuthorization: Bearer 
    W->>W: timingSafeEqual(token, expected)
    W->>U: 200 + Imgur-shape response
    end

    rect rgba(255,220,200,0.3)
    Note over U,W: 路徑 C: Public read
    U->>W: GET /i/Dwql3jX.png
    W->>W: D1 lookup + R2 get
    W->>U: image bytes + cache headers
    end

3.4 D1 schema 一覽

 1CREATE TABLE images (
 2  id           TEXT PRIMARY KEY,    -- 短 ID, Imgur-shape (7 字元)
 3  deletehash   TEXT UNIQUE NOT NULL, -- 刪除用 token, 不外露
 4  owner        TEXT NOT NULL,       -- 'me' 或 Access identity sub
 5  filename, title, description,
 6  mime         TEXT NOT NULL,       -- e.g. 'image/png'
 7  ext          TEXT NOT NULL,
 8  size, width, height,              -- bytes 與像素
 9  sha256       TEXT NOT NULL,       -- dedup 用
10  created_at   INTEGER NOT NULL,
11  deleted_at   INTEGER              -- soft delete
12);
13CREATE INDEX idx_images_owner_created
14  ON images(owner, created_at DESC)
15  WHERE deleted_at IS NULL;
16CREATE INDEX idx_images_sha
17  ON images(sha256);

SHA-256 index 允許未來 dedup(同檔案不重複佔 R2 空間,邏輯目前未實作但 schema 預留)。


4. Helper Scripts 詳細用法

4.1 Makefile 入口

 1make help                  # 列所有 target
 2make install               # pnpm install
 3make typecheck             # tsc --noEmit
 4make test                  # vitest run(20 個 test)
 5make dev                   # wrangler dev :8787
 6make build                 # typecheck(wrangler 自動 bundle)
 7make deploy                # wrangler deploy
 8make db-local              # 套 schema 到本地 d1
 9make db-remote             # 套 schema 到 production d1
10make install-cli           # ln -s img-hosting/bin/img-hosting ~/bin/
11make install-skill         # ln -s img-hosting/ ~/.claude/skills/

4.2 REST API(Imgur-shape)

 1# 上傳 (raw)
 2curl -X POST https://your-worker/3/image \
 3     -H "Authorization: Bearer $API_KEY" \
 4     -H "Content-Type: image/png" \
 5     --data-binary @photo.png
 6
 7# 上傳 (multipart)
 8curl -X POST https://your-worker/3/image \
 9     -H "Authorization: Bearer $API_KEY" \
10     -F image=@photo.png -F title="awesome"
11
12# 上傳 (JSON + base64)
13curl -X POST https://your-worker/3/image \
14     -H "Authorization: Bearer $API_KEY" \
15     -H "Content-Type: application/json" \
16     -d '{"image":"iVBORw...","name":"photo.png"}'
17
18# 列出
19curl https://your-worker/3/account/me/images \
20     -H "Authorization: Bearer $API_KEY"
21
22# 計數
23curl https://your-worker/3/account/me/images/count \
24     -H "Authorization: Bearer $API_KEY"
25
26# 取單一
27curl https://your-worker/3/image/Dwql3jX \
28     -H "Authorization: Bearer $API_KEY"
29
30# 刪除(用 deletehash 不是 id)
31curl -X DELETE https://your-worker/3/image/<deletehash> \
32     -H "Authorization: Bearer $API_KEY"
33
34# 公開讀
35curl -O https://your-worker/i/Dwql3jX.png
36curl -O 'https://your-worker/i/Dwql3jX.png?w=800&fit=cover&q=80'  # 動態 resize

4.3 CLI 用法

 1img-hosting upload photo.png                   # 印 URL
 2img-hosting upload photo.png --json            # 印完整 JSON
 3img-hosting md photo.png                       # ![alt](url) markdown
 4img-hosting md photo.png --alt "demo screenshot"
 5img-hosting html photo.png                     # <img src="url" />
 6img-hosting list                               # 我的上傳
 7img-hosting count
 8img-hosting get Dwql3jX
 9img-hosting delete <deletehash>
10img-hosting whoami
11img-hosting upload - < photo.png               # stdin 路徑

CLI 會依序找:環境變數 → $IMG_HOSTING_ENV → skill 同目錄 .env~/.config/img-hosting/.envcwd/.env

4.4 Claude Code skill

img-hosting/ symlink 到 ~/.claude/skills/img-hosting/ 後,Claude Code 看到使用者要「上傳 / 分享 / 嵌入圖片」就觸發 skill。SKILL.md 規範 agent:

  • 必跑 CLI(img-hosting upload),不要自己組 HTTP
  • 不要 echo bearer token
  • 沒給路徑時先問使用者

5. 應用場景

5.1 場景 A:個人 markdown 寫作

1img-hosting md ~/Pictures/diagram.png --alt "system architecture"
2# ![system architecture](https://i.yourdomain.com/i/Dwql3jX.png)

直接複製貼 markdown 文件、Obsidian、Notion blog。

5.2 場景 B:AI agent 自動截圖 + 嵌入

1User: 幫我抓 PaperZilla 首頁截圖,產一份 markdown report
2Claude:
31. opencli-browser → screenshot.png
42. img-hosting upload screenshot.png → URL
53. 把 ![paperzilla](URL) 嵌進 report.md

5.3 場景 C:研究室 / 小團隊 OAuth 限制

11. Cloudflare Access app rule:
2   Include: emails ending @apotek.tw
32. 同事打開 upload-image.apotek.tw → Google 登入
43. 上傳 → web UI 顯示縮圖
54. R2 圖檔依 owner=<sub from JWT> 區分

無需 user 表 / password 表,全靠 IdP(Google Workspace / Okta / GitHub)。

5.4 場景 D:Markdown 文件圖檔遷移

1# 把舊文件裡的 imgur URL 全部抓下重傳到自己圖床
2for url in $(grep -oE 'https://i.imgur.com/[a-zA-Z0-9]+\.(png|jpg|gif)' notes/*.md); do
3  tmp=$(mktemp /tmp/XXXX.png)
4  curl -sL "$url" -o "$tmp"
5  new_url=$(img-hosting upload "$tmp")
6  sed -i "s|$url|$new_url|g" notes/*.md
7  rm "$tmp"
8done

6. 資安掃描報告

掃描方法:grep -rE "eval\(|exec\(|new Function|child_process|sk-[A-Za-z0-9]{20}|password=" src/ + 檢視 .gitignore + .dev.vars.example + auth 模組程式碼。

6.1 結論:🟢 LOW RISK

對一個 1 日大的小專案而言,作者在 secrets / auth / 上傳防護 都做到該做的事。可放心 self-host。

6.2 細項

風險點等級說明
API key timing attack🟢 LOWauth.tstiming-safe equal(自實作的 constant-time loop),不會 leak token 長度差異
Hard-coded secret🟢 LOWgrep sk-...|password= 無發現;.dev.vars.example.env.example 都明標 “replace-with…”
.gitignore 完整性🟢 LOW排除 .dev.vars / .env / wrangler.toml(含真實 D1 database_id),只 commit *.example
任意指令執行🟢 LOW唯一 exec( 出現在 auth.ts 的 regex match (/^Bearer\s+(.+)$/i.exec());non-issue
上傳大小🟢 LOWMAX_BYTES = 20 * 1024 * 1024(20 MiB cap),對齊 Imgur free tier
MIME 偽造🟢 LOWsniff.ts 用 magic bytes 判型,不信任 Content-Type header — 擋 polyglot file
Rate limiting🟢 LOWCloudflare RL binding:60 req / 60s per key 或 per IP;測試環境 fail open(已標註)
Path traversal🟢 LOW短 ID 路徑(7 字元 alphanum),不接 user 傳檔名
Access JWT verify🟢 LOWaccess.ts 走 JWKS fetch 真正驗 RS256 簽名(不是只看 header)
公開讀無 auth⚠️ BY-DESIGN/i/:id.ext 任何人能讀 — 這是「Imgur-shape」的核心特性;想擋要自己加 Access on /i/*
D1 database_id 外洩🟢 LOWwrangler.toml 在 .gitignore;只 commit wrangler.toml.example(空白 database_id = ""
SHA-256 / deletehash🟢 LOWids.ts 用 crypto API 產生;deletehash 走獨立欄位,不從 id 推導
CORS🟡 MEDIUMREADME 與 source 未明確設 CORS allowlist — 若 web UI / API 跨域呼叫,需注意(Cloudflare 預設不限)
Logging 機敏🟢 LOWAccess JWT 驗失敗只記 message,不記原始 token

6.3 建議使用者注意

  1. 小團隊內部用:可直上 production,記得 wrangler secret put API_KEY 用強隨機(openssl rand -hex 32
  2. ⚠️ 若處理敏感影像:考慮把 /i/:id.ext 也加上 Access policy(修 src/serve.ts),預設是公開讀
  3. ⚠️ 若給多個使用者:目前 OWNER = 'me' hard-code 在 images.ts;要做多租戶要改成從 Access identity 派生
  4. CI/CD:跑 make test(20 個 test)作為部署 gate

7. FAQ

Q1:為什麼用 Cloudflare 而不是 AWS S3 + Lambda? A:成本 + 冷啟動。CF Workers free tier 10 萬 req/day、無冷啟動;R2 沒有 egress 費用,S3 出流量會被收錢。對個人 / 小團隊圖床完美。

Q2:圖會被搜尋引擎抓到嗎? A:/i/:id.ext URL 是隨機短 ID(7 字元 base62 ≈ 3.5 兆組合)。沒有 listing,所以不會被爬到,但只要有人拿到 URL 就能看(unlisted 而非 private)。要全私請加 Access。

Q3:可以開分享連結但設過期嗎? A:目前不支援。可以自己加 D1 欄位 expires_at 然後在 serve.ts 判斷。

Q4:圖檔上限為什麼是 20 MiB? A:對齊 Imgur free tier,也是 Cloudflare Workers single-request body 推薦上限。要更大可改 images.tsMAX_BYTES,但要注意 Worker 50 ms CPU time 限制(free plan)。

Q5:CLI 如何在多台機器分享 config? A:把 .env~/.config/img-hosting/.env,CLI 會自動找。或設 IMG_HOSTING_ENV=/path/to/custom

Q6:跟 Imgur 真的 100% 相容嗎? A:API shape 對齊(fields 名稱、回 JSON 結構),但 auth header 是 Bearer(Imgur 是 Client-ID),rate limit 邏輯也不同。能用大多 Imgur 客戶端,但需要改 auth scheme。

Q7:D1 / R2 滿了會怎樣? A:CF 會丟錯,Worker 回 500。建議定期跑 img-hosting list 看占用,或加個 cron 刪 N 天前的軟刪檔(schema 已有 deleted_at)。


8. 進階技巧

8.1 動態 resize(省頻寬)

1# 原始
2https://i.yourdomain.com/i/Dwql3jX.png
3# 800px 寬,q=80 jpeg
4https://i.yourdomain.com/i/Dwql3jX.png?w=800&q=80
5# 縮圖 thumbnail
6https://i.yourdomain.com/i/Dwql3jX.png?w=200&h=200&fit=cover

需在 CF dashboard 開「Transform images」(Speed > Optimization > Image Optimization)。

8.2 自訂 PUBLIC_BASE_URL

1# wrangler.toml
2[vars]
3PUBLIC_BASE_URL = "https://i.yourdomain.com"

設了之後 API 回傳的 link 欄位會用這個 base,方便 markdown 直接複製。

8.3 OWNER 從 Access JWT 派生(多租戶)

images.ts line 11 const OWNER = 'me'; 改成從 c.get('identity') 拿 sub / email,每個使用者只看自己的圖。要記得改 account.ts 的 query 條件。

8.4 整合進你的 Obsidian

Obsidian Image Auto Upload Plugin → custom uploader:

1{
2  url: "https://i.yourdomain.com/3/image",
3  method: "POST",
4  headers: { "Authorization": "Bearer YOUR_API_KEY" }
5}

8.5 用 Wrangler tail debug

1pnpm wrangler tail --format=pretty
2# 即時看 console.log / 錯誤 / 請求 metadata

9. 整合進其他工作流

9.1 與本專案的關係


flowchart LR
    A[docling / paper-search
產生圖檔] --> B[img-hosting upload] B --> C[hosted URL] C --> D[quarkdown / kami
嵌入 HTML/PDF] C --> E[ai-save
知識 md]

把 img-hosting 當「圖檔 URL 來源」整進現有 markdown workflow:docling 抽圖 → upload → 嵌回 .md → quarkdown 排版 → 出 HTML。

9.2 與 Claude Code skill 生態的位置

skill職責
agent-skills (tech-leads-club)跨 14 種 agent skill registry
academic-research-skills (Imbad0202)學術 deep stack plugin
img-hosting (htlin222)單一功能 skill — agent 上傳本地圖 → URL
kami / quarkdown (本專案)文件排版層,img-hosting 是上游圖檔來源

img-hosting 是 single-purpose skill 的好範例:SKILL.md < 100 行,trigger 條件明確(PNG/JPEG/GIF/WebP 上傳)。

9.3 替代方案比較

方案成本隱私易用性
img-hosting (本專案)$0–5/mo (CF free)自有 R2 + Access★★★★ (img-hosting upload)
Imgur free$0 + 廣告公開★★★ (web only)
AWS S3 + CF$5–20/mo自有 bucket★★ (要自寫 CLI)
GitHub Gist + asset$0repo 公開★ (要 fork)
MinIO self-hostedVPS ~$5/mo完全自有★ (要維運)

10. 重點摘要 Checklist

  • Cloudflare 全家桶:Workers + R2 + D1 + Access + Image Transforms 五合一
  • 三介面:web UI(OAuth)+ REST API(Bearer)+ CLI / Claude skill
  • Imgur-shape API:對齊 /3/image / /3/account/* / /i/:id.ext
  • secrets 全分離.dev.vars / wrangler secret / .env 都在 .gitignore
  • timing-safe auth + magic-byte sniff + rate limit 三道防護
  • 20 vitest test passing,CI/CD gate 完整
  • MIT 授權,含商用
  • 零起跳成本(CF free tier 個人用足夠)
  • 資安 🟢 LOW(小團隊內部可直上 production)
  • 5–10 分鐘部署make install + wrangler deploy

11. 進一步閱讀

  • GitHub repo:https://github.com/htlin222/img-hosting
  • 繁體中文 README:README.zh-TW.md
  • 作者個人首頁:Lin Hsieh-Ting
  • Cloudflare Workers 文件:https://developers.cloudflare.com/workers/
  • Cloudflare R2:https://developers.cloudflare.com/r2/
  • Cloudflare D1:https://developers.cloudflare.com/d1/
  • Cloudflare Access:https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/
  • Hono framework:https://hono.dev/
  • Imgur API 規格:https://apidocs.imgur.com/

Captured:2026-05-19 by gh-tutorial-qd workflow(depth=full / lang=zh-tw / qd_preset=report / security=on)。