Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions functions/api/og/zg/[[path]].js
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +85 to +87

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);
}
20 changes: 13 additions & 7 deletions public/js/art.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +387 to +391
}

export const heroSheetReady = () => sheetState === true;
Expand Down
17 changes: 15 additions & 2 deletions public/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -174,7 +185,6 @@ function showBriefing() {
card.append(name, role);
heroes.appendChild(card);
}
show('brief');
}

function initBriefing() {
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 14 additions & 2 deletions public/js/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Comment on lines +99 to +101
const [tx, err] = await client.upload(data, rpc || DEFAULT_RPC, signer);
if (err) throw new Error(String(err && err.message ? err.message : err));

Expand Down