基于 Cloudflare R2 的自建图床搭建指南

By | 2026-01-15

基于 Cloudflare R2 的自建图床搭建步骤

核心思路

利用 Cloudflare Workers 构建一个网页上传工具,将图片存入 R2 存储桶,并自动返回 Markdown 格式的图片链接。


步骤一:创建 R2 存储桶

  1. 登录 Cloudflare 控制台。
  2. 左侧菜单 → R2 → 点击 创建存储桶
  3. 输入名称(如 my-blog-imgs)。
  4. 存储类型必须选择“标准”(Standard),以享受免费额度。
  5. 点击 创建存储桶

步骤二:绑定自定义域名(实现公开访问)

  1. 进入刚创建的存储桶 → 点击 设置 标签。
  2. 滚动至 公开访问 区域 → 找到 自定义域
  3. 点击 连接域,输入你的二级域名(如 img.yourdomain.com)。
  4. 按提示完成配置,等待状态变为 有效
    • 图片访问地址格式:https://img.yourdomain.com/文件名
    • ⚠️ 不要使用 *.r2.dev 域名(国内访问受限)。

步骤三:创建并配置 Worker

3.1 创建 Worker

  1. 左侧菜单 → Workers 和 Pages创建应用程序创建 Worker
  2. 设置名称(如 r2-uploader),点击 部署

3.2 绑定 R2 存储桶

  1. 进入该 Worker → 设置变量
  2. R2 存储桶绑定 区域 → 添加绑定
    • 变量名称IMG_BUCKET(必须全大写,与代码一致)
    • R2 存储桶:选择第一步创建的桶(如 my-blog-imgs
  3. 点击 部署 保存。

3.3 设置上传密码

  1. 环境变量 区域 → 添加变量
    • 变量名称UPLOAD_TOKEN
    • :自定义密码(如 mima123456
  2. 点击 部署 保存。

步骤四:部署上传代码

  1. 返回 Worker 页面 → 点击 编辑代码
  2. 删除 worker.js 中所有默认内容。
  3. 粘贴提供的完整上传脚本(需确保代码中 r2Domain 已替换为你的自定义域名,如 img.yourdomain.com)。

使用方式

  • 首次使用时输入 UPLOAD_TOKEN 密码(会自动缓存)。
  • 上传方法:
    • 截图后直接 Ctrl + V 粘贴
    • 拖拽图片文件 到页面
  • 上传成功后点击 复制 Markdown 链接,即可粘贴使用。

常见问题排查

问题 解决方法
403 / 密码错误 检查网页输入的密码是否与 UPLOAD_TOKEN 一致
500 Internal Error 1. 确认 R2 绑定变量名为 IMG_BUCKET<br>2. 检查代码中 r2Domain 是否已设为你的域名
图片上传成功但链接打不开 1. 检查 R2 自定义域状态是否为“有效”<br>2. 确保未使用 *.r2.dev 域名

成本说明

  • Cloudflare R2 免费额度:10GB 存储空间
  • 只要总用量 ≤ 10GB,完全免费
/**
 * 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>
`;