GH-ImgBed —— 用 GitHub 存图 + Cloudflare 边缘分发,零成本自建图床
- 1GH-ImgBed —— 用 GitHub 存图 + Cloudflare 边缘分发,零成本自建图床本文
- 2Cloudflare 优选 IP 教程
- 3蓝奏云直链解析工具
- 4用 Cloudflare Workers + R2 搭建免费的 Serverless 云存储系统
一个零服务器成本的免费图床:图片存进你自己的 GitHub 仓库,通过 Cloudflare 边缘节点缓存分发。
前端是纯静态页面,后端只有两个 Cloudflare Pages Functions。没有数据库、没有自建服务器、没有构建步骤。
第三方图床要么有「X 个月未访问自动删除」,要么哪天就关停了。图片存在自己仓库里,数据在自己手上,链接也不会因为服务跑路而失效。
🏗️ 整体架构
浏览器从头到尾只跟 Cloudflare 边缘节点通信,回源由服务端完成。
关键点:访客所在网络能否直连 GitHub,并不影响访问 —— 回源这一步发生在服务端。
🔄 上传与访问时序
文件名格式是 YYMMDD-5位随机.扩展名,例如 261010-k7f2m.png。5 位随机空间约 6000 万,撞名概率极低,代码里仍保留了最多 6 次的换名重试。
🚀 部署流程
✨ 功能特性
⚙️ 环境变量
| 变量名 | 说明 | 必填 |
|---|---|---|
GITHUB_TOKEN | Fine-grained 令牌,仅对存图仓库开放 Contents 读写 | ✅ |
GITHUB_OWNER | 存图仓库的所有者用户名 | ✅ |
GITHUB_REPO | 存图仓库名 | ✅ |
GITHUB_PATH | 仓库内子目录,留空则存根目录(链接更短) | 可选 |
IMG_CDN | 设为 jsdelivr 可改走 jsDelivr 回源 | 可选 |
TURNSTILE_SECRET | Turnstile 的 Secret Key(不是 Site Key) | 可选 |
TURNSTILE_HOSTNAMES | 实际访问用的完整主机名,逗号分隔 | 启用验证时必填 |
1. 分支名固定为 images,不能自定义。
访问代理不校验路径,如果图片与代码同分支,代码文件可能被构造路径读到。固定孤儿分支是安全隔离的前提。
2. 务必把 Branch Preview 设为 None。
否则每次上传图片推送到 images 分支都会触发一次构建,很快耗尽每月 500 次的免费额度。
改完环境变量需要重新部署才生效。
assets/app.js 顶部的 TURNSTILE_SITEKEY 是本项目作者的,直接部署不会生效 —— 该 widget 的 domains 白名单里没有你的域名,验证组件会渲染失败。
const TURNSTILE_SITEKEY = "0x4AAAAAAFUKaXfCncBNv71l"; // ← 换成你自己的它与 TURNSTILE_SECRET 必须来自同一个 widget,跨 widget 混用同样会报 invalid-input-secret。
🔐 人机验证(可选)
防止脚本批量刷上传接口,基于 Cloudflare Turnstile。只保护上传,不保护 /v2/ —— 那是出图用的,加了验证图就无法外链了。
为什么需要两处配置
| 配置 | 位置 | 作用 |
|---|---|---|
| widget 的 domains | Turnstile 控制台 | 哪些域名能签发 token |
TURNSTILE_HOSTNAMES | 环境变量 | 只接受为你签发的 token |
两处都要配。只配控制台是不够的:任何人都能自己建一个 widget(域名填他自己的),通过挑战后把 token 提交给你的接口,Cloudflare 会如实回答 success=true —— 那确实是真的 token,只是不是为你签发的。所以后端必须比对 hostname。
一次验证放行整批
Turnstile token 是一次性的,无法在多张图之间复用。所以由后端签发凭证解决:
第 1 张 ──带 token──> siteverify ──通过──> 签发 HMAC 凭证(10 分钟)第 2..N 张 ──带凭证──> 校验签名与有效期 ──通过──> 写入 GitHub凭证是用 TURNSTILE_SECRET 做的 HMAC-SHA256 签名,无法伪造 —— 攻击者仍须先通过一次真实验证才能拿到。批次结束后前端重置组件,下次批量需重新验证一次。
未配置 TURNSTILE_SECRET 时行为不变(完全放行),可以随时启用或停用 —— 万一验证出问题,删掉这个变量就能立刻恢复。
两串都以 0x4AAAAAAA 开头,光看开头分辨不出来,是本项目最容易填错的地方。Site Key 已写在 index.html 里(公开无妨),Secret Key 只能进环境变量。填错会报 invalid-input-secret,若已混淆,直接在控制台重新生成一个最省事。
上传报「人机验证未通过」怎么排查
错误提示里会带上具体原因,对照这张表定位:
| 提示 | 原因 | 处理 |
|---|---|---|
invalid-input-secret | Secret Key 填错,或误填成了 Site Key | 重新复制该 widget 的 Secret Key |
| 主机名不匹配:本站接受 X,实际为 Y | 访问域名与 TURNSTILE_HOSTNAMES 不一致 | 按提示里的 Y 填进去 |
timeout-or-duplicate | token 已被用过,或超过 300 秒 | 刷新页面重新验证 |
invalid-input-response | token 无效或已过期 | 同上 |
missing-input-response | 前端没把 token 传上来 | 检查 widget 是否渲染成功 |
| 未收到验证凭据…收到的字段:file | 请求里完全没有验证字段 | 刷新页面;若持续出现,检查脚本是否被拦截 |
| 验证组件未加载,请刷新页面重试 | challenges.cloudflare.com 脚本没加载成功 | 检查网络能否访问该域名 |
实际踩过的坑,按概率排序:
- Secret Key 误填成 Site Key —— 两者长得几乎一样,最容易犯。
TURNSTILE_HOSTNAMES填了根域名(5al.top)而实际访问是子域名(img.5al.top)。- 改完环境变量没有重新部署,函数里读到的还是旧值。
- 控制台 widget 的 domains 里没加实际访问用的域名。
📁 目录结构
index.html 页面结构assets/app.css 样式:设计令牌、深浅主题、响应式assets/app.js 逻辑:上传、列表、复制、二维码、主题 └─ 含 TURNSTILE_SITEKEY,启用验证前须换成自己的assets/fonts.css Material Icons 字体声明(本地)assets/fonts/*.woff2 Material Icons 字体文件functions/upload.js 上传:写入 GitHubfunctions/v2/[[vkey]].js 访问:回源 + 边缘缓存scripts/verify_upload.py 部署前自检脚本图标字体是本地自托管的,不引用 fonts.googleapis.com(该域名在中国大陆不可访问)。字体名须与 mdui 期望的完全一致,且需开启 font-feature-settings: 'liga',否则连字不生效、图标会显示为文字。
assets/app.js 里的 CONCURRENCY 控制同时上传的数量,默认 1(串行)。多选图片时若并发过高,Cloudflare Functions 需同时做多次 base64 编码,可能超出免费额度的 CPU / 内存限制而被中断。除非确认额度充足,否则不建议调大。
✅ 部署前自检
export GITHUB_TOKEN='github_pat_xxx'export GITHUB_OWNER='你的用户名'export GITHUB_REPO='你的仓库'python3 scripts/verify_upload.py脚本会依次检查令牌有效性、仓库可访问性、写入权限、链接可访问性,并自动清理测试文件。
🔧 日常维护
删除单张图片
每次上传的响应里带有 sha,浏览器已存在 localStorage。用它可精确删除:
curl -X DELETE \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/<owner>/<repo>/contents/<path> \ -d '{"message":"delete","sha":"<上传时返回的 sha>","branch":"images"}'也可以在 GitHub 网页上直接删除文件。
边缘缓存最长保留 1 年。删除后如需立即生效,到 Cloudflare 后台手动清除缓存(Caching → Configuration → Purge Everything)。
更换令牌
令牌到期后重新生成一个,更新 Pages 的 GITHUB_TOKEN 环境变量并重新部署。
仓库满了
GitHub 建议单仓库控制在 1GB 以内。超了就新建仓库、建 images 孤儿分支、改 GITHUB_REPO 环境变量、重新部署。旧仓库的链接会失效,如需保留可保留旧仓库不动、只切换新图。
❓ 常见问题
上传返回 403 — 令牌缺 Contents: Read and write,或 Repository access 没包含该仓库。
上传返回 422 — 通常是同名文件冲突,代码会自动换名重试 6 次;若仍失败,检查分支是否存在。
多选上传时部分失败,提示「网络错误」 — 同时上传太多图片会让 Functions 并发做多次 base64 编码,可能超出免费额度的 CPU / 内存限制而被中断。默认已串行(CONCURRENCY = 1),若仍失败就一次少选几张。
图片上传了但打不开 — 确认 images 分支存在且文件确实写入。访问代理回源的是 raw.githubusercontent.com/<owner>/<repo>/images/<path>。
刷新后上传历史没了 — 历史存在浏览器 localStorage,按域名隔离。换了域名后旧记录读不到,这是浏览器行为,不是故障。
图标显示为 cloud_upload 这样的英文 — Material Icons 字体没加载。确认 assets/fonts/*.woff2 能访问,且字体名与 mdui 期望的一致。
⚠️ 已知限制
| 限制 | 说明 |
|---|---|
| 单文件 20MB | 代码中已做校验 |
| 访问路径未做白名单 | 依赖 images 孤儿分支隔离,不要往该分支放非图片文件 |
| GitHub API 限额 | 5000 次/小时,个人图床足够 |
| Cloudflare 免费额度 | 请求 10 万/天,构建 500 次/月 |
| 仓库体积 | 建议控制在 1GB 以内 |
| 国内访问 | Cloudflare 在中国大陆无边缘节点,访客可能被路由到境外。可设 IMG_CDN=jsdelivr 改走 jsDelivr 回源 |
| 外部依赖 | 启用人机验证后会加载 challenges.cloudflare.com 脚本(验证的必要条件)。其余资源全部本地 |
🛠️ 技术栈
| 部分 | 说明 |
|---|---|
| 页面 | 原生 HTML + JavaScript,无框架、无构建 |
| 组件 | mdui 2(Web Components,CDN 引入) |
| 图标 | Material Icons 字体(本地自托管) |
| 二维码 | qrcode(CDN,按需动态加载) |
| 人机验证 | Cloudflare Turnstile(可选,仅保护上传接口) |
| 后端 | Cloudflare Pages Functions |
📄 许可
MIT License — 可自由使用、修改和分发
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

