[开源] PicHost - 部署在 Cloudflare 上的个人轻量图床

博客写久了,配图是刚需。市面上图床不少:界面花花绿绿、相册管理、社交分享、付费套餐一应俱全。对我这种只给自己博客用的场景来说,功能越多反而越重——我更在意几件事:数据在自己手里、不依赖某家图床的政策和续费、自己不删就尽量一直能访问。
最初的想法很朴素:蹭 Cloudflare 大善人 的免费额度,对象存储用 R2,自己上传的图片落在自己的桶里,只要不手滑删掉,就相当于长期自持。不想维护 VPS,也不想再背一套 PHP + MySQL 图床程序。
于是有了 PicHost 的第一版:Nuxt 管理后台 + R2 私有桶 + Worker 出图,管理域和图片域拆开,Referer 白名单挡一挡普通盗链。对我来说,这就够写博客、够粘贴 Markdown 了。
后来博客接上了 Twikoo 评论,才发现评论区发图也要走图床。Twikoo 文档里列了不少「官方支持」的图床方案,不少要付费或绑第三方服务——和我「自建、少依赖、能长期放」的初衷不太合拍。与其每个月为评论配图单独掏钱,不如把手里这套再改吧改吧:补上 EasyImage 2.0 兼容接口(POST /api/index.php)、独立的 API_UPLOAD_TOKEN,让 Twikoo 后台填个地址和 Token 就能用。正文配图走管理后台,评论配图走脚本 Token,同一套 R2、同一个 img 域出图,不用维护两套存储。
所以 PicHost 不是冲着「功能最全」去的,而是先满足自己博客 + 评论的长期存图,再顺手开源。下面是从架构到部署的完整记录;若你也在 Firefly + Twikoo 这条路上,希望能少踩一点坑。
项目是怎么长出来的
| 阶段 | 需求 | PicHost 的回应 |
|---|---|---|
| 一开始 | 博客配图,CF + R2,自建、长期保存 | Pages 上传 + R2 私有 + Worker CDN |
| 接上 Twikoo 后 | 评论发图,不想用收费官方图床 | EasyImage 2.0 兼容 + API_UPLOAD_TOKEN |
| 上线运维 | 防盗链、防误删、deploy 不炸图 | Referer 白名单、双 Token、Dashboard 配环境变量 |
市面上那些「花花绿绿」的图床依然很好,只是诉求不同:我要的是轻量、自持、和现有博客栈(Astro / Twikoo)咬得上,而不是再开一个图床 SaaS 账号。
项目定位
| 维度 | PicHost 的选择 |
|---|---|
| 用户模型 | 单人管理,无注册/多用户 |
| 鉴权 | 管理密钥登录 + 独立脚本上传 Token |
| 存储 | R2 私有,禁止 r2.dev 公共访问 |
| 出图 | 独立 Worker 域,Referer 白名单 |
| 兼容 | Twikoo / EasyImage 2.0、Authorization: Bearer |
| 成本 | Cloudflare 免费额度内可跑个人博客量级 |
适合:个人博客、评论配图、偶尔脚本上传,不适合公开图床服务或海量相册。
为什么要拆成两个域名?
很多「单域名图床」把上传 API 和图片 URL 绑在一起:一旦要加防盗链或 CDN 规则,很容易误伤上传接口,或者为了省事把 R2 桶公开暴露出去。
PicHost 刻意拆开:
浏览器 ├─ pic.example.com → Cloudflare Pages(Nuxt 4 + Nitro API) │ ├─ 登录 / 上传 / 列表 / 删除 │ └─ R2 binding(IMAGES → personal-images) │ └─ img.example.com → Cloudflare Worker(image-proxy) ├─ Referer 白名单 ├─ Range / ETag / 边缘缓存 └─ 只读 R2,不暴露 list API| 组件 | 域名示例 | 职责 |
|---|---|---|
| 管理后台 | pic.example.com | 登录、上传、管理 |
| 图片 CDN | img.example.com | 对外图片 URL、防盗链 |
| 存储 | — | R2 私有桶 personal-images |
文章里插入的图片地址必须来自 img 域;环境变量 IMAGE_BASE_URL 也必须指向它,而不是管理后台域名。否则评论/正文里的 URL 和防盗链规则会对不齐。
功能一览
登录页与后台界面:


