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

3157 字
16 分钟
[开源] 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 这条路上,希望能少踩一点坑。

uurani
/
PicHost
Waiting for api.github.com...
00K
0K
0K
Waiting...

项目是怎么长出来的#

阶段需求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登录、上传、管理
图片 CDNimg.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![](url)
  • HTML<img src="url" alt="...">

鉴权与人机验证#

  • 管理后台ADMIN_SECRET 密钥登录(非用户名密码)
  • 脚本上传:独立 API_UPLOAD_TOKEN(可与管理密钥分开轮换)
  • Turnstile(可选):仅在 登录 时校验,上传/列表不再重复验证

技术栈#

技术用途
Nuxt 4 + Nuxt UI 4 + Tailwind 4管理界面
Nitrocloudflare_pages preset)REST API、R2 读写
Cloudflare R2私有对象存储
Cloudflare Worker图片代理、防盗链、边缘缓存
Turnstile(可选)登录防刷
GitHub Actionslint → 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,未登录 401

Session 不是随机 session id,而是对固定盐值 pic-sessionHMAC-SHA256(ADMIN_SECRET),再用 timing-safe equal 比对 Cookie,避免时序攻击。未登录请求在鉴权层直接拒绝,不会触达 R2

本地开发可设 DEV_BYPASS_ACCESS=true 跳过 Cookie 检查。

2. 上传(网页或脚本)#

客户端(可选 WebP 压缩)
↓ multipart
POST /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=86400

4. 删图与缓存 purge#

删除 R2 对象后,Pages 侧会 best-effort 调 Worker:

POST https://img.example.com/__internal/purge
Authorization: Bearer <INTERNAL_PURGE_TOKEN>
Body: { "keys": ["images/2026/08/xxxxx.webp"] }

INTERNAL_PURGE_TOKENpic-hostimage-proxy 必须相同。purge 失败不会阻止删 R2,只是边缘缓存可能短暂残留旧图。


API 一览#

鉴权#

方法路径说明
GET/api/auth/configTurnstile / Secret 配置状态
POST/api/auth/login登录
POST/api/auth/logout退出
GET/api/auth/me当前会话
方法路径说明
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.phptoken + image 表单字段(EasyImage 2.0)

上传成功返回示例:

