> ## Documentation Index
> Fetch the complete documentation index at: https://felimet-hub.jmcores.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloudflare R2 檔案上傳與管理系統

> 以 Cloudflare Workers 與 R2 物件儲存建構的無伺服器檔案上傳與管理系統，無檔案大小限制，特別適合儲存 .pt、.ckpt、.safetensors 等深度學習模型檔，提供瀏覽、刪除與分享功能，透過 wrangler CLI 一鍵部署。

export const UploadDemo = ({lang = "zh"}) => {
  const t = lang === "en" ? {
    drop: "Drag files here, or",
    btn: "Simulate upload",
    uploading: "Uploading",
    done: "Uploaded",
    empty: "No files yet",
    hint: "No size limit. Backed by Cloudflare R2."
  } : {
    drop: "把檔案拖到這裡，或",
    btn: "模擬上傳",
    uploading: "上傳中",
    done: "已上傳",
    empty: "尚無檔案",
    hint: "無檔案大小限制，後端為 Cloudflare R2。"
  };
  const SAMPLES = [{
    name: "model.safetensors",
    size: "1.24 GB",
    kind: "file"
  }, {
    name: "checkpoint-epoch12.ckpt",
    size: "860 MB",
    kind: "file"
  }, {
    name: "sam2.1_hiera_large.pt",
    size: "2.4 GB",
    kind: "file"
  }, {
    name: "lora_adapter.safetensors",
    size: "148 MB",
    kind: "file"
  }, {
    name: "dataset_v3.zip",
    size: "5.1 GB",
    kind: "archive"
  }];
  const fileIcon = kind => kind === "archive" ? <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /><path d="M3.3 7 12 12l8.7-5" /><path d="M12 22V12" /></svg> : <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>;
  const [files, setFiles] = useState([]);
  const [seq, setSeq] = useState(0);
  const active = files.some(f => f.progress < 100);
  useEffect(() => {
    if (!active) return;
    const iv = setInterval(() => {
      setFiles(fs => fs.map(f => f.progress < 100 ? {
        ...f,
        progress: Math.min(100, f.progress + 9)
      } : f));
    }, 110);
    return () => clearInterval(iv);
  }, [active]);
  const add = () => {
    const s = SAMPLES[seq % SAMPLES.length];
    setSeq(n => n + 1);
    setFiles(fs => [{
      id: seq,
      name: s.name,
      size: s.size,
      kind: s.kind,
      progress: 0
    }, ...fs].slice(0, 5));
  };
  const css = `
  .ud-root{--ud-bg:#FAF8F3;--ud-surface:rgba(0,0,0,0.025);--ud-border:rgba(0,0,0,0.09);--ud-text:#2b2722;--ud-dim:#6f6a62;--ud-faint:#8a8378;--ud-accent:#bf7551;--ud-track:rgba(0,0,0,0.07);border:1px solid var(--ud-border);border-radius:14px;background:var(--ud-bg);color:var(--ud-text);overflow:hidden;}
  .dark .ud-root{--ud-bg:#1b1a18;--ud-surface:rgba(255,255,255,0.03);--ud-border:rgba(255,255,255,0.08);--ud-text:#e7e3da;--ud-dim:#a8a299;--ud-faint:#8a8378;--ud-accent:#cf8a68;--ud-track:rgba(255,255,255,0.08);}
  .ud-zone{margin:16px;border:1.5px dashed var(--ud-border);border-radius:12px;padding:22px 16px;text-align:center;background:var(--ud-surface);}
  .ud-ic{font-size:24px;color:var(--ud-accent);opacity:.7;}
  .ud-zt{font-size:13.5px;color:var(--ud-dim);margin-top:7px;}
  .ud-btn{margin-top:11px;border:none;border-radius:9px;background:var(--ud-accent);color:#fff;font-size:13.5px;font-weight:600;padding:8px 18px;cursor:pointer;transition:opacity .15s;}
  .ud-btn:hover{opacity:.9;}
  .ud-hint{font-size:11.5px;color:var(--ud-faint);margin-top:9px;}
  .ud-list{padding:0 16px 14px;display:flex;flex-direction:column;gap:9px;}
  .ud-sec{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--ud-faint);padding:2px 2px 0;}
  .ud-empty{font-size:13px;color:var(--ud-faint);padding:4px 2px 8px;}
  .ud-file{border:1px solid var(--ud-border);border-radius:10px;padding:10px 13px;background:var(--ud-surface);}
  .ud-frow{display:flex;align-items:center;gap:10px;}
  .ud-fic{width:26px;height:26px;border-radius:7px;background:rgba(191,117,81,.12);color:var(--ud-accent);display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;}
  .ud-fname{flex:1 1 0;min-width:0;font-size:13.5px;font-weight:550;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .ud-fsize{font-size:12px;color:var(--ud-faint);flex-shrink:0;font-variant-numeric:tabular-nums;}
  .ud-fpct{font-size:12px;color:var(--ud-accent);flex-shrink:0;width:38px;text-align:right;font-variant-numeric:tabular-nums;}
  .ud-fdone{font-size:13px;color:#5f8a52;flex-shrink:0;}
  .dark .ud-fdone{color:#86b274;}
  .ud-bar{height:4px;border-radius:2px;background:var(--ud-track);overflow:hidden;margin-top:8px;}
  .ud-fill{height:100%;background:var(--ud-accent);border-radius:2px;transition:width .11s linear;}
  .ud-ic{line-height:0;}
  .ud-ic svg{width:30px;height:30px;}
  .ud-fic svg{width:15px;height:15px;}
  .ud-fdone{display:flex;align-items:center;}
  .ud-fdone svg{width:16px;height:16px;}
  `;
  const uploading = files.filter(f => f.progress < 100);
  const done = files.filter(f => f.progress >= 100);
  return <div className="ud-root">
      <style>{css}</style>
      <div className="ud-zone">
        <div className="ud-ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 13v8" /><path d="m8 17 4-4 4 4" /><path d="M20 16.2A4.5 4.5 0 0 0 17.5 8h-1.8A7 7 0 1 0 4 14.9" /></svg></div>
        <div className="ud-zt">{t.drop}</div>
        <button type="button" className="ud-btn" onClick={add}>{t.btn}</button>
        <div className="ud-hint">{t.hint}</div>
      </div>
      <div className="ud-list">
        {files.length === 0 && <div className="ud-empty">{t.empty}</div>}
        {uploading.length > 0 && <div className="ud-sec">{t.uploading}</div>}
        {uploading.map(f => <div className="ud-file" key={f.id}>
            <div className="ud-frow">
              <span className="ud-fic">{fileIcon(f.kind)}</span>
              <span className="ud-fname">{f.name}</span>
              <span className="ud-fsize">{f.size}</span>
              <span className="ud-fpct">{f.progress}%</span>
            </div>
            <div className="ud-bar"><div className="ud-fill" style={{
    width: f.progress + "%"
  }} /></div>
          </div>)}
        {done.length > 0 && <div className="ud-sec">{t.done}</div>}
        {done.map(f => <div className="ud-file" key={f.id}>
            <div className="ud-frow">
              <span className="ud-fic">{fileIcon(f.kind)}</span>
              <span className="ud-fname">{f.name}</span>
              <span className="ud-fsize">{f.size}</span>
              <span className="ud-fdone"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg></span>
            </div>
          </div>)}
      </div>
    </div>;
};

