/** * Pages Function: POST /api/players/publish * * Body: { account_id, match_id } * Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set). * * Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style * JSON, merges profile.recent, PUTs to Aliyun OSS: * players/{account_id}/profile.json * players/{account_id}/matches/{match_id}.json * * Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, * optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY. * * Soft-fail: match not ready → 202; bad membership → 403; never echo secrets. */ const OPENDOTA = "https://api.opendota.com/api"; const DEFAULT_BUCKET = "climperor"; const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com"; const RECENT_LIMIT = 30; function jsonResponse(body, status = 200, extraHeaders = {}) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...extraHeaders, }, }); } function envOf(context) { return (context && context.env) || {}; } function intField(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : fallback; } function kda(kills, deaths, assists) { return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10; } function mvpScore(p) { const k = intField(p.kills); const d = intField(p.deaths); const a = intField(p.assists); const dmg = intField(p.hero_damage); const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent); return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1); } function itemIds(player) { const out = []; for (let i = 0; i < 6; i++) { const id = intField(player[`item_${i}`]); if (id > 0) out.push(id); } return out; } function accountInMatch(match, accountId) { const players = match.players || []; for (const p of players) { if (p && intField(p.account_id, -1) === accountId) return true; } return false; } function utcNow() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); } async function fetchJson(url, { headers } = {}) { const res = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) }, }); if (!res.ok) { const err = new Error(`HTTP ${res.status}`); err.status = res.status; throw err; } return res.json(); } async function loadHeroMap(opendotaKey) { const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : ""; try { const rows = await fetchJson(`${OPENDOTA}/heroes${q}`); const map = new Map(); if (Array.isArray(rows)) { for (const h of rows) { if (!h || h.id == null) continue; const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null; map.set(intField(h.id), { key, name_loc: h.localized_name || key, }); } } return map; } catch { return new Map(); } } function normalizeMatch(match, focusAccountId, heroMap) { const playersRaw = match.players; if (!Array.isArray(playersRaw) || !playersRaw.length) return null; const matchId = intField(match.match_id); if (matchId <= 0) return null; const radiantWin = !!match.radiant_win; const teamKills = [0, 0]; const teamNw = [0, 0]; const teamDmg = [0, 0]; const slim = []; for (const p of playersRaw) { if (!p || typeof p !== "object") continue; const slot = intField(p.player_slot); const isRadiant = slot < 128; const side = isRadiant ? 0 : 1; const kills = intField(p.kills); const deaths = intField(p.deaths); const assists = intField(p.assists); const heroDamage = intField(p.hero_damage); let netWorth = intField(p.net_worth); if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent); teamKills[side] += kills; teamNw[side] += netWorth; teamDmg[side] += heroDamage; const heroId = intField(p.hero_id); const hero = heroMap.get(heroId) || {}; let accountId = null; if (p.account_id != null) { const a = intField(p.account_id, -1); accountId = a >= 0 ? a : null; } let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null; if (!personaname) personaname = null; slim.push({ account_id: accountId, personaname, hero_id: heroId, hero_key: hero.key || null, hero_name_loc: hero.name_loc || hero.key || null, level: intField(p.level), kills, deaths, assists, kda: kda(kills, deaths, assists), hero_damage: heroDamage, net_worth: netWorth, items: itemIds(p), is_radiant: isRadiant, won: isRadiant ? radiantWin : !radiantWin, _mvp: mvpScore(p), _side: side, }); } if (slim.length < 2) return null; for (const p of slim) { const side = p._side; const tk = teamKills[side] || 1; const td = teamDmg[side] || 1; p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000; p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000; } let mvp = slim[0]; for (const p of slim) { if (p._mvp > mvp._mvp) mvp = p; } const mvpAccount = mvp.account_id; for (const p of slim) { p.is_mvp = mvpAccount != null && p.account_id === mvpAccount; delete p._mvp; delete p._side; } let startTime = null; if (match.start_time != null) { const t = intField(match.start_time, -1); startTime = t >= 0 ? t : null; } return { match_id: matchId, start_time: startTime, duration: intField(match.duration), radiant_win: radiantWin, radiant: { kills: teamKills[0], net_worth: teamNw[0] }, dire: { kills: teamKills[1], net_worth: teamNw[1] }, mvp_account_id: mvpAccount, players: slim, focus_account_id: focusAccountId, fetched_at: utcNow(), source: "opendota", }; } function summaryForProfile(detail, accountId) { const focus = (detail.players || []).find((p) => p.account_id === accountId); if (!focus) return null; return { match_id: detail.match_id, start_time: detail.start_time, duration: detail.duration, won: !!focus.won, hero_id: focus.hero_id, hero_key: focus.hero_key, hero_name_loc: focus.hero_name_loc, kills: focus.kills, deaths: focus.deaths, assists: focus.assists, kda: focus.kda, }; } async function ossGetJson(env, key) { const bucket = env.OSS_BUCKET || DEFAULT_BUCKET; const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT; const url = `https://${bucket}.${endpoint}/${key}`; try { const res = await fetch(url, { headers: { Accept: "application/json" } }); if (!res.ok) return null; return await res.json(); } catch { return null; } } async function hmacSha1Base64(secret, stringToSign) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-1" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign)); const bytes = new Uint8Array(sig); let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } async function ossPutJson(env, key, obj) { const accessKeyId = env.OSS_ACCESS_KEY_ID; const accessKeySecret = env.OSS_ACCESS_KEY_SECRET; if (!accessKeyId || !accessKeySecret) { const err = new Error("OSS credentials missing"); err.status = 503; throw err; } const bucket = env.OSS_BUCKET || DEFAULT_BUCKET; const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT; const body = JSON.stringify(obj); const contentType = "application/json; charset=utf-8"; const date = new Date().toUTCString(); const resource = `/${bucket}/${key}`; // Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL). const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`; const signature = await hmacSha1Base64(accessKeySecret, stringToSign); const url = `https://${bucket}.${endpoint}/${key}`; const res = await fetch(url, { method: "PUT", headers: { "Content-Type": contentType, Date: date, Authorization: `OSS ${accessKeyId}:${signature}`, "Cache-Control": "public, max-age=60", }, body, }); if (!res.ok) { const text = await res.text().catch(() => ""); const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`); err.status = 502; throw err; } } function mergeProfile(existing, accountId, summary, personaname) { const profile = existing && typeof existing === "object" ? { ...existing } : { account_id: accountId, personaname: null, recent: [] }; let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : []; recent = recent.filter((r) => intField(r.match_id) !== summary.match_id); recent.unshift(summary); profile.recent = recent.slice(0, RECENT_LIMIT); profile.account_id = accountId; if (personaname) profile.personaname = personaname; profile.public_share = true; profile.updated_at = utcNow(); return profile; } export async function onRequestPost(context) { try { const env = envOf(context); const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim(); if (expected) { const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim(); if (got !== expected) { return jsonResponse({ error: "forbidden" }, 403); } } let body; try { body = await context.request.json(); } catch { return jsonResponse({ error: "invalid json" }, 400); } const accountId = intField(body && body.account_id, -1); const matchId = intField(body && body.match_id, -1); if (accountId <= 0 || matchId <= 0) { return jsonResponse({ error: "account_id and match_id required" }, 400); } const odKey = (env.OPENDOTA_API_KEY || "").trim(); const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : ""; let match; try { match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`); } catch (e) { if (e && e.status === 404) { return jsonResponse( { ok: false, pending: true, message: "match not ready on OpenDota" }, 202 ); } return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502); } if (!match || !Array.isArray(match.players) || !match.players.length) { return jsonResponse( { ok: false, pending: true, message: "match incomplete on OpenDota" }, 202 ); } if (!accountInMatch(match, accountId)) { return jsonResponse({ error: "account not in match" }, 403); } const heroMap = await loadHeroMap(odKey); const detail = normalizeMatch(match, accountId, heroMap); if (!detail) { return jsonResponse({ error: "normalize failed" }, 500); } const summary = summaryForProfile(detail, accountId); if (!summary) { return jsonResponse({ error: "focus player missing" }, 500); } let personaname = null; for (const p of detail.players) { if (p.account_id === accountId && p.personaname) { personaname = p.personaname; break; } } const profileKey = `players/${accountId}/profile.json`; const matchKey = `players/${accountId}/matches/${matchId}.json`; const existing = await ossGetJson(env, profileKey); const profile = mergeProfile(existing, accountId, summary, personaname); await ossPutJson(env, matchKey, detail); await ossPutJson(env, profileKey, profile); return jsonResponse({ ok: true, account_id: accountId, match_id: matchId, profile_key: profileKey, match_key: matchKey, }); } catch (e) { const status = (e && e.status) || 500; return jsonResponse( { error: "publish failed", detail: String((e && e.message) || e) }, status >= 400 && status < 600 ? status : 500 ); } } export async function onRequestOptions() { return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, X-Climperor-Publish-Secret", "Access-Control-Max-Age": "86400", }, }); }