{
"code": 200,
"result": "success",
"url": "https://img.example.com/images/2026/08/AbCdEfGhIjKl.webp",
"markdown": "![](https://img.example.com/images/2026/08/AbCdEfGhIjKl.webp)",
"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 桶#

Terminal window
npx wrangler login
npx wrangler r2 bucket create personal-images

Dashboard → R2 → personal-images

  • 关闭 r2.dev 公共访问
  • 不要绑定公开自定义域名

2. 部署 Pages(pic-host)#

Terminal window
npm install
cd workers/image-proxy && npm install && cd ../..
npm run build
npx 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_SECRETSecret管理登录
API_UPLOAD_TOKENSecretTwikoo / 脚本
IMAGE_BASE_URL变量https://img.example.com
IMAGE_WORKER_PURGE_URL变量https://img.example.com/__internal/purge
INTERNAL_PURGE_TOKENSecret与 Worker 一致
TURNSTILE_SITE_KEY变量可选
TURNSTILE_SECRETSecret可选,两个都配才启用

自检:

Terminal window
curl https://pic.example.com/api/auth/config

应看到 "adminSecretConfigured": true"apiUploadTokenConfigured": true

Secret 写入命令示例:

Terminal window
npx wrangler pages secret put ADMIN_SECRET --project-name pic-host
npx wrangler pages secret put API_UPLOAD_TOKEN --project-name pic-host
npx wrangler pages secret put INTERNAL_PURGE_TOKEN --project-name pic-host

3. 部署图片 Worker(image-proxy)#

Terminal window
cd workers/image-proxy
npx wrangler secret put INTERNAL_PURGE_TOKEN
npx wrangler deploy

Dashboard → image-proxy → 域和路由 → 添加 img.example.comcustom_domain: true,pattern 写整域,不要 img.example.com/*)。

4. GitHub Actions 自动部署#

推送 main 或手动触发 Deploy to Cloudflare

  1. npm run lint + typecheck
  2. nuxt build → 部署 pic-host
  3. 部署 image-proxy Worker

GitHub Secrets:

Secret说明
CLOUDFLARE_API_TOKEN需含 Pages Edit + Workers 权限
CLOUDFLARE_ACCOUNT_IDAccount 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.comwww.muxui.com 会在下一次 CI 后全部丢失

正确做法#

  1. workers/image-proxy/wrangler.jsonc 不要写 vars(仓库已刻意留空)。
  2. 只在 Dashboard 配置:
ALLOWED_REFERER_HOSTS=muxui.com,www.muxui.com,pic.example.com
PUBLIC_IMAGE_ORIGIN=https://img.example.com
  1. INTERNAL_PURGE_TOKENwrangler secret put,deploy 不会清空 Secret。
  2. 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.comexample.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.com60 次/分钟/IP
上传Host = pic... + Path 含 /api/images/upload20 次/分钟/IP
登录Path = /api/auth/login10 次/分钟/IP

免费版能力有限,至少开 Bot Fight Mode + 预算警报。


故障排查速查#

症状优先检查
「未配置 ADMIN_SECRET」Production 是否配 Secret;/api/auth/config
Twikoo 报未配置 TokenapiUploadTokenConfigured;变量名是否 API_UPLOAD_TOKEN
博客插图 403ALLOWED_REFERER_HOSTS 是否含博客域;deploy 后是否被 wrangler 覆盖
部署后图片域 5xx / 路由错误Worker 自定义域是否绑定;CLOUDFLARE_ACCOUNT_ID 是否同一账号
删图后仍看到旧图purge 失败可接受;等缓存过期或手动 purge
Actions 报 7003Token 权限、Account ID 是否为 32 位 hex

本地开发#

.env

Terminal window
ADMIN_SECRET=dev-secret
IMAGE_BASE_URL=http://localhost:8787
DEV_BYPASS_ACCESS=true

两个终端:

Terminal window
npm run dev # Nuxt,默认 :3000
npm run worker:dev # Worker,:8787

上传后返回的 URL 会指向 localhost:8787,便于联调 Worker 防盗链逻辑。


安全自检清单#

  • R2 关闭 r2.dev,桶未公开
  • ADMIN_SECRETAPI_UPLOAD_TOKEN,可独立轮换
  • ALLOWED_REFERER_HOSTS 仅 Dashboard 配置,wrangler.jsonc 无 vars
  • INTERNAL_PURGE_TOKEN Pages / Worker 一致
  • 上传仅 JPEG/PNG/WebP/GIF + 魔数校验
  • Turnstile 两个 Key 同时配置或同时不配
  • 博客域、www、管理后台域已在 Referer 白名单

写在最后#

回头看,PicHost 的演进路径很清晰:先为博客配图(CF + R2、自持、尽量不删即长期在),再为 Twikoo 评论配图(避开收费官方图床、自己适配 EasyImage 接口)。它没有去拼市面图床的花哨功能,而是把 私有 R2、双域分离、双 Token、Referer 防盗链、删图 purge 这些「个人博客用得着」的细节收进一个仓库。

我自己踩过最大的坑,仍是 ALLOWED_REFERER_HOSTS 别写进 wrangler.jsoncvars——每次 GitHub Actions 部署都可能把 Dashboard 里辛苦配好的博客域名覆盖掉,全站插图 403。Twikoo 接好后尤其要检查这一项:评论图和正文图都依赖 img 域,白名单漏了 www 或根域,读者端才会「只有我电脑能看」。

仓库:github.com/uurani/PicHost(MIT)。若你也在用 Firefly + Twikoo,可以把正文配图和评论配图都接到这一套上——同一桶、同一 CDN,省一份图床钱,也多一份「自己不删就还在」的踏实感。部署或对接有问题,欢迎 Issue / 评论区交流。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

[开源] PicHost - 部署在 Cloudflare 上的个人轻量图床
https://muxui.com/posts/pichost-cloudflare-r2-image-host/
作者
Muxui
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
基于 Cloudflare Workers + Telegram Bot + D1 的双向匿名聊天系统完整实现
原创在本篇文章中,我将分享一个基于 Cloudflare Workers + Telegram Bot + D1 数据库 的完全无服务器(Serverless)聊天系统。该机器人支持用户匿名与管理员双向通信,具备首访验证、自动诈骗检测、屏蔽管理
2
YOLO训练麻将(Mahjong)识别,导出ONNX
原创本文基于 Roboflow Universe 数据集(例如 $1 )与仓库内脚本,在 Windows 上完成训练,并导出 ONNX 供后端推理。下文配图均为本机实测截图,便于对照。快速体验 小程序 1\. 环境要求 \ Windows 10
3
从 WordPress 到 Astro迁移实战全记录
原创记录将 Muxui 从 WordPress(B2 Pro)完整迁移到 Astro 静态博客主题 Firefly 的全过程:导出 WXR、HTML 转 Markdown、图床改写、友链与导航定制、Twikoo 评论迁移,以及迁移中踩过的坑。
4
自建 Headscale + DERP 全流程实战记录
原创记录一次完整、可上线、可长期运行的 Headscale + 自建 DERP 搭建过程。 本文不是“能跑就行”的教程,而是 生产可用、已多客户端验证 的配置方案。 简介 Tailscale(Headscale)就是组建一个大的局域网,可以将你
5
[开源]AI Summary - WordPress智能摘要生成插件
原创AI Summary是一款专为WordPress设计的智能摘要生成插件,它集成了五大主流AI服务:百度文心一言、OpenAI ChatGPT、Google Gemini、字节豆包和阿里通义千问。无论您是个人博客作者还是企业网站运营者,这款插
随机文章随机推荐

评论区

Profile Image of the Author
Muxui
写代码、记笔记,把折腾过的东西留给未来的自己。
公告
欢迎来到我的博客!这是一则示例公告。
统计
--
总浏览量
--
访问数
--
游客数
分类
标签
站点统计
文章
21
分类
3
标签
24
总字数
53,261
运行时长
0
天气预报
定位中...
----
高温 --°C / 低温 --°C
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.15.5
文章许可
CC BY-NC-SA 4.0