export const ArchFlow = ({nodes = [], lang = "zh"}) => {
  const t = lang === "en" ? {
    flow: "Data flow",
    hint: "Click any stage to see what it does",
    stage: "Stage"
  } : {
    flow: "資料流",
    hint: "點任一階段看它做什麼",
    stage: "階段"
  };
  const safe = Array.isArray(nodes) ? nodes : [];
  const [active, setActive] = useState(0);
  if (safe.length === 0) return null;
  const idx = Math.min(active, safe.length - 1);
  const current = safe[idx];
  const css = `
  .af-root{--af-bg:#FAF8F3;--af-surface:rgba(191,117,81,0.06);--af-surface2:rgba(0,0,0,0.025);--af-border:rgba(0,0,0,0.09);--af-text:#2b2722;--af-dim:#6f6a62;--af-faint:#8a8378;--af-rail:rgba(0,0,0,0.12);--af-accent:#bf7551;border:1px solid var(--af-border);border-radius:14px;background:var(--af-bg);color:var(--af-text);overflow:hidden;}
  .dark .af-root{--af-bg:#1b1a18;--af-surface:rgba(207,138,104,0.09);--af-surface2:rgba(255,255,255,0.03);--af-border:rgba(255,255,255,0.08);--af-text:#e7e3da;--af-dim:#a8a299;--af-faint:#8a8378;--af-rail:rgba(255,255,255,0.13);--af-accent:#cf8a68;}
  .af-head{padding:13px 18px 11px;display:flex;align-items:center;gap:9px;flex-wrap:wrap;border-bottom:1px solid var(--af-border);}
  .af-head-ic{color:var(--af-accent);flex-shrink:0;}
  .af-head-t{font-size:12px;font-weight:700;letter-spacing:.6px;color:var(--af-dim);text-transform:uppercase;}
  .af-head-h{font-size:12px;color:var(--af-faint);}
  .af-grid{display:flex;gap:0;align-items:stretch;}
  .af-list{flex:1 1 0;min-width:0;padding:12px 12px 16px;}
  .af-node{display:flex;align-items:stretch;gap:10px;width:100%;text-align:left;background:transparent;border:none;cursor:pointer;padding:0;color:inherit;font:inherit;-webkit-tap-highlight-color:transparent;}
  .af-railcol{width:22px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;}
  .af-dot{width:9px;height:9px;border-radius:50%;margin-top:14px;background:var(--af-faint);opacity:.5;flex-shrink:0;transition:background .15s,box-shadow .15s,opacity .15s;}
  .af-line{width:2px;flex:1 1 0;background:var(--af-rail);min-height:10px;}
  .af-body{flex:1 1 0;min-width:0;padding:9px 12px;margin:2px 0;border-radius:9px;border:1px solid transparent;transition:background .15s,border-color .15s;}
  .af-node:hover .af-body{background:var(--af-surface2);}
  .af-node-on .af-body{background:var(--af-surface);border-color:rgba(191,117,81,.30);}
  .af-node-on .af-dot{background:var(--af-accent);opacity:1;box-shadow:0 0 0 4px rgba(191,117,81,.15);}
  .af-label{font-size:14.5px;font-weight:550;line-height:1.45;}
  .af-node-on .af-label{color:var(--af-accent);}
  .af-detail{width:300px;flex-shrink:0;border-left:1px solid var(--af-border);padding:16px 18px;background:var(--af-surface2);}
  .af-detail-k{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--af-accent);margin-bottom:7px;}
  .af-detail-l{font-size:16px;font-weight:600;margin-bottom:9px;line-height:1.35;}
  .af-detail-d{font-size:13.5px;line-height:1.65;color:var(--af-dim);}
  @media (max-width:640px){.af-grid{flex-direction:column;}.af-detail{width:auto;border-left:none;border-top:1px solid var(--af-border);}}
  `;
  return <div className="af-root">
      <style>{css}</style>
      <div className="af-head">
        <svg className="af-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="8" height="8" x="3" y="3" rx="2" /><path d="M7 11v4a2 2 0 0 0 2 2h4" /><rect width="8" height="8" x="13" y="13" rx="2" /></svg>
        <span className="af-head-t">{t.flow}</span>
        <span className="af-head-h">{t.hint}</span>
      </div>
      <div className="af-grid">
        <div className="af-list">
          {safe.map((n, i) => <button key={i} type="button" className={"af-node" + (i === idx ? " af-node-on" : "")} onClick={() => setActive(i)} onMouseEnter={() => setActive(i)}>
              <div className="af-railcol">
                <div className="af-dot" />
                {i < safe.length - 1 && <div className="af-line" />}
              </div>
              <div className="af-body">
                <div className="af-label">{n.label}</div>
              </div>
            </button>)}
        </div>
        <div className="af-detail">
          <div className="af-detail-k">{t.stage} {idx + 1} / {safe.length}</div>
          <div className="af-detail-l">{current.label}</div>
          <div className="af-detail-d">{current.detail}</div>
        </div>
      </div>
    </div>;
};

