使用 Cloudflare R2 + Workers 搭建个人图床
一、基本功能
实现:
- 上传地址:https://your_uploadimg_domain
- 图片外链:https://your_readimg_domain
- R2 存储图片
- Worker 做登录验证 + 上传 + 管理后台 管理页功能:
- 按月份分组
- 每页 20 张
- 每行 4 张
- 分页
- 图片备注(自动保存)
- 删除图片
- 一键复制直链
- 一键复制 Markdown 链接
- ctrl+v粘贴图片上传
- 拖放图片到地址栏下方区域上传
1.1、准备条件
- 已将域名接入 Cloudflare
- 已开启 R2
- 已绑定支付方式(不会自动扣费,超额才收费,当前免费额度见第十章节)
1.2、创建 R2 存储桶(图片存储)
1.2.1、 创建存储桶
进入:
Cloudflare → R2 对象存储
点击:
创建存储桶
填写:
名称:dex-img
类型:标准(Standard)
创建完成。
1.2.2、绑定图片域名
进入:
R2 → dex-img → 设置 → 公开访问 → 自定义域
填写:
your_readimg_domain
完成绑定。
1.2.3、DNS 添加
进入 DNS:
添加记录:
类型 名称 内容 代理
R2 your_readimg_domain 已代理
1.3、创建上传 Worker
进入:
Cloudflare → Workers 和 Pages → 创建 Worker
命名:
dex-upload
1.4、绑定 R2
进入:
dex-upload → 绑定 → 添加绑定
选择:
R2 存储桶
变量名称:
BUCKET
选择:
dex-img
保存。
1.5、创建 KV(存储备注)
进入:
Workers 和 Pages → KV 命名空间
创建:
dex-img-notes
然后回到Worker → 绑定 → 添加绑定:
类型:
KV 命名空间
变量名称:
NOTES
选择:
dex-img-notes
保存。
1.6、添加用户名密码
进入:
Worker → 设置 → 变量 → 机密变量(Secrets)
添加两个:
BASIC_USER = 用户名
BASIC_PASS = 你的密码
保存后点击:
👉 再部署一次 Worker
1.7、添加上传域名
进入:
Worker → 概述 → 域和路由 → 添加
填写:
your_uploadimg_domain/*
保存。
DNS 添加
DNS → 添加记录:
类型 名称 内容 代理
A uploadimg 192.0.2.1 已代理
1.8、Worker 完整代码
将以下代码全部替换到 Worker 中,然后点击“部署”。
// Cloudflare Worker: R2 图床(上传 + 历史管理 /list + 拖放上传 + Ctrl+V 粘贴上传 + 备注(KV) + 删除 + 复制链接)//// 需要绑定:// 1) R2 存储桶:BUCKET// 2) KV 命名空间:NOTES// 3) Secrets:BASIC_USER, BASIC_PASS//// 路由:上传图片的域名/*//// 外链域名:上传图片的域名 (R2 公开桶自定义域)// 文件 key 结构:YYYY/MM/DD/<random>.<ext>
function unauthorized() { return new Response("需要登录", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="uploadimg"' }, });}
function checkAuth(request, env) { const auth = request.headers.get("Authorization"); if (!auth?.startsWith("Basic ")) return false; const raw = atob(auth.slice(6)); const i = raw.indexOf(":"); if (i < 0) return false; const u = raw.slice(0, i); const p = raw.slice(i + 1); return u === env.BASIC_USER && p === env.BASIC_PASS;}
function htmlEscape(s) { return String(s) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'");}
function ymdFromKey(key) { const m = key.match(/^(\d{4})\/(\d{2})\/(\d{2})\//); if (!m) return { ym: "未知月份", ymd: "未知日期" }; const ym = `${m[1]}-${m[2]}`; const ymd = `${m[1]}-${m[2]}-${m[3]}`; return { ym, ymd };}
function nowDatePath() { const now = new Date(); return ( now.getFullYear() + "/" + String(now.getMonth() + 1).padStart(2, "0") + "/" + String(now.getDate()).padStart(2, "0") );}
async function handleUploadFile(file, env) { if (!(file instanceof File)) return { ok: false, msg: "没有文件" };
// 限制大小 10MB if (file.size > 10 * 1024 * 1024) return { ok: false, msg: "文件超过 10MB 限制" };
// 只允许图片 const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; if (!allowedTypes.includes(file.type)) return { ok: false, msg: "只允许上传图片文件" };
const datePath = nowDatePath(); const ext = (file.name.split(".").pop() || "png").toLowerCase(); const token = crypto.randomUUID().replace(/-/g, ""); const key = `${datePath}/${token}.${ext}`;
await env.BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" }, });
const imgUrl = `https://上传图片的域名/${key}`; return { ok: true, key, url: imgUrl };}
export default { async fetch(request, env) { const url = new URL(request.url); const path = url.pathname;
// 全站要求登录(上传/管理/API) if (!checkAuth(request, env)) return unauthorized();
// ============ 上传页(根路径) ============ if (request.method === "GET" && path === "/") { return new Response( `<!doctype html><meta charset="utf-8"> <title>Upload</title> <h2>图片上传</h2> <p><a href="/list">进入历史/管理</a></p> <form method="post" enctype="multipart/form-data"> <input type="file" name="file" required> <button type="submit">上传</button> </form>`, { headers: { "content-type": "text/html; charset=utf-8" } } ); }
// ============ 上传处理(根路径表单上传) ============ if (request.method === "POST" && path === "/") { const ct = request.headers.get("content-type") || ""; if (!ct.includes("multipart/form-data")) { return new Response("请用表单上传", { status: 400 }); }
const form = await request.formData(); const file = form.get("file"); const r = await handleUploadFile(file, env);
if (!r.ok) return new Response(r.msg, { status: 400 });
return new Response( `上传成功
直链:${r.url}
Markdown:
管理页:/list`, { headers: { "content-type": "text/plain; charset=utf-8" } } ); }
// ============ API:上传(给 /list 拖放/粘贴用) ============ if (request.method === "POST" && path === "/api/upload") { const ct = request.headers.get("content-type") || ""; if (!ct.includes("multipart/form-data")) { return new Response("请用 multipart/form-data", { status: 400 }); } const form = await request.formData(); const file = form.get("file"); const r = await handleUploadFile(file, env); if (!r.ok) { return new Response(JSON.stringify({ ok: false, msg: r.msg }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" }, }); } return new Response(JSON.stringify(r), { headers: { "content-type": "application/json; charset=utf-8" }, }); }
// ============ API:保存备注 ============ if (request.method === "POST" && path === "/api/note") { const body = await request.json().catch(() => null); if (!body?.key) return new Response("bad request", { status: 400 }); const note = String(body.note ?? "").slice(0, 500); await env.NOTES.put(body.key, note); return new Response("ok"); }
// ============ API:删除图片 + 删除备注 ============ if (request.method === "POST" && path === "/api/delete") { const body = await request.json().catch(() => null); if (!body?.key) return new Response("bad request", { status: 400 }); await env.BUCKET.delete(body.key); await env.NOTES.delete(body.key); return new Response("ok"); }
// ============ 管理页:/list ============ if (request.method === "GET" && path === "/list") { const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10)); const pageSize = 20; const startIndex = (page - 1) * pageSize;
// 拉取最多 2000 个 key(个人图床够用) let cursor; let all = []; for (let i = 0; i < 20; i++) { const res = await env.BUCKET.list({ cursor, limit: 100 }); all.push(...res.objects.map((o) => o.key)); cursor = res.cursor; if (!res.truncated || all.length >= 2000) break; }
// 最新在前 all = all.slice().reverse();
const total = all.length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const keys = all.slice(startIndex, startIndex + pageSize);
const notesArr = await Promise.all(keys.map((k) => env.NOTES.get(k))); const noteMap = new Map(keys.map((k, i) => [k, notesArr[i] || ""]));
// 当前页按月份分组 const groups = new Map(); for (const key of keys) { const { ym, ymd } = ymdFromKey(key); const arr = groups.get(ym) || []; arr.push({ key, ymd, note: noteMap.get(key) }); groups.set(ym, arr); }
const makePager = () => { const prev = page > 1 ? page - 1 : 1; const next = page < totalPages ? page + 1 : totalPages; return ` <div class="pager"> <a class="btn" href="/list?page=1">首页</a> <a class="btn" href="/list?page=${prev}">上一页</a> <span class="info">第 ${page} / ${totalPages} 页(共 ${total} 张)</span> <a class="btn" href="/list?page=${next}">下一页</a> <a class="btn" href="/list?page=${totalPages}">末页</a> </div> `; };
let bodyHtml = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>历史/管理</title><style> body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; margin:20px;} a{color:inherit} .top{display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;} .pager{display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:14px 0;} .btn{border:1px solid #ddd; padding:6px 10px; border-radius:10px; text-decoration:none; background:#fff;} .info{color:#666;} h2{margin:22px 0 10px;} .grid{display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:14px;} @media (max-width: 1100px){ .grid{grid-template-columns:repeat(3, 1fr);} } @media (max-width: 800px){ .grid{grid-template-columns:repeat(2, 1fr);} } @media (max-width: 520px){ .grid{grid-template-columns:repeat(1, 1fr);} } .card{border:1px solid #eee; border-radius:14px; padding:10px; background:#fff;} .thumb{width:100%; aspect-ratio: 16/10; object-fit:cover; border-radius:10px; background:#f5f5f5;} .meta{display:flex; justify-content:space-between; align-items:center; margin:8px 0; color:#666; font-size:12px;} .key{font-size:12px; word-break:break-all; color:#333;} .row{display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;} button{border:1px solid #ddd; background:#fff; padding:6px 10px; border-radius:10px; cursor:pointer;} textarea{width:100%; box-sizing:border-box; margin-top:8px; border:1px solid #ddd; border-radius:10px; padding:8px; min-height:54px; resize:vertical;} .toast{position:fixed; right:16px; bottom:16px; background:#111; color:#fff; padding:10px 12px; border-radius:10px; opacity:0; transform:translateY(10px); transition:all .2s;} .toast.show{opacity:1; transform:translateY(0);} .drop{border:2px dashed #ddd;border-radius:14px;padding:14px;margin:14px 0;background:#fafafa;}</style>
<div class="top"> <div> <a class="btn" href="/">返回上传</a> <span class="info">每页 20 张 / 每行 4 张</span> </div> <div>${makePager()}</div></div>
<div id="drop" class="drop" tabindex="0" title="拖放图片到这里,或 Ctrl+V 粘贴截图"> <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;"> <div> <b>拖放上传 / Ctrl+V 粘贴上传</b>(也可点击选择文件) <div class="info" style="font-size:12px;margin-top:4px;">支持 jpg/png/webp/gif,≤10MB;把焦点放在本页面直接 Ctrl+V</div> </div> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> <input id="pick" type="file" accept="image/*" multiple style="display:none;"> <button id="pickBtn" class="btn" type="button">选择图片</button> <span id="upStatus" class="info"></span> </div> </div></div>
${makePager()}`;
for (const [ym, items] of groups) { bodyHtml += `<h2>${htmlEscape(ym)}</h2><div class="grid">`; for (const it of items) { const imgUrl = `https://上传图片的域名/${it.key}`; bodyHtml += ` <div class="card" data-key="${htmlEscape(it.key)}" data-url="${htmlEscape(imgUrl)}"> <a href="${htmlEscape(imgUrl)}" target="_blank" rel="noreferrer"> <img class="thumb" loading="lazy" src="${htmlEscape(imgUrl)}" alt=""> </a> <div class="meta"> <span>${htmlEscape(it.ymd)}</span> <span>✅</span> </div> <div class="key">${htmlEscape(it.key)}</div>
<div class="row"> <button class="copy-link">复制直链</button> <button class="copy-md">复制 Markdown</button> <button class="del">删除</button> </div>
<textarea class="note" placeholder="备注(自动保存)...">${htmlEscape(it.note || "")}</textarea> </div>`; } bodyHtml += `</div>`; }
bodyHtml += `${makePager()}
<div id="toast" class="toast">已复制</div>
<script> const toast = document.getElementById('toast'); let toastTimer; function showToast(msg){ toast.textContent = msg; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(()=>toast.classList.remove('show'), 1200); }
async function copyText(text){ await navigator.clipboard.writeText(text); showToast('已复制'); }
document.addEventListener('click', async (e) => { const card = e.target.closest('.card'); if(!card) return; const key = card.dataset.key; const url = card.dataset.url;
if(e.target.classList.contains('copy-link')){ await copyText(url); }
if(e.target.classList.contains('copy-md')){ await copyText(''); }
if(e.target.classList.contains('del')){ if(!confirm('确定删除这张图片吗?')) return; const r = await fetch('/api/delete', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({key}) }); if(r.ok){ card.remove(); showToast('已删除'); }else{ showToast('删除失败'); } } });
// 备注自动保存(防抖) const timers = new Map(); document.addEventListener('input', (e) => { if(!e.target.classList.contains('note')) return; const card = e.target.closest('.card'); const key = card.dataset.key; const note = e.target.value;
clearTimeout(timers.get(key)); timers.set(key, setTimeout(async () => { const r = await fetch('/api/note', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({key, note}) }); showToast(r.ok ? '备注已保存' : '保存失败'); }, 500)); });
// ===== 拖放上传 / 选择上传 / Ctrl+V 粘贴上传 ===== const drop = document.getElementById('drop'); const pick = document.getElementById('pick'); const pickBtn = document.getElementById('pickBtn'); const upStatus = document.getElementById('upStatus');
function setDropActive(active){ drop.style.borderColor = active ? '#999' : '#ddd'; drop.style.background = active ? '#f0f0f0' : '#fafafa'; }
pickBtn.addEventListener('click', () => pick.click()); drop.addEventListener('click', (e) => { if (e.target === pickBtn) return; pick.click(); });
drop.addEventListener('dragover', (e) => { e.preventDefault(); setDropActive(true); }); drop.addEventListener('dragleave', () => setDropActive(false)); drop.addEventListener('drop', async (e) => { e.preventDefault(); setDropActive(false); const files = [...(e.dataTransfer?.files || [])]; if (files.length) await uploadFiles(files); });
pick.addEventListener('change', async () => { const files = [...(pick.files || [])]; pick.value = ''; if (files.length) await uploadFiles(files); });
// Ctrl+V 粘贴上传:从剪贴板提取图片 window.addEventListener('paste', async (e) => { const items = e.clipboardData?.items ? [...e.clipboardData.items] : []; if (!items.length) return;
const files = []; for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f && f.type && f.type.startsWith('image/')) { // 截图/粘贴图片常见是无文件名,这里给个默认名 const ext = (f.type.split('/')[1] || 'png').toLowerCase(); const named = new File([f], 'pasted.' + ext, { type: f.type }); files.push(named); } } } if (files.length) { e.preventDefault(); await uploadFiles(files); } });
async function uploadFiles(files){ // 仅图片 files = files.filter(f => f && f.type && f.type.startsWith('image/')); if (!files.length) return;
upStatus.textContent = \`开始上传 \${files.length} 个...\`;
let ok = 0, fail = 0;
for (let i = 0; i < files.length; i++) { const f = files[i]; upStatus.textContent = \`上传中 \${i+1}/\${files.length}:\${f.name}\`;
const fd = new FormData(); fd.append('file', f, f.name);
try{ const r = await fetch('/api/upload', { method:'POST', body: fd }); if(!r.ok) throw new Error(await r.text()); const j = await r.json(); if(j?.ok){ ok++; }else{ fail++; } }catch(err){ fail++; } }
upStatus.textContent = \`完成:成功 \${ok},失败 \${fail}。正在刷新...\`; showToast(\`上传完成:成功 \${ok},失败 \${fail}\`);
setTimeout(()=>location.reload(), 600); }</script>`; return new Response(bodyHtml, { headers: { "content-type": "text/html; charset=utf-8" }, }); }
return new Response("Not Found", { status: 404 }); },};1.9、使用方式
1.9.1.上传图片
访问:
https://your_uploadimg_domain登录后上传图片。
图片外链格式
https://your_readimg_domain/YYYY/MM/DD/随机字符串.jpg
1.9.2.管理后台
访问:
https://your_uploadimg_domain/list功能:
- 按月份分组
- 每页 20 张
- 每行 4 张
- 备注自动保存
- 删除图片
- 一键复制直链
- 一键复制 Markdown
1.10、免费额度
- R2 存储 10GB
- 每月 100万写请求
- 每月 1000万读请求
- Workers 每天 10万请求
- 个人使用基本永久免费。
1.11、最终结构图
用户上传
↓
上传地址:https://your_uploadimg_domain
↓
Worker(登录验证)
↓
R2 存储桶 dex-img
↓
图片外链:https://your_readimg_domain(公开访问)
完成
1.12、你现在拥有
✔ 独立图床 ✔ 自有域名 ✔ 无广告 ✔ 免费 CDN ✔ 管理后台 ✔ 数据完全可控
二、使用cf图片缓存
创建2个规则。路径:
域名——缓存——Cache Rules
2.1、规则 1:后台绕过缓存
- 名称:
upload-no-cache- 选择:
编辑表达式
- 框里填写:
http.host eq "上传图片的域名"- 缓存资格:
绕过缓存
- 放置位置:
第一个
- 部署:
点击部署,完成设置
2.2、规则 2:图片缓存(加速 + 降低 R2 读次数)
- 名称:
img-cache-precise- 选择:
编辑表达式
- 表达式:
(http.host eq "浏览使用图片的域名" and (ends_with(http.request.uri.path, ".jpg") orends_with(http.request.uri.path, ".jpeg") orends_with(http.request.uri.path, ".png") orends_with(http.request.uri.path, ".gif") orends_with(http.request.uri.path, ".webp") orends_with(http.request.uri.path, ".avif")))- 缓存资格:
符合缓存条件
- 边缘 TTL:
添加设置——忽略缓存控制标头,使用此 TTL—— 1个月
- 放置位置:
最后一个
- 部署:
点击部署,完成设置
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
部分内容可能已过时
Firefly