上传与管理
- 拖拽 / 点击 / Ctrl+V 粘贴,单次最多 10 张
- 支持 JPEG / PNG / WebP / GIF(不接受 SVG,避免 XSS 面)
- 单张上限 10 MB
- 存储 key:
images/YYYY/MM/<12位随机>.ext - 列表 R2 cursor 分页(默认 30,最大 100)
- 按文件名或路径 搜索(对个人图床够用;本质是扫桶)
- 单张 / 批量删除(批量建议
POST /api/images/batch-delete,比 DELETE body 更稳)
浏览器端 WebP 压缩(可选)
后台开关「上传前压缩为 WebP」默认开启:
- 仅处理 JPEG / PNG(GIF / WebP 原样上传)
- 最大宽度 2560px,质量 0.85
- 用 Canvas
toBlob('image/webp')转码后再走上传 API
这样大图进 R2 前体积会小一截,R2 出站流量和 Worker 回源次数都能降下来。
复制格式
每张图可一键复制:
- 直链:
https://img.example.com/images/2026/08/xxxxx.webp - Markdown:
 - HTML:
<img src="url" alt="...">
鉴权与人机验证
- 管理后台:
ADMIN_SECRET密钥登录(非用户名密码) - 脚本上传:独立
API_UPLOAD_TOKEN(可与管理密钥分开轮换) - Turnstile(可选):仅在 登录 时校验,上传/列表不再重复验证
技术栈
| 技术 | 用途 |
|---|---|
| Nuxt 4 + Nuxt UI 4 + Tailwind 4 | 管理界面 |
Nitro(cloudflare_pages preset) | REST API、R2 读写 |
| Cloudflare R2 | 私有对象存储 |
| Cloudflare Worker | 图片代理、防盗链、边缘缓存 |
| Turnstile(可选) | 登录防刷 |
| GitHub Actions | lint → build → 部署 Pages + Worker |
核心链路
1. 管理后台登录
GET /api/auth/config → 是否启用 Turnstile、Secret 是否已配置POST /api/auth/login → 校验 Turnstile(若启用)+ ADMIN_SECRET → Set-Cookie: pic_auth(httpOnly, 7天, Secure, SameSite=Lax)GET /api/auth/me → 校验 Cookie,未登录 401Session 不是随机 session id,而是对固定盐值 pic-session 做 HMAC-SHA256(ADMIN_SECRET),再用 timing-safe equal 比对 Cookie,避免时序攻击。未登录请求在鉴权层直接拒绝,不会触达 R2。
本地开发可设 DEV_BYPASS_ACCESS=true 跳过 Cookie 检查。
2. 上传(网页或脚本)
客户端(可选 WebP 压缩) ↓ multipartPOST /api/images/upload ├─ Cookie 鉴权(管理员) └─ 或 Authorization: Bearer <API_UPLOAD_TOKEN> ↓服务端 processSingleImageUpload: 1. 大小 ≤ 10MB 2. 读文件头魔数 → 识别真实 MIME(不信浏览器传的 type) 3. 白名单 MIME + 签名校验 4. generateImageKey → images/YYYY/MM/random.ext 5. bucket.put + 返回 url / markdown / html为什么一定要魔数校验? 改扩展名把 .php 伪装成 .jpg 上传,在只靠 Content-Type 的图床里很常见。PicHost 用 file-signature.ts 读前几字节,MIME 与内容对不上直接拒。
3. 图片访问(Worker)
GET/HEAD https://img.example.com/images/2026/08/xxxxx.webp ↓解析 pathname → 校验 key(必须以 images/ 开头、禁止 .. 和 //) ↓Referer 白名单(无 Referer → 放行) ↓Cache API 查缓存(GET 且无 Range 时) ↓R2 head/get → 支持 Range、ETag、304 ↓Cache-Control: public, max-age=86400, s-maxage=604800, stale-while-revalidate=864004. 删图与缓存 purge
删除 R2 对象后,Pages 侧会 best-effort 调 Worker:
POST https://img.example.com/__internal/purgeAuthorization: Bearer <INTERNAL_PURGE_TOKEN>Body: { "keys": ["images/2026/08/xxxxx.webp"] }INTERNAL_PURGE_TOKEN 在 pic-host 与 image-proxy 必须相同。purge 失败不会阻止删 R2,只是边缘缓存可能短暂残留旧图。
API 一览
鉴权
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/auth/config | Turnstile / Secret 配置状态 |
| POST | /api/auth/login | 登录 |
| POST | /api/auth/logout | 退出 |
| GET | /api/auth/me | 当前会话 |
图片(管理端 Cookie)
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/images/upload | 上传(也支持 Bearer Token) |
| GET | /api/images | 分页列表 |
| GET | /api/images/count | 总数 |
| GET | /api/images/search?q= | 搜索 |
| DELETE | /api/images?key= | 单张删除 |
| POST | /api/images/batch-delete | 批量删除 |
Twikoo / EasyImage 兼容
这条链路是后来「改吧改吧」加上去的,专门给评论发图用——和网页后台登录是两套鉴权:
| 用途 | 鉴权方式 | 典型场景 |
|---|---|---|
| 管理后台 | ADMIN_SECRET → Cookie | 你本人上传、删图、复制链接 |
| Twikoo / 脚本 | API_UPLOAD_TOKEN | 读者在评论区贴图 |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/index.php | token + image 表单字段(EasyImage 2.0) |
上传成功返回示例:
{ "code": 200, "result": "success", "url": "https://img.example.com/images/2026/08/AbCdEfGhIjKl.webp", "markdown": "", "html": "<img src=\"https://img.example.com/images/2026/08/AbCdEfGhIjKl.webp\" alt=\"\">"}Twikoo 后台:图床模式选 EasyImage 类接口,地址填 https://pic.example.com/api/index.php,Token 填 API_UPLOAD_TOKEN(不是管理密钥)。评论里展示的图片走 img 域,记得把博客域名写进 Worker 的 ALLOWED_REFERER_HOSTS,否则读者看图会 403。
部署全流程
1. 创建 R2 桶
npx wrangler loginnpx wrangler r2 bucket create personal-imagesDashboard → R2 → personal-images:
- 关闭
r2.dev公共访问 - 不要绑定公开自定义域名
2. 部署 Pages(pic-host)
npm installcd workers/image-proxy && npm install && cd ../..npm run buildnpx wrangler pages deploy dist --project-name pic-host --branch=main首次在 Dashboard → pic-host → 绑定:
- R2:
personal-images,binding 名IMAGES - 自定义域:
pic.example.com
Production 环境变量(Preview 不算,自定义域走 Production):
| 名称 | 类型 | 说明 |
|---|---|---|
ADMIN_SECRET | Secret | 管理登录 |
API_UPLOAD_TOKEN | Secret | Twikoo / 脚本 |
IMAGE_BASE_URL | 变量 | https://img.example.com |
IMAGE_WORKER_PURGE_URL | 变量 | https://img.example.com/__internal/purge |
INTERNAL_PURGE_TOKEN | Secret | 与 Worker 一致 |
TURNSTILE_SITE_KEY | 变量 | 可选 |
TURNSTILE_SECRET | Secret | 可选,两个都配才启用 |
自检:
curl https://pic.example.com/api/auth/config应看到 "adminSecretConfigured": true、"apiUploadTokenConfigured": true。
Secret 写入命令示例:
npx wrangler pages secret put ADMIN_SECRET --project-name pic-hostnpx wrangler pages secret put API_UPLOAD_TOKEN --project-name pic-hostnpx wrangler pages secret put INTERNAL_PURGE_TOKEN --project-name pic-host3. 部署图片 Worker(image-proxy)
cd workers/image-proxynpx wrangler secret put INTERNAL_PURGE_TOKENnpx wrangler deployDashboard → image-proxy → 域和路由 → 添加 img.example.com(custom_domain: true,pattern 写整域,不要 img.example.com/*)。
4. GitHub Actions 自动部署
推送 main 或手动触发 Deploy to Cloudflare:
npm run lint+typechecknuxt build→ 部署pic-host- 部署
image-proxyWorker
GitHub Secrets:
| Secret | 说明 |
|---|---|
CLOUDFLARE_API_TOKEN | 需含 Pages Edit + Workers 权限 |
CLOUDFLARE_ACCOUNT_ID | Account ID(32 位 hex),不是 Zone ID |
根目录 wrangler.jsonc 不要写 account_id(Wrangler 4 Pages 会报错)。首次 CI 可能自动创建 pic-host 项目,R2 绑定和环境变量仍需在 Dashboard 手工补一次。
重点踩坑:ALLOWED_REFERER_HOSTS 每次部署后被重置
这是我线上最容易翻车的点。
现象
博客插图正常,重新 deploy Worker 或跑完 GitHub Actions 后,站内图片全部 403,控制台:Forbidden: hotlink protection。
原因
wrangler deploy 会用 wrangler.jsonc 同步 Worker 配置。若文件里写了:
"vars": { "ALLOWED_REFERER_HOSTS": "example.com"}每次部署都会用文件覆盖 Dashboard 变量。你在控制台后来加的 muxui.com、www.muxui.com 会在下一次 CI 后全部丢失。
正确做法
workers/image-proxy/wrangler.jsonc不要写vars块(仓库已刻意留空)。- 只在 Dashboard 配置:
ALLOWED_REFERER_HOSTS=muxui.com,www.muxui.com,pic.example.comPUBLIC_IMAGE_ORIGIN=https://img.example.comINTERNAL_PURGE_TOKEN用wrangler secret put,deploy 不会清空 Secret。- Dashboard 改变量通常无需重新部署;但若
vars写进了仓库,下次 deploy 仍会覆盖——从 wrangler.jsonc 删掉vars才是根治。
Referer 规则(实现细节)
Worker 里 parseAllowedHosts 会:
- 把逗号分隔的 hostname 转小写放进
Set - 自动加入
PUBLIC_IMAGE_ORIGIN的 hostname(图片域本身永远在白名单)
| Referer | 结果 |
|---|---|
| 白名单 hostname(精确匹配) | ✅ 200 |
| 空 Referer | ✅ 允许(直接打开链接、RSS、部分客户端) |
| 其他域名 | ❌ 403 |
| 畸形 Referer URL | ❌ 403 |
www.example.com 与 example.com 要分别添加。子域绕过(evil.example.com)靠精确匹配挡住,但 blog.example.com 若也要用图,得单独加。
防盗链 ≠ 防刷:三种「被刷」要分清
| 类型 | 表现 | Referer 有用吗 | 主要靠什么 |
|---|---|---|---|
| 网页盗链 | 别人站 <img src="你的图"> | ✅ | ALLOWED_REFERER_HOSTS |
| 直链 CC | 脚本拿 URL 狂刷 | ❌ | WAF 限速 + 边缘缓存 |
| 路径遍历 | 猜文件名扫桶 | 部分 | 随机 12 字符 key + 私有 R2 |
Referer 防不住「不带 Referer 的刷流量」——这是设计上的取舍,不是 bug。
建议在 Dashboard 额外做的
A. 预算警报:Billing → 设 1~5 美元 预算邮件。
B. WAF Rate Limiting(有套餐的话):
| 规则 | 匹配 | 建议阈值 |
|---|---|---|
| 图片域 | Host = img.example.com | 60 次/分钟/IP |
| 上传 | Host = pic... + Path 含 /api/images/upload | 20 次/分钟/IP |
| 登录 | Path = /api/auth/login | 10 次/分钟/IP |
免费版能力有限,至少开 Bot Fight Mode + 预算警报。
故障排查速查
| 症状 | 优先检查 |
|---|---|
| 「未配置 ADMIN_SECRET」 | Production 是否配 Secret;/api/auth/config |
| Twikoo 报未配置 Token | apiUploadTokenConfigured;变量名是否 API_UPLOAD_TOKEN |
| 博客插图 403 | ALLOWED_REFERER_HOSTS 是否含博客域;deploy 后是否被 wrangler 覆盖 |
| 部署后图片域 5xx / 路由错误 | Worker 自定义域是否绑定;CLOUDFLARE_ACCOUNT_ID 是否同一账号 |
| 删图后仍看到旧图 | purge 失败可接受;等缓存过期或手动 purge |
| Actions 报 7003 | Token 权限、Account ID 是否为 32 位 hex |
本地开发
.env:
ADMIN_SECRET=dev-secretIMAGE_BASE_URL=http://localhost:8787DEV_BYPASS_ACCESS=true两个终端:
npm run dev # Nuxt,默认 :3000npm run worker:dev # Worker,:8787上传后返回的 URL 会指向 localhost:8787,便于联调 Worker 防盗链逻辑。
安全自检清单
- R2 关闭
r2.dev,桶未公开 -
ADMIN_SECRET≠API_UPLOAD_TOKEN,可独立轮换 -
ALLOWED_REFERER_HOSTS仅 Dashboard 配置,wrangler.jsonc 无vars -
INTERNAL_PURGE_TOKENPages / Worker 一致 - 上传仅 JPEG/PNG/WebP/GIF + 魔数校验
- Turnstile 两个 Key 同时配置或同时不配
- 博客域、
www、管理后台域已在 Referer 白名单
写在最后
回头看,PicHost 的演进路径很清晰:先为博客配图(CF + R2、自持、尽量不删即长期在),再为 Twikoo 评论配图(避开收费官方图床、自己适配 EasyImage 接口)。它没有去拼市面图床的花哨功能,而是把 私有 R2、双域分离、双 Token、Referer 防盗链、删图 purge 这些「个人博客用得着」的细节收进一个仓库。
我自己踩过最大的坑,仍是 ALLOWED_REFERER_HOSTS 别写进 wrangler.jsonc 的 vars——每次 GitHub Actions 部署都可能把 Dashboard 里辛苦配好的博客域名覆盖掉,全站插图 403。Twikoo 接好后尤其要检查这一项:评论图和正文图都依赖 img 域,白名单漏了 www 或根域,读者端才会「只有我电脑能看」。
仓库:github.com/uurani/PicHost(MIT)。若你也在用 Firefly + Twikoo,可以把正文配图和评论配图都接到这一套上——同一桶、同一 CDN,省一份图床钱,也多一份「自己不删就还在」的踏实感。部署或对接有问题,欢迎 Issue / 评论区交流。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