`Cloudflare Workers` · `Cloudflare R2` · `JavaScript` · `Serverless` · `Wrangler`

## 專案概述

以 Cloudflare Workers 為運算層、Cloudflare R2 為物件儲存，實作無伺服器的檔案上傳與管理介面。預設無檔案大小限制，特別適合存放機器學習模型權重（`.pt`、`.ckpt`、`.safetensors` 等）。前端可部署於 Cloudflare Pages、GitHub Pages，或在本機直接開啟 HTML 檔案。後端 Worker 透過 wrangler CLI 部署與管理。

## 介面預覽

點「模擬上傳」感受實際的拖放上傳流程（此為前端 mock，不會真的上傳檔案）。

<UploadDemo />

## 核心功能

* 無檔案大小限制，適合上傳大型模型檔案
* 拖放上傳介面，即時顯示進度與預估剩餘時間
* 自動偵測檔案類型並顯示對應圖示
* 支援圖片預覽與所有檔案格式的下載
* 刪除已上傳的檔案
* Cloudflare Workers 處理上傳、下載、刪除的 API 請求
* 前端部署彈性：Cloudflare Pages、GitHub Pages 或本機靜態檔案均可

## 專案架構

<ArchFlow
  nodes={[
{ label: "瀏覽器", detail: "使用者在前端頁面進行拖放上傳、瀏覽、下載或刪除操作，透過 fetch 呼叫 Worker 的 API 端點。" },
{ label: "靜態前端主機", detail: "前端靜態檔案可部署於 Cloudflare Pages、GitHub Pages，或直接在本機以瀏覽器開啟 HTML 檔案，不依賴特定主機。" },
{ label: "Cloudflare Workers", detail: "運行於 Cloudflare 邊緣的無伺服器函式，處理上傳、下載、刪除的 API 請求，並負責 CORS 控管。以 wrangler deploy 部署。" },
{ label: "Cloudflare R2", detail: "物件儲存，不設預設檔案大小上限，適合存放模型權重（.pt、.ckpt、.safetensors）等大型二進位檔案。" }
]}
/>

