技术杂烩· · 发布于 2026-01-17 14:01:59

AI手搓一个基于cloudflareR2储存桶的图床,从此不再依赖公益图床

核心思路
我们将利用 Cloudflare Workers (无服务器代码) 搭建一个简易的网页,这个网页不仅长得像个上传工具,背后的代码还能帮你把图片存进 R2 存储桶,并自动吐出 Markdown 链接。

第一步:准备 R2 存储桶(如果还没建)
1.登录 Cloudflare 后台。
2.左侧菜单栏点击 R2。
3.点击 创建存储桶,起个名字(比如 my-blog-imgs),点击 创建存储桶。

1.:warning: 关键:绑定域名(为了图片能公开访问且免费)
进入刚才建好的桶,点击上方的 设置 标签。
向下滚动找到 公开访问 区域里的 自定义域。
点击 连接域,输入一个你自己的二级域名(例如 img.mpsboring.com),按照提示保存。
(状态显示“有效”后,你的图片链接就是 https://img.mpsboring.com/文件名)。

第二步:创建 Worker(上传工具的后端)
1.左侧菜单栏点击 Workers 和 Pages。
2.点击 创建应用程序 按钮。
3.点击 创建 Worker 按钮。
4.名称可以保持默认或改为 r2-uploader,点击 部署。
5.现在的页面是“恭喜!Worker 已部署”,先别急着编辑代码,我们要先去设置权限。
第三步:绑定 R2 和设置密码(最重要的一步)
点击刚才创建的 Worker 页面中的 设置 标签,然后选择 变量。

  1. 绑定 R2 存储桶(让代码能读写你的桶)
找到 R2 存储桶绑定 区域,点击 添加绑定。


变量名称:填入 IMG_BUCKET (必须填这个,一字不差,因为代码里要用)。

R2 存储桶:在下拉菜单中选择你在第一步创建的那个桶(如 my-blog-imgs)。

点击 部署 保存。

  1. 设置上传密码(防止陌生人乱传图)
1.还在 变量 页面,找到上面的 环境变量 区域,点击 添加变量。 2.变量名称:填入 UPLOAD_TOKEN。 3.值:设置一个属于你的密码(比如 mima123456)。 4.点击 部署 保存。 第四步:写入代码 1.点击页面上方的 编辑代码 按钮。 2.你会看到左侧有一个 worker.js 文件,把里面所有的代码全删掉。 3.复制并粘贴以下代码:
/**
 * Cloudflare R2 图床 (修复中文文件名报错版)
 */

export default {
async fetch(request, env) {
const url = new URL(request.url);

// === 1. 后端逻辑 ===
if (request.method === 'POST') {
const auth = request.headers.get('Authorization');
if (auth !== env.UPLOAD_TOKEN) {
return new Response('❌ 密码错误', { status: 403 });
}

// 🛑 修复点 1:获取文件名时进行解码
// 如果客户端发来的是 "%E6%B5%8B.png",这里还原成 "测.png"
let rawName = request.headers.get('File-Name');
let filename = 'unknown.png';
try {
filename = decodeURIComponent(rawName);
} catch (e) {
filename = rawName; // 如果解码失败,就用原始的
}

const fileExt = filename.split('.').pop();
const date = new Date();
const path = `${date.getFullYear()}/${(date.getMonth()+1).toString().padStart(2,'0')}`;
// 生成随机文件名,保留原后缀
const randomName = `${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`;
const finalPath = `${path}/${randomName}`;

await env.IMG_BUCKET.put(finalPath, request.body);

// ⚠️⚠️⚠️【请修改这里】⚠️⚠️⚠️
// 换成你的自定义域名
const r2Domain = 'https://img.yourdomain.com';

return new Response(`${r2Domain}/${finalPath}`);
}

// === 2. 前端界面 ===
return new Response(html, {
headers: { 'Content-Type': 'text/html;charset=UTF-8' },
});
},
};

// 修复后的 HTML 界面
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>R2 云图床</title>
<style>
:root { --primary: #4F46E5; --primary-hover: #4338ca; --bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); --card-bg: rgba(255, 255, 255, 0.95); }
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background: var(--bg-gradient); height: 100vh; margin: 0; display: flex; justify-content: center; align-items: center; color: #1f2937; }
.container { width: 90%; max-width: 450px; background: var(--card-bg); backdrop-filter: blur(10px); border-radius: 20px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); padding: 2rem; }
.header { text-align: center; margin-bottom: 1.5rem; }
.header h1 { margin: 0; font-size: 1.5rem; color: #111827; }
.header p { margin: 5px 0 0; color: #6b7280; font-size: 0.9rem; }
.auth-group input { width: 100%; padding: 12px 16px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; box-sizing: border-box; background: #f9fafb; outline: none; margin-bottom: 1.5rem; }
.upload-zone { border: 2px dashed #e5e7eb; border-radius: 16px; padding: 2.5rem 1.5rem; text-align: center; cursor: pointer; transition: all 0.3s; background: #f9fafb; }
.upload-zone:hover, .upload-zone.dragover { border-color: var(--primary); background: #eef2ff; transform: scale(1.01); }
.upload-zone svg { width: 48px; height: 48px; color: #9ca3af; margin-bottom: 10px; }
#result-area { display: none; margin-top: 1.5rem; animation: fadeIn 0.4s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.preview-img { width: 100%; height: 160px; object-fit: cover; border-radius: 10px; border: 1px solid #e5e7eb; margin-bottom: 1rem; }
.link-group { display: flex; gap: 10px; }
.btn { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 6px; font-size: 0.9rem; }
.btn-primary { background: var(--primary); color: white; }
.btn-outline { background: white; border: 1px solid #e5e7eb; color: #374151; }
#status-msg { margin-top: 10px; text-align: center; font-size: 0.9rem; min-height: 20px; }
.error { color: #ef4444; } .success { color: #10b981; } .loading { color: var(--primary); }
</style>
</head>
<body>
<div class="container">
<div class="header"><h1>☁️ R2 图床</h1><p>拖拽图片 / Ctrl+V 粘贴 / 点击上传</p></div>
<div class="auth-group"><input type="password" id="token" placeholder="🔐 请输入访问密码"></div>
<div class="upload-zone" id="dropZone">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<p>点击选择图片</p>
<input type="file" id="fileInput" hidden accept="image/*">
</div>
<div id="status-msg"></div>
<div id="result-area">
<img id="preview" class="preview-img" src="">
<div class="link-group">
<button class="btn btn-outline" onclick="copyText(imgUrl, this)">🔗 复制链接</button>
<button class="btn btn-primary" onclick="copyText(mdUrl, this)">⬇️ 复制 Markdown</button>
</div>
</div>
</div>
<script>
const tokenInput = document.getElementById('token');
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const statusMsg = document.getElementById('status-msg');
let imgUrl = '', mdUrl = '';

tokenInput.value = localStorage.getItem('r2_token') || '';
tokenInput.addEventListener('input', () => localStorage.setItem('r2_token', tokenInput.value));

dropZone.onclick = () => fileInput.click();
fileInput.onchange = (e) => handleUpload(e.target.files[0]);
dropZone.ondragover = (e) => { e.preventDefault(); dropZone.classList.add('dragover'); };
dropZone.ondragleave = () => dropZone.classList.remove('dragover');
dropZone.ondrop = (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); handleUpload(e.dataTransfer.files[0]); };
document.onpaste = (e) => { const item = e.clipboardData.items[0]; if (item && item.kind === 'file') handleUpload(item.getAsFile()); };

async function handleUpload(file) {
if (!file) return;
if (!file.type.startsWith('image/')) return showStatus('⚠️ 只能上传图片', 'error');
if (!tokenInput.value) { shake(tokenInput); return showStatus('🔐 请输入密码', 'error'); }

document.getElementById('result-area').style.display = 'none';
showStatus('⏳ 上传中...', 'loading');

try {
// 🛑 修复点 2:使用 encodeURIComponent 对文件名编码
// 这样 "微信.png" 会变成 "%E5%BE%AE%E4%BF%A1.png",浏览器就不会报错了
const res = await fetch('/', {
method: 'POST',
headers: {
'Authorization': tokenInput.value,
'File-Name': encodeURIComponent(file.name)
},
body: file
});

if (!res.ok) throw new Error(await res.text());
const url = await res.text();
showSuccess(url);
} catch (err) {
showStatus('❌ 失败: ' + err.message, 'error');
}
fileInput.value = '';
}

function showSuccess(url) {
imgUrl = url; mdUrl = \`![](\${url})\`;
document.getElementById('preview').src = url;
document.getElementById('result-area').style.display = 'block';
showStatus('✅ 上传成功', 'success');
}
function showStatus(text, type) { statusMsg.innerText = text; statusMsg.className = type; }
function copyText(text, btn) { navigator.clipboard.writeText(text).then(() => { const old = btn.innerText; btn.innerText = '✅ 已复制'; setTimeout(() => btn.innerText = old, 1500); }); }
function shake(el) { el.animate([{transform:'translateX(0)'},{transform:'translateX(-10px)'},{transform:'translateX(10px)'},{transform:'translateX(0)'}],{duration:300}); el.focus(); }
</script>
</body>
</html>
`;


1.修改一处代码:找到代码中写着 const r2Domain = ‘https://img.mpsboring.com’ 的那一行。一定要把它改成你在第一步里绑定的域名(例如 https://img.mpsboring.com)。
2.点击右上角的 部署。
第五步:如何使用?
1.部署成功后,你会看到 Cloudflare 给你提供了一个类似 https://r2-uploader.xxxx.workers.dev 的链接。
2.点击打开这个链接,这就是你的图床界面了(建议收藏到浏览器书签)。
https://img.mpsboring.com/uploads/img/20260117/...
首次使用:
1.在输入框里填入你在“环境变量”里设置的那个密码。
2.密码会自动保存在浏览器缓存里,下次不用输了。
上传方式:
1.写文章时截个图,切到这个网页,直接按 Ctrl + V 粘贴。
2.或者把图片文件拖进去。
获取链接:
上传完成后,点击“复制 Markdown 链接”,然后回到你的文章编辑器里粘贴即可。
常见问题排错
1.报错 403 / 密码错误:检查你在网页填的密码,和在 Worker 设置 → 变量 → 环境变量里填的 UPLOAD_TOKEN 是否一致。
2.报错 500 / Internal Server Error:
3.检查 Worker 设置 → 变量 → R2 存储桶绑定,变量名是不是 IMG_BUCKET(全大写)。
4.检查代码里的 r2Domain 有没有改成你自己的域名。
5.图片能上传但链接打不开:
去 R2 存储桶的设置里,检查“自定义域”是不是状态正常的。
不要用 Cloudflare 给的 r2.dev 结尾的域名,那个国内访问慢且有次数限制。
另外:CF的R2储存桶是按存量算的,只要不超过10G就不收费,相当于一个10G小U盘。
cloudflare r2储存桶到底是怎么收费的?

Cloudflare R2 的收费模式非常简单,其核心卖点是没有流量费(零 Egress Fees),这与 AWS S3 等传统云存储有显著区别。

R2 的费用主要由三部分组成:存储量、A 类操作(写入)和 B 类操作(读取)。

  1. 核心收费标准
如果你超出了免费额度,将按照以下标准计费:
费用项目标准存储 (Standard)低频访问 (Infrequent Access)
存储费用$0.015 / GB-月$0.01 / GB-月
A 类操作 (上传、列表等)$4.50 / 百万次请求$9.00 / 百万次请求
B 类操作 (下载、获取元数据)$0.36 / 百万次请求$0.90 / 百万次请求
数据检索 (读取数据量)免费$0.01 / GB
流量费 (Egress)始终免费始终免费
  1. 免费额度 (每月)
Cloudflare 提供了一个非常慷慨的“永久免费额度”,对于很多小规模项目来说,几乎不需要付钱:

存储空间:前 10 GB 免费。

  1. A 类操作:每月前 100 万次 免费。
  2. B 类操作:每月前 1000 万次 免费。

注意:免费额度仅适用于“标准存储”。低频访问存储没有免费额度。


  1. 如何区分 A 类和 B 类操作?

了解这两者的区别能帮你省下不少钱:

A 类操作(高价值/修改类):
PutObject (上传文件)
ListBuckets (列出存储桶)
CreateBucket (创建存储桶)
CopyObject (复制文件)
B 类操作(低价值/读取类):
GetObject (下载/读取文件)
HeadObject (获取文件元数据)
Usage Summary (查看使用摘要)

  1. 关键点提示

1.没有流量坑:在 S3 中,如果你的图片被疯传,带宽费(Egress)可能会让你破产。在 R2 中,无论用户下载多少次,你只需要支付极低的“B 类操作费”(每 100 万次才 0.36 美元)。
2.低频访问的“陷阱”:虽然它的存储单价更便宜($0.01),但它有数据检索费(读取 1GB 收 $0.01),且操作费翻倍。只有当你确定文件存进去后几乎不看(比如备份),才选低频访问。
3.最小计费周期:存储费是按“GB-月”计算的。如果你存了 100GB 刚好半个月然后删了,会按 50GB-月 计费。
如果我1月存了9.5G,第二个月存5g,会不会收费

简单直接的回答是:第一个月不收费,第二个月开始收费。

R2 的免费额度是按账户下所有文件的总占用量来计算的,而不是看你每个月“新增”了多少。你可以把 R2 的免费空间想象成一个 10GB 大小的 U 盘。

登录后操作
暂无回复
🛡️ 权限设置
提示:选择"私有"会覆盖等级限制。
app
安装到桌面,像 App 一样使用
打开更快 · 全屏体验 · 入口常驻

iPhone/iPad 安装到桌面

  1. 使用 Safari 打开本站(微信/QQ 内置浏览器不稳定)。
  2. 点击底部 分享 按钮(方框上箭头)。
  3. 选择 添加到主屏幕,确认即可。
首页
搜索
动态
发帖
私信
我的