From 94c6db7f27cb6564a350351904d618b7bb910220 Mon Sep 17 00:00:00 2001 From: ConSSS Lab Date: Wed, 9 Sep 2026 15:49:04 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E5=BD=A2=E8=B1=A1=E7=85=A7=E6=8E=A2?= =?UTF-8?q?=E6=B8=AC=E6=94=B9=E6=88=90=E5=B9=B3=E8=A1=8C=EF=BC=8C=E4=B8=A6?= =?UTF-8?q?=E5=9C=A8=E6=8E=A2=E6=B8=AC=E6=99=9A=E5=9B=9E=E4=BE=86=E6=99=82?= =?UTF-8?q?=E8=A3=9C=E7=95=AB=E8=8B=B1=E9=9B=84=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 這兩點不是「線上顯示舊人物」的原因 —— 我原本以為是,實測後發現舊版程式在 同樣的 SPA fallback 條件下也能正常顯示三張照片,那個問題是部署內容裡沒有圖。 但這兩點本身仍是實際存在的脆弱處,順手修掉: 1) 探測改成四個候選同時載,不再一個一個等。 線上有 SPA fallback(找不到檔案回 index.html 200),不存在的副檔名不會快速 404,而是回一整份 HTML 才失敗。依序試的話總時間是四個相加;平行只花最慢的 那一個。仍依候選順序挑第一個成功的,結果是確定的。 2) 探測是非同步的,但畫面同步檢查 heroSheetReady()。網路慢的時候簡報畫面 可能先用 SVG 畫好,探測晚一點才回來說圖其實在,那時畫面不會自己更新, 玩家就永遠看到備援版本。把英雄卡的繪製抽成 renderHeroCards(), 探測完成後如果簡報畫面還開著就就地重畫。 在模擬 Cloudflare SPA fallback 的伺服器上驗過(缺檔回 200 text/html): 按下「跳過劇情」後 0.4 秒就是三張形象照,採用 /images/heroes.jpeg,無 JS 錯誤。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3 --- public/js/art.js | 20 +++++++++++++------- public/js/main.js | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/public/js/art.js b/public/js/art.js index d4e9034..060e078 100644 --- a/public/js/art.js +++ b/public/js/art.js @@ -374,15 +374,21 @@ function tryLoad(src) { }); } +/** + * 四個候選同時載,不要一個一個等。 + * + * 線上有 SPA fallback(找不到檔案就回 index.html 200),所以不存在的副檔名 + * 不會快速 404,而是回一整份 HTML 才失敗。依序試的話光是前兩個就足以讓 + * 簡報畫面先畫完、退回 SVG。平行跑就只花「最慢的那一個」的時間, + * 再依候選順序挑第一個成功的,結果仍然是確定的。 + */ export async function probeHeroSheet() { if (sheetState !== null) return sheetState; - for (const src of HERO_SHEET_CANDIDATES) { - if (await tryLoad(src)) { - HERO_SHEET = src; - return (sheetState = true); - } - } - return (sheetState = false); + const results = await Promise.all(HERO_SHEET_CANDIDATES.map(tryLoad)); + const hit = results.findIndex(Boolean); + if (hit === -1) return (sheetState = false); + HERO_SHEET = HERO_SHEET_CANDIDATES[hit]; + return (sheetState = true); } export const heroSheetReady = () => sheetState === true; diff --git a/public/js/main.js b/public/js/main.js index 3fcfee2..8298f81 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -160,7 +160,18 @@ function showBriefing() { list.appendChild(li); } + renderHeroCards(); + show('brief'); +} + +/** + * 三張英雄卡。抽出來是因為形象照的探測是非同步的 —— + * 畫面可能先用 SVG 畫好,探測晚一點才回來說「圖其實在」, + * 那時要能就地換成照片,不然玩家看到的永遠是備援版本。 + */ +function renderHeroCards() { const heroes = $('brief-heroes'); + if (!heroes) return; heroes.innerHTML = ''; for (const h of HEROES) { const card = document.createElement('div'); @@ -174,7 +185,6 @@ function showBriefing() { card.append(name, role); heroes.appendChild(card); } - show('brief'); } function initBriefing() { @@ -872,7 +882,10 @@ function initResult() { /* ══════════════ 啟動 ══════════════ */ -ART.probeHeroSheet(); +// 探測結束後如果簡報畫面已經畫好了,就地把 SVG 換成形象照 +ART.probeHeroSheet().then((ok) => { + if (ok && el.screens.brief.classList.contains('is-active')) renderHeroCards(); +}); initTitle(); initStory(); initBriefing(); From 84c33a44934e2fd8c694f819ce0ab22d990a3bc4 Mon Sep 17 00:00:00 2001 From: ConSSS Lab Date: Thu, 10 Sep 2026 01:45:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?0G=20Storage=20=E4=B8=8A=E5=82=B3=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=E5=90=8C=E6=BA=90=E4=BB=A3=E7=90=86=EF=BC=8C=E7=B9=9E?= =?UTF-8?q?=E9=96=8B=20storage=20node=20=E7=9A=84=20CORS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 診斷收斂後確認:瀏覽器打得到 indexer,卡在後面。SDK 上傳一個檔案要跟兩種 主機講話 —— 先問 indexer「該傳給哪些 storage node」,再拿那份清單裡的 url 直接打每一台節點的 zgs_uploadSegment。第一步通,第二步不通:那些節點是各自 獨立的主機,沒有為瀏覽器開 CORS,axios 只回一句沒有內容的 "Network Error"。 新增 functions/api/og/zg/[[path]].js 把兩步都繞過自家 Worker: 前端 new Indexer('/api/og/zg/indexer') ├─ 轉發到真的 indexer,並把回應裡每個節點的 url 改寫成 │ /api/og/zg/node/<編碼後的原始網址> └─ 節點請求再轉發到那台真的 storage node 這樣瀏覽器全程只跟自己的網域講話,沒有跨來源請求。Flow 合約那步不經過代理, 它走 MetaMask 簽名,本來就沒有 CORS 問題;EVM RPC 也維持直連(實測通得過)。 節點清單的形狀在 SDK 版本之間變過(有時陣列、有時 { trusted: [...] } 分片 物件),所以改寫不去假設結構,直接走訪整棵 JSON,看到叫 url 且是 http(s) 開頭的字串就換掉。非 0g.ai 的主機原樣留著讓它自己失敗,不靜悄悄吞掉。 安全性:只轉發到 0g.ai(與其子網域)的 https 主機,否則就是一個任何人都能 借用的開放代理。轉發時只帶 content-type,不把 cookie、authorization 之類的 標頭交給第三方主機。 驗證: - 用假的 indexer/節點驅動 Function 本體:節點網址正確改寫、編碼後的網址能 還原成原始目標(含 port 與 path)、method 與 body 原樣帶過去。 - 白名單四種繞過嘗試都被擋(非 0g.ai、http 明文、not0g.ai 這種後綴冒充、 0g.ai.evil.com 這種子網域冒充),合法的 0g.ai 放行。 - wrangler pages dev 確認巢狀 [[path]] catch-all 路由解析正確,未知路徑回 404,且 /api/og/storage 與 /api/og/status 不受影響。 實際上傳仍需在瀏覽器實測 —— 這個開發環境連不到 0g.ai。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3 --- functions/api/og/zg/[[path]].js | 162 ++++++++++++++++++++++++++++++++ public/js/storage.js | 16 +++- 2 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 functions/api/og/zg/[[path]].js diff --git a/functions/api/og/zg/[[path]].js b/functions/api/og/zg/[[path]].js new file mode 100644 index 0000000..480c00e --- /dev/null +++ b/functions/api/og/zg/[[path]].js @@ -0,0 +1,162 @@ +/** + * /api/og/zg/* —— 0G Storage 的同源代理。 + * + * 為什麼需要它: + * + * SDK 上傳一個檔案要跟兩種主機講話 —— + * 1. indexer:問「這個檔案該傳給哪些 storage node」(indexer_getShardedNodes) + * 2. 那份清單裡的每一台 storage node:真正把 segment 傳上去(zgs_uploadSegment) + * + * 第 1 步從瀏覽器打得通,第 2 步打不通:那些 storage node 是各自獨立的主機, + * 沒有為瀏覽器開 CORS,所以 axios 只會回一句沒有內容的 "Network Error"。 + * + * 解法是把兩步都繞過這支 Function,讓瀏覽器全程只跟自己的網域講話: + * + * 前端 new Indexer('/api/og/zg/indexer') + * │ + * ├─ 這支轉發到真的 indexer, + * │ 並把回應裡每個節點的 url 改寫成 /api/og/zg/node/<編碼後的原始網址> + * │ + * └─ 節點請求再由這支轉發到那台真的 storage node + * + * Flow 合約那步不經過這裡 —— 它走 MetaMask 簽名,本來就沒有 CORS 問題。 + * + * 安全性:只准轉發到 0g.ai 底下的 https 主機,否則就成了任何人都能借用的開放代理。 + */ + +import { json, indexerOf } from '../_shared.js'; + +/** 只有這個網域(與其子網域)能被轉發。 */ +const ALLOWED_SUFFIX = '.0g.ai'; +const ALLOWED_EXACT = '0g.ai'; + +const MAX_BODY = 6 * 1024 * 1024; // 一個 segment 是 256KB,留足餘裕 +const TIMEOUT_MS = 30000; + +const b64urlEncode = (s) => + btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +const b64urlDecode = (s) => { + const pad = s.replace(/-/g, '+').replace(/_/g, '/'); + return atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)); +}; + +/** 這個網址可以轉發嗎?只認 https 的 0g.ai。 */ +function allowed(target) { + let u; + try { + u = new URL(target); + } catch { + return null; + } + if (u.protocol !== 'https:') return null; + const h = u.hostname.toLowerCase(); + if (h !== ALLOWED_EXACT && !h.endsWith(ALLOWED_SUFFIX)) return null; + return u; +} + +/** + * 把 JSON 裡所有指向外部主機的 url 欄位改寫成走這支代理。 + * + * indexer 回傳的節點清單形狀在不同版本之間變過(有時是陣列,有時是 + * { trusted: [...] } 這種分片物件),所以不去假設結構,直接走訪整棵樹, + * 看到叫 url 而且是 http(s) 開頭的字串就換掉。轉發不到的主機(非 0g.ai) + * 原樣留著,讓它自己去失敗,不要靜悄悄地吞掉。 + */ +function rewriteUrls(node, origin) { + if (Array.isArray(node)) return node.map((n) => rewriteUrls(n, origin)); + if (!node || typeof node !== 'object') return node; + + const out = {}; + for (const [k, v] of Object.entries(node)) { + if (k === 'url' && typeof v === 'string' && /^https?:\/\//i.test(v)) { + out[k] = allowed(v) ? `${origin}/api/og/zg/node/${b64urlEncode(v)}` : v; + } else { + out[k] = rewriteUrls(v, origin); + } + } + return out; +} + +async function forward(target, request, { rewrite = null } = {}) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), TIMEOUT_MS); + try { + const body = + request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text(); + if (body && body.length > MAX_BODY) return json({ error: 'payload too large' }, 413); + + const res = await fetch(target.toString(), { + method: request.method, + signal: ac.signal, + // 只帶必要的標頭:不要把 cookie、authorization 之類的東西轉給第三方主機 + headers: { 'content-type': request.headers.get('content-type') || 'application/json' }, + body, + }); + + const text = await res.text(); + if (!rewrite) { + return new Response(text, { + status: res.status, + headers: { + 'content-type': res.headers.get('content-type') || 'application/json', + 'cache-control': 'no-store', + }, + }); + } + + let parsed; + try { + parsed = JSON.parse(text); + } catch { + // 不是 JSON 就原樣回去,改寫本來就只對 JSON 有意義 + return new Response(text, { + status: res.status, + headers: { 'content-type': res.headers.get('content-type') || 'text/plain' }, + }); + } + return json(rewrite(parsed), res.status); + } catch (err) { + const msg = err && err.name === 'AbortError' ? '轉發逾時' : String(err && err.message ? err.message : err); + return json({ error: `代理轉發失敗:${msg}`.slice(0, 200) }, 502); + } finally { + clearTimeout(timer); + } +} + +export async function onRequest({ request, params, env }) { + const segments = Array.isArray(params.path) ? params.path : [params.path].filter(Boolean); + const origin = new URL(request.url).origin; + + // ── /api/og/zg/indexer ────────────────────────────── + // 轉發到真的 indexer,並把回應裡的節點網址改寫成走這支代理。 + if (segments[0] === 'indexer' && segments.length === 1) { + const target = allowed(indexerOf(env)); + if (!target) return json({ error: 'indexer 設定不是合法的 0g.ai https 網址' }, 500); + return forward(target, request, { rewrite: (data) => rewriteUrls(data, origin) }); + } + + // ── /api/og/zg/node/<編碼網址>/<其餘路徑> ───────────── + // 轉發到那台真的 storage node。 + if (segments[0] === 'node' && segments.length >= 2) { + let decoded; + try { + decoded = b64urlDecode(segments[1]); + } catch { + return json({ error: '節點網址編碼不正確' }, 400); + } + const base = allowed(decoded); + if (!base) return json({ error: '只允許轉發到 0g.ai 的 https 主機' }, 403); + + // 保留節點網址本身的路徑,再接上代理路徑剩下的部分 + const rest = segments.slice(2).map(encodeURIComponent).join('/'); + const target = new URL( + [base.pathname.replace(/\/+$/, ''), rest].filter(Boolean).join('/') || '/', + base.origin, + ); + target.search = new URL(request.url).search; + return forward(target, request); + } + + return json({ error: 'unknown proxy route' }, 404); +} diff --git a/public/js/storage.js b/public/js/storage.js index 807b50d..ff9eaac 100644 --- a/public/js/storage.js +++ b/public/js/storage.js @@ -17,7 +17,17 @@ const SDK_URL = '/vendor/zgstorage.esm.min.js'; const ETHERS_URL = '/vendor/ethers.min.js'; -const DEFAULT_INDEXER = 'https://indexer-storage-testnet-turbo.0g.ai'; +/** + * indexer 走自家的同源代理,不直接打 0g.ai。 + * + * 直接打的話 indexer 本身通得過,但它回傳的 storage node 是另一批主機, + * 那些主機沒為瀏覽器開 CORS,SDK 傳 segment 時只會拿到一句沒有內容的 + * "Network Error"。代理會把節點網址一併改寫成走同一支 Function, + * 整條上傳鏈路就都是同源的了。見 functions/api/og/zg/[[path]].js。 + */ +const DEFAULT_INDEXER = '/api/og/zg/indexer'; + +/** EVM RPC 維持直連:它走 MetaMask 與公開節點,實測瀏覽器打得通。 */ const DEFAULT_RPC = 'https://evmrpc-testnet.0g.ai'; let modsPromise = null; @@ -86,7 +96,9 @@ export async function upload(shard, { indexer, rpc, onStep } = {}) { const signer = await provider.getSigner(); step('送出 Flow 合約 submit 並上傳 segment…'); - const client = new zg.Indexer(indexer || DEFAULT_INDEXER); + // 這裡刻意忽略呼叫端傳進來的真實 indexer 網址,一律走代理 —— + // 直連的話 segment 那步會被 storage node 的 CORS 擋掉。 + const client = new zg.Indexer(new URL(DEFAULT_INDEXER, location.origin).toString()); const [tx, err] = await client.upload(data, rpc || DEFAULT_RPC, signer); if (err) throw new Error(String(err && err.message ? err.message : err));