前後端分離。Worker 以 `wrangler deploy` 部署到 Cloudflare 邊緣，前端只需設定正確的 Worker URL 即可獨立託管。

## 快速開始

<Steps>
  <Step title="安裝 wrangler CLI 並登入">
    ```bash theme={null}
    npm install -g wrangler
    wrangler login
    ```
  </Step>

  <Step title="建立 R2 Bucket">
    ```bash theme={null}
    wrangler r2 bucket create your-bucket-name
    ```
  </Step>

  <Step title="設定 wrangler.toml">
    依實際需求填入 Worker 名稱、Bucket binding 與名稱：

    ```toml theme={null}
    name = "your-worker-name"
    main = "src/index.js"
    compatibility_date = "yyyy-MM-dd"

    [[r2_buckets]]
    binding = "your-binding"
    bucket_name = "your-bucket-name"

    [vars]
    ALLOWED_ORIGINS = "*"
    ```
  </Step>

  <Step title="更新前端 API URL">
    在 `frontend/app.js` 中，將 `API_URL` 改為實際的 Worker 端點：

    ```javascript theme={null}
    const API_URL = 'https://your-worker-name.your-account.workers.dev';
    ```
  </Step>

  <Step title="部署 Worker">
    ```bash theme={null}
    wrangler deploy
    ```
  </Step>

  <Step title="部署或開啟前端">
    將 `frontend/` 目錄上傳至 Cloudflare Pages 或 GitHub Pages，或直接在瀏覽器開啟 `frontend/index.html`。
  </Step>
</Steps>

## 安全提醒

<Warning>
  預設 CORS 設定為 `*`（允許所有來源），且系統沒有任何身份驗證機制。任何知道 Worker URL 的人均可上傳、下載或刪除檔案。用於生產環境前，必須加入身份驗證並將 CORS 限縮至特定來源，否則等同公開存取。此外，R2 儲存用量可能產生 Cloudflare 費用，請留意用量上限。
</Warning>

## 實際應用面

適合個人或小型團隊在不想維運後端伺服器的前提下，需要一個可存放大型二進位檔案（模型權重、資料集壓縮包）的私有臨時儲存點。也可作為學習 Cloudflare Workers 與 R2 整合的起點專案。

## 相關連結

* GitHub：[felimet/cf-auto-deploy-worker](https://github.com/felimet/cf-auto-deploy-worker)
