云端部署

Cloudflare R2 + Workers:搭建个人图床

用 Cloudflare R2 对象存储加一个 Worker 搭建个人图床:一条命令上传截图,返回可以直接贴进博客的 Markdown 图片链接,图片带长期缓存和 ETag,同一张图重复上传得到同一个地址,上传和删除都需要令牌。

BINBHO · 图文教程

用 Cloudflare R2 对象存储加一个 Worker 搭建个人图床:一条命令上传截图,返回可以直接贴进博客的 Markdown 图片链接,图片带长期缓存和 ETag,同一张图重复上传得到同一个地址,上传和删除都需要令牌。

先看清成品

成品是一个 Worker 和一个上传脚本 upload.sh。执行 ./upload.sh 截图.png 得到 ![](https://img.example.com/i/2026/10/xxxx.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: `![](${url})`, 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":"![](http://localhost:8788/i/2026/10/2c54bca91344d415.png)","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 动图不动 某些平台会转码 本图床原样返回,问题在引用方

项目资源

用途 地址
R2 文档 https://developers.cloudflare.com/r2/
R2 定价 https://developers.cloudflare.com/r2/pricing/
在 Worker 中使用 R2 https://developers.cloudflare.com/r2/api/workers/workers-api-usage/
R2 Workers API 参考 https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
Workers 限制 https://developers.cloudflare.com/workers/platform/limits/
Wrangler 命令 https://developers.cloudflare.com/workers/wrangler/commands/
Secrets https://developers.cloudflare.com/workers/configuration/secrets/

建议先在本地用 wrangler dev 上传一张截图,用浏览器打开返回的地址确认能显示,再用 curl -D - 看一眼缓存头。

本地没问题后再创建存储桶部署,第一张线上图片建议用自己拍的照片测试。