非常简单的CLOUDFLARE R2的网盘WORKERS

0 评论
const CUSTOM_CSS = `
*,:after,:before{margin:0;padding:0;box-sizing:border-box}
html{font-size:16px;scroll-behavior:smooth;-webkit-text-size-adjust:100%}
body{font-family:Noto Sans SC,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:#F6F4EF;color:#2C2A26;line-height:1.75;letter-spacing:.02em;min-height:100vh;display:flex;flex-direction:column}
a{color:#A88F6F;text-decoration:none;border-bottom:none;transition:color .2s ease}
a:hover{color:#8A7655}
h1,h2,h3,h4,h5,h6{font-family:"Noto Serif SC",Playfair Display,Georgia,Songti SC,serif;font-weight:600;line-height:1.4;color:#2C2A26;letter-spacing:.02em}
h1{font-size:2rem; margin-bottom: 1rem;}
h2{font-size:1.5rem}
table{width:100%;border-collapse:collapse;margin:1rem 0;}
td,th{text-align:left;vertical-align:middle;padding:.75rem 1rem}
thead th{font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.1em;color:#8A867D;border-bottom:1px solid #e3ded5;background:transparent}
tbody tr:nth-child(2n){background:#FAFAF7}
tbody tr:hover{background:#EFECE5}
tbody tr td{border-bottom:1px solid #e3ded5}
code{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:.875em;background:#EFECE5;padding:.125em .375em;border-radius:3px;color:#2C2A26}
textarea{width:100%;font-family:inherit;font-size:.875rem;padding:.625rem .875rem;border:1px solid #e3ded5;border-radius:6px;background:#FAFAF8;color:#2C2A26;outline:none;resize:vertical;}
textarea:focus{border-color:#A88F6F;box-shadow:0 0 0 3px rgba(168,143,111,.1);background:#fff}
hr{border:none;border-top:1px solid #e3ded5;margin:2rem 0}
.site-header{border-bottom:1px solid #e3ded5;position:sticky;top:0;background:rgba(246,244,239,.95);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:100}
.header-inner{max-width:72rem;margin:0 auto;padding:0 2rem;display:flex;align-items:center;justify-content:space-between;height:4rem}
.site-title{font-family:"Noto Serif SC",Playfair Display,Georgia,serif;font-weight:700;font-size:1.125rem;letter-spacing:.04em;color:#2C2A26}
.site-main{flex:1 1;max-width:72rem;margin:2rem auto;padding:0 2rem;width:100%}
.admin-page{max-width:26rem;margin:6rem auto;padding:2.5rem;border:1px solid #e3ded5;border-radius:4px;background:#FAFAF7;box-shadow: 0 4px 12px rgba(44,42,38,.03)}
.admin-page h2{text-align:center;margin-bottom:1.5rem}
.admin-page input[type=password]{width:100%;padding:.75rem 1rem;font-size:.9375rem;border:1px solid #e3ded5;border-radius:4px;background:#F6F4EF;color:#2C2A26;outline:none;transition:border-color .2s ease}
.admin-page input[type=password]:focus{border-color:#A88F6F}
.btn{display:inline-block;padding:.625rem 1.5rem;background:#A88F6F;color:#F6F4EF;border:1px solid #a88f6f;border-radius:4px;font-size:.8125rem;font-weight:500;letter-spacing:.04em;cursor:pointer;transition:all .2s ease}
.btn:hover{background:#F6F4EF;color:#A88F6F}
.btn-delete{font-size:.6875rem;font-weight:500;padding:.25rem .625rem;border:1px solid rgba(168,143,111,.3);border-radius:4px;background:transparent;color:#8A867D;cursor:pointer;transition:all .2s ease}
.btn-delete:hover{background:#A88F6F;color:#F6F4EF;border-color:#A88F6F}
.error{color:#c0392b;font-size:.8125rem;text-align:center;margin-top:.75rem}
.upload-zone{background:#FAFAF7;border:1px solid #e3ded5;border-radius:4px;padding:1.5rem;margin-top:2rem}
.file-input-wrapper{margin-bottom:1rem}
@media (max-width:768px){.header-inner,.site-main{padding:0 1.25rem}table{display:block;overflow-x:auto}.admin-page{margin:2rem 1rem;padding:1.5rem}}
`;

