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/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(); 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));