2697 字
13 分钟

使用 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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:
![](${r.url})
管理页:
/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('![](' + url + ')');
}
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") or
ends_with(http.request.uri.path, ".jpeg") or
ends_with(http.request.uri.path, ".png") or
ends_with(http.request.uri.path, ".gif") or
ends_with(http.request.uri.path, ".webp") or
ends_with(http.request.uri.path, ".avif")
))
  • 缓存资格:

符合缓存条件

  • 边缘 TTL:

添加设置——忽略缓存控制标头,使用此 TTL—— 1个月

  • 放置位置:

最后一个

  • 部署:

点击部署,完成设置

文章分享

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

使用 Cloudflare R2 + Workers 搭建个人图床
https://blog.rax.pp.ua/posts/picture-bed-on-cloudflare-r2-worker/
作者
DH
发布于
2026-02-11
许可协议
CC BY-NC-SA 4.0
最后更新于 2026-02-11,距今已过 43 天

部分内容可能已过时

目录