// 登录页面 HTML
const LOGIN_TEMPLATE = `
<html>
  <head>
    <title>R2 Storage - 验证</title>
    <style>${CUSTOM_CSS}</style>
  </head>
  <body>
    <div class="admin-page">
      <h2>R2 File Manager</h2>
      $ERROR$
      <form action="/login" method="post">
        <input type="password" name="password" placeholder="请输入安全访问密码" required autofocus>
        <button type="submit" class="btn" style="width:100%; margin-top:1rem;">验证登录</button>
      </form>
    </div>
  </body>
</html>`;

// 主页面框架 HTML
const HTML_TEMPLATE = `
<html>
  <head>
    <title>R2 Cloud Storage</title>
    <style>${CUSTOM_CSS}</style>
  </head>
  <body>
    <header class="site-header">
      <div class="header-inner">
        <div class="site-title">R2 File Manager</div>
        <div class="site-nav"><a href="/logout" class="nav-link" style="font-size:0.8125rem;color:#8A867D;">退出登录</a></div>
      </div>
    </header>
    <main class="site-main">
      $content$
    </main>
  </body>
</html>`;

// 简易 Cookie 解析
function getCookie(request, name) {
  const cookieString = request.headers.get('Cookie');
  if (!cookieString) return null;
  const cookies = cookieString.split(';').map(c => c.trim().split('='));
  const cookie = cookies.find(c => c[0] === name);
  return cookie ? decodeURIComponent(cookie[1]) : null;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);
    const CORRECT_PASSWORD = env.AUTH_TOKEN || 'admin';

    // 1. 处理登录
    if (url.pathname === '/login' && request.method === 'POST') {
      const formData = await request.formData();
      const password = formData.get('password');
      if (password === CORRECT_PASSWORD) {
        return new Response('Redirecting...', {
          status: 302,
          headers: {
            'Location': '/',
            'Set-Cookie': `r2_session=${encodeURIComponent(CORRECT_PASSWORD)}; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict`
          }
        });
      } else {
        const html = LOGIN_TEMPLATE.replace('$ERROR$', '<p class="error">密码验证失败,请重试</p>');
        return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
      }
    }

    // 2. 处理登出
    if (url.pathname === '/logout') {
      return new Response('Redirecting...', {
        status: 302,
        headers: { 'Location': '/', 'Set-Cookie': 'r2_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict' }
      });
    }

    // 3. 鉴权检验
    const session = getCookie(request, 'r2_session');
    const isFileDownload = key && !key.startsWith('delete/') && request.method === 'GET';
    
    if (!isFileDownload && session !== CORRECT_PASSWORD) {
      const html = LOGIN_TEMPLATE.replace('$ERROR$', '');
      return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
    }

    // 4. 核心逻辑处理
    switch (request.method) {
      case 'GET': {
        // 主页:文件列表展示
        if (!key) {
          const fileListObject = await env.MY_BUCKET.get('file-list.txt');
          let fileRowsHtml = '<tr><td colspan="2" style="color:#8A867D; text-align:center; padding:2rem;">暂无上传的文件</td></tr>';
          
          if (fileListObject !== null) {
            const fileList = await fileListObject.text();
            const files = fileList.split('\n').filter(Boolean);
            if (files.length > 0) {
              fileRowsHtml = files.map(file => {
                return `<tr>
                  <td><a href="${url.origin}/${file}" target="_blank"><code>/${file}</code></a></td>
                  <td style="text-align:right;"><a href="${url.origin}/delete/${encodeURIComponent(file)}" class="btn-delete" onclick="return confirm('确定要删除此文件吗?')">删除</a></td>
                </tr>`;
              }).join('');
            }
          }

          const content = `
            <h1>文件管理</h1>
            <table>
              <thead>
                <tr>
                  <th>文件存储路径 (R2 Key)</th>
                  <th style="text-align:right; width:100px;">操作</th>
                </tr>
              </thead>
              <tbody>
                ${fileRowsHtml}
              </tbody>
            </table>
            
            <div class="upload-zone">
              <h3 style="margin-bottom:1rem;">上传新文件</h3>
              <form action="/" method="post" enctype="multipart/form-data">
                <div class="file-input-wrapper">
                  <input type="file" name="file" required>
                </div>
                <button type="submit" class="btn">开始上传</button>
              </form>
              <p style="font-size:12px;color:#8A867D;margin-top:0.5rem;">温馨提示:系统将自动将文件重命名为时间戳,并归档至当月目录中。</p>
            </div>
          `;
          return new Response(HTML_TEMPLATE.replace('$content$', content), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
        }

        // 删除逻辑
        if (key.startsWith('delete/')) {
          const fileName = decodeURIComponent(key.slice(7));
          await env.MY_BUCKET.delete(fileName);
          
          // 更新索引记录
          const fileListObject = await env.MY_BUCKET.get('file-list.txt');
          let fileList = '';
          if (fileListObject !== null) {
            fileList = await fileListObject.text();
          }
          fileList = fileList.split('\n').filter(file => file !== fileName).join('\n');
          await env.MY_BUCKET.put('file-list.txt', new TextEncoder().encode(fileList));

          const content = `
            <div style="text-align:center; padding: 3rem 0;">
              <p style="margin-bottom:1.5rem;">文件 <code>${fileName}</code> 已成功移出存储桶。</p>
              <button onclick="window.location.href='${url.origin}'" class="btn">返回工作台</button>
            </div>`;
          return new Response(HTML_TEMPLATE.replace('$content$', content), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
        }

        // 文件读取下载
        const object = await env.MY_BUCKET.get(key);
        if (object === null) {
          return new Response('Object Not Found', { status: 404 });
        }
        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set('etag', object.httpEtag);
        return new Response(object.body, { headers });
      }

      case 'POST': {
        // 上传与核心重命名逻辑
        const formData = await request.formData();
        const file = formData.get('file');
        const originalName = file.name;
        
        // 提取文件后缀名 (例如 .png)
        const dotIndex = originalName.lastIndexOf('.');
        const extension = dotIndex !== -1 ? originalName.slice(dotIndex) : '';

        // 获取当前北京/东八区时间 (或者直接使用 UTC/系统本地时间戳)
        const now = new Date();
        const year = now.getFullYear();
        // 补足两位月份,如 06
        const month = String(now.getMonth() + 1).padStart(2, '0');
        const timestamp = now.getTime(); // 毫秒级时间戳

        // 拼接成新路径: 2026/06/1773123456789.png
        const newFileName = `${year}/${month}/${timestamp}${extension}`;

        // 保存文件到 R2
        await env.MY_BUCKET.put(newFileName, file.stream());

        // 写入索引记录
        const fileListObject = await env.MY_BUCKET.get('file-list.txt');
        let fileList = '';
        if (fileListObject !== null) {
          fileList = await fileListObject.text();
        }
        fileList += newFileName + '\n';
        await env.MY_BUCKET.put('file-list.txt', new TextEncoder().encode(fileList));

        const content = `
          <div style="padding: 1rem 0;">
            <h2 style="color:#A88F6F; margin-bottom:1rem;">🎉 文件上传成功!</h2>
            <p>原文件名:<code>${originalName}</code></p>
            <p>已重命名归档至:<code>${newFileName}</code></p>
            <hr>
            <p style="margin-bottom:0.5rem;">访问与直链地址:</p>
            <textarea readonly rows="2">${url.origin}/${newFileName}</textarea>
            <div style="margin-top:1.5rem;">
              <a href="${url.origin}/${newFileName}" target="_blank" class="btn" style="margin-right:1rem; color:#F6F4EF;">预览文件</a>
              <button onclick="window.location.href='${url.origin}'" class="btn" style="background:transparent; color:#A88F6F;">返回列表</button>
            </div>
          </div>`;
        return new Response(HTML_TEMPLATE.replace('$content$', content), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
      }

      default:
        return new Response('Method not supported', { status: 405 });
    }
  },
};

发表评论 小贴士:填写昵称与邮箱即可参与讨论