用 Cloudflare R2 对象存储加一个 Worker 搭建个人图床:一条命令上传截图,返回可以直接贴进博客的 Markdown 图片链接,图片带长期缓存和 ETag,同一张图重复上传得到同一个地址,上传和删除都需要令牌。
先看清成品
成品是一个 Worker 和一个上传脚本
upload.sh。执行./upload.sh 截图.png得到。本文代码已在本地用 Wrangler 4.151.3 的wrangler dev(本地模拟 R2)实际运行:上传返回 201,重复上传得到相同地址,下载内容和原图逐字节一致,响应带正确的类型、缓存头和 ETag,条件请求返回 304,无令牌上传返回 401,非图片类型返回 415,删除后返回 404。部署到线上没有在本文环境中执行。
注意:图床里只放你有权使用的图片。不要上传他人受版权保护的作品或他人隐私照片,也不要把图床开放给陌生人上传,否则你要为其中的内容负责。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 上传接口 | PUT /upload,令牌保护 |
| 图片地址 | GET /i/年/月/哈希.扩展名 |
| 去重 | 按内容哈希命名 |
| 缓存 | 一年强缓存 + ETag |
| 删除接口 | DELETE /i/...,令牌保护 |
| 命令行脚本 | 批量上传,输出 Markdown |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Cloudflare 账号 | 免费计划即可 | 部署时需要登录 |
| Node.js | 22 或更高 | 当前版本的 Wrangler 要求 Node 22 |
| Wrangler | 4.x | 本文使用 4.151.3,作为项目开发依赖安装 |
| 域名(可选) | 已接入 Cloudflare | 不绑定时使用 *.workers.dev 地址 |
R2 需要在后台先开通(免费计划也要求绑定支付方式)。R2 有每月免费额度,且从 R2 下载到互联网的流量不收费,具体额度见项目资源里的定价页。
第一步:项目结构是什么样的?
img-host/
├── src/
│ └── index.js
├── wrangler.jsonc
├── package.json
├── upload.sh
└── .dev.vars # 本地令牌,不提交
第二步:代码怎么写?
1. wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "img-host",
"main": "src/index.js",
"compatibility_date": "2026-10-01",
"r2_buckets": [
// 先运行 npx wrangler r2 bucket create my-images
{ "binding": "IMAGES", "bucket_name": "my-images" }
],
"vars": {
"MAX_BYTES": "10485760"
}
}
MAX_BYTES 设为 10 MB。
2. src/index.js
// 个人图床:PUT /upload 上传(需令牌),GET /i/<key> 读取,DELETE /i/<key> 删除(需令牌)
const TYPES = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/avif": "avif" };
const json = (d, s = 200) => new Response(JSON.stringify(d), { status: s, headers: { "content-type": "application/json; charset=utf-8" } });
const authed = (req, env) => env.UPLOAD_TOKEN && req.headers.get("authorization") === `Bearer ${env.UPLOAD_TOKEN}`;
async function sha256Hex(buf) {
const hash = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function upload(req, env) {
if (!authed(req, env)) return json({ error: "unauthorized" }, 401);
const type = (req.headers.get("content-type") ?? "").split(";")[0].trim();
const ext = TYPES[type];
if (!ext) return json({ error: `不支持的类型 ${type || "(空)"},只接受 ${Object.keys(TYPES).join(", ")}` }, 415);
const buf = await req.arrayBuffer();
if (buf.byteLength === 0) return json({ error: "文件为空" }, 400);
if (buf.byteLength > Number(env.MAX_BYTES)) return json({ error: "文件过大" }, 413);
// 用内容哈希命名:同一张图重复上传得到同一个地址
const now = new Date();
const key = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}/${(await sha256Hex(buf)).slice(0, 16)}.${ext}`;
await env.IMAGES.put(key, buf, { httpMetadata: { contentType: type, cacheControl: "public, max-age=31536000, immutable" } });
const url = new URL(`/i/${key}`, req.url).toString();
return json({ key, url, markdown: ``, size: buf.byteLength }, 201);
}
async function serve(req, env, key) {
const obj = await env.IMAGES.get(key, { onlyIf: req.headers, range: req.headers });
if (obj === null) return new Response("Not found", { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("etag", obj.httpEtag);
headers.set("x-content-type-options", "nosniff");
// onlyIf 条件不满足时没有 body,返回 304
if (!("body" in obj) || obj.body === undefined) return new Response(null, { status: 304, headers });
return new Response(obj.body, { headers });
}
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (url.pathname === "/upload" && req.method === "PUT") return upload(req, env);
if (url.pathname.startsWith("/i/")) {
const key = decodeURIComponent(url.pathname.slice(3));
if (req.method === "GET" || req.method === "HEAD") return serve(req, env, key);
if (req.method === "DELETE") {
if (!authed(req, env)) return json({ error: "unauthorized" }, 401);
await env.IMAGES.delete(key);
return json({ deleted: key });
}
}
return new Response("图床运行中", { status: 200 });
},
};
| 设计 | 原因 |
|---|---|
| 类型白名单 | 防止上传 HTML 等文件被当成网页执行 |
nosniff 响应头 |
浏览器不会猜测文件类型 |
| 内容哈希命名 | 去重,且地址不会被猜到 |
immutable 缓存 |
同一地址内容永远不变,可以放心长期缓存 |
onlyIf 和 range |
支持 304 条件请求和断点续传 |
3. upload.sh
#!/usr/bin/env bash
# 用法:IMG_BASE=https://img.example.com IMG_TOKEN=xxx ./upload.sh 图片文件...
set -euo pipefail
for f in "$@"; do
case "${f,,}" in
*.png) t=image/png ;; *.jpg|*.jpeg) t=image/jpeg ;; *.webp) t=image/webp ;; *.gif) t=image/gif ;; *.avif) t=image/avif ;;
*) echo "跳过不支持的文件:$f"; continue ;;
esac
curl -sS -X PUT "$IMG_BASE/upload" -H "authorization: Bearer $IMG_TOKEN" -H "content-type: $t" --data-binary "@$f"
echo
done
第三步:怎样在本地测试?
1. 启动
npm install
echo "UPLOAD_TOKEN=$(openssl rand -hex 12)" > .dev.vars
npx wrangler dev --port 8788
本地开发时 R2 由 Wrangler 在本机模拟。
2. 上传
IMG_BASE=http://localhost:8788 IMG_TOKEN=$(cut -d= -f2 .dev.vars) ./upload.sh 截图.png
本文用一张 64×64 测试图实际输出:
{"key":"2026/10/2c54bca91344d415.png","url":"http://localhost:8788/i/2026/10/2c54bca91344d415.png","markdown":"","size":316}
再传一次同一张图,返回完全相同的 key。
3. 检查结果
curl -s -D - -o got.png http://localhost:8788/i/2026/10/2c54bca91344d415.png
本文实际响应头:
HTTP/1.1 200 OK
Content-Type: image/png
Cache-Control: public, max-age=31536000, immutable
ETag: "9ac523890f2812682a6fad122ffdb837"
x-content-type-options: nosniff
| 测试 | 本文结果 |
|---|---|
| 下载内容和原图对比 | 一致 |
带 If-None-Match 请求 |
304 |
| 无令牌上传 | 401 |
text/html 类型上传 |
415 |
| 删除后访问 | 404 |
第四步:怎样部署上线?
1. 创建存储桶
npx wrangler login
npx wrangler r2 bucket create my-images
2. 设置令牌并部署
npx wrangler secret put UPLOAD_TOKEN
npx wrangler deploy
3. 绑定图床域名
在 Worker 的 设置 → 域和路由 添加自定义域,例如 img.example.com。之后上传时:
IMG_BASE=https://img.example.com IMG_TOKEN=你的线上令牌 ./upload.sh *.png
4. 配合写作工具
Typora 等编辑器支持“自定义命令”上传图片,命令填 upload.sh 的路径即可。脚本输出 JSON,如果工具要求只输出网址,可以在脚本末尾用 python3 -c 或 jq -r .url 提取。
出问题时该怎么调?
1. 上传返回 415
content-type 不在白名单里。upload.sh 按扩展名判断,确认文件扩展名正确;手动 curl 时记得加 -H "content-type: image/png"。
2. 上传大图失败
| 原因 | 解决方法 |
|---|---|
超过 MAX_BYTES |
返回 413,压缩图片或调大限制 |
| 免费计划 CPU 时间限制 | 免费计划每个请求 10 ms CPU 时间,计算大文件哈希可能超时;压缩图片或升级付费计划 |
| 请求体超过套餐上限 | 见 Workers 限制文档 |
3. 更新图片后还是旧的
按内容哈希命名,新图片会得到新地址,旧地址仍是旧图。这正是设置 immutable 的前提。不要在代码里改成固定文件名,否则缓存会让用户一直看到旧图。
4. 部署报 bucket not found
存储桶名和 wrangler.jsonc 里的 bucket_name 不一致,用 npx wrangler r2 bucket list 核对。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 开通 R2 要求填卡 | R2 需要绑定支付方式 | 按后台提示完成,免费额度内不收费 |
| 图片被别的网站盗链 | 地址公开 | 在 Worker 里检查 Referer,或开启 Cloudflare 防盗链设置 |
| 想看所有图片 | 没有列表接口 | 后台 R2 页面浏览,或 wrangler r2 object 命令 |
.dev.vars 泄露 |
被提交到仓库 | 更换令牌 |
| 本地数据丢失 | 本地模拟数据在 .wrangler 目录 |
本地仅用于测试,不要当正式存储 |
| GIF 动图不动 | 某些平台会转码 | 本图床原样返回,问题在引用方 |
项目资源
建议先在本地用 wrangler dev 上传一张截图,用浏览器打开返回的地址确认能显示,再用 curl -D - 看一眼缓存头。
本地没问题后再创建存储桶部署,第一张线上图片建议用自己拍的照片测试。