Ship Steam login, D1 player sync, and cached「我」dashboard.

Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
@@ -0,0 +1,209 @@
/**
* Shared Steam OpenID + signed-session helpers for Pages Functions.
*
* Env: STEAM_API_KEY, SESSION_SECRET (required for login/me).
* Cookie: climperor_steam (HttpOnly, signed payload).
*/
const COOKIE_NAME = "climperor_steam";
const SESSION_DAYS = 30;
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
const STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/";
export 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,
},
});
}
export function envOf(context) {
return (context && context.env) || {};
}
function b64urlEncode(bytes) {
let bin = "";
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function b64urlDecode(str) {
const pad = "=".repeat((4 - (str.length % 4)) % 4);
const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmacSign(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return b64urlEncode(sig);
}
export function steamId64ToAccountId(steamId64) {
try {
const n = BigInt(String(steamId64));
const account = n - 76561197960265728n;
if (account <= 0n) return null;
return Number(account);
} catch {
return null;
}
}
export function parseSteamIdFromClaimedId(claimedId) {
if (!claimedId || typeof claimedId !== "string") return null;
if (!claimedId.startsWith(STEAM_ID_PREFIX)) return null;
const id = claimedId.slice(STEAM_ID_PREFIX.length).replace(/\/$/, "");
if (!/^\d{17}$/.test(id)) return null;
return id;
}
export async function createSessionToken(secret, payload) {
const body = {
...payload,
exp: Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400,
};
const raw = b64urlEncode(new TextEncoder().encode(JSON.stringify(body)));
const sig = await hmacSign(secret, raw);
return `${raw}.${sig}`;
}
export async function verifySessionToken(secret, token) {
if (!secret || !token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 2) return null;
const [raw, sig] = parts;
const expect = await hmacSign(secret, raw);
if (sig.length !== expect.length) return null;
let ok = 0;
for (let i = 0; i < sig.length; i++) ok |= sig.charCodeAt(i) ^ expect.charCodeAt(i);
if (ok !== 0) return null;
try {
const json = new TextDecoder().decode(b64urlDecode(raw));
const data = JSON.parse(json);
if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null;
if (!data.steamid || !data.account_id) return null;
return data;
} catch {
return null;
}
}
export function readCookie(request, name = COOKIE_NAME) {
const header = request.headers.get("Cookie") || "";
const parts = header.split(";").map((s) => s.trim());
for (const p of parts) {
if (p.startsWith(name + "=")) {
return decodeURIComponent(p.slice(name.length + 1));
}
}
return null;
}
function sessionCookieHeader(token, { clear = false } = {}) {
if (clear) {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
const maxAge = SESSION_DAYS * 86400;
return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
}
/** Add Secure on non-localhost. */
export function sessionCookieHeaderForRequest(request, token, { clear = false } = {}) {
let base = sessionCookieHeader(token, { clear });
const host = new URL(request.url).hostname;
if (host !== "127.0.0.1" && host !== "localhost") {
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax");
}
return base;
}
export function steamLoginRedirectUrl(origin) {
const returnTo = `${origin}/api/auth/steam/callback`;
const params = new URLSearchParams({
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": returnTo,
"openid.realm": origin,
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
});
return `${STEAM_OPENID}?${params.toString()}`;
}
export async function verifySteamOpenId(query) {
const mode = query.get("openid.mode");
if (mode !== "id_res") return { ok: false, error: "bad mode" };
const claimed = query.get("openid.claimed_id");
const steamid = parseSteamIdFromClaimedId(claimed);
if (!steamid) return { ok: false, error: "bad claimed_id" };
const body = new URLSearchParams();
for (const [k, v] of query.entries()) {
if (k.startsWith("openid.")) body.set(k, v);
}
body.set("openid.mode", "check_authentication");
const res = await fetch(STEAM_OPENID, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "climperor-steam-auth",
},
body,
});
const text = await res.text();
if (!/is_valid\s*:\s*true/i.test(text)) {
return { ok: false, error: "openid invalid" };
}
return { ok: true, steamid };
}
export async function fetchSteamPersona(apiKey, steamid) {
if (!apiKey) return { personaname: null, avatar: null };
const url = new URL(
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
);
url.searchParams.set("key", apiKey);
url.searchParams.set("steamids", steamid);
try {
const res = await fetch(url.toString(), {
headers: { "User-Agent": "climperor-steam-auth" },
});
if (!res.ok) return { personaname: null, avatar: null };
const data = await res.json();
const players = data && data.response && data.response.players;
const p = Array.isArray(players) && players[0] ? players[0] : null;
if (!p) return { personaname: null, avatar: null };
return {
personaname: p.personaname || null,
avatar: p.avatarfull || p.avatarmedium || p.avatar || null,
};
} catch {
return { personaname: null, avatar: null };
}
}
export async function sessionFromRequest(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
if (!secret) return null;
const token = readCookie(context.request);
if (!token) return null;
return verifySessionToken(secret, token);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* POST|GET /api/auth/logout — clear session cookie.
*/
import { jsonResponse, sessionCookieHeaderForRequest } from "./_steam_common.js";
function clear(context) {
const origin = new URL(context.request.url).origin;
const wantsHtml = (context.request.headers.get("Accept") || "").includes("text/html");
if (wantsHtml || context.request.method === "GET") {
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
"Cache-Control": "no-store",
},
});
}
return jsonResponse(
{ ok: true },
200,
{
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
}
);
}
export async function onRequestGet(context) {
return clear(context);
}
export async function onRequestPost(context) {
return clear(context);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* GET /api/auth/me — current Steam session (or { authenticated: false }).
*/
import { jsonResponse, sessionFromRequest } from "./_steam_common.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session) {
return jsonResponse({ authenticated: false });
}
return jsonResponse({
authenticated: true,
steamid: session.steamid,
account_id: session.account_id,
personaname: session.personaname || null,
avatar: session.avatar || null,
});
} catch {
return jsonResponse({ authenticated: false });
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* GET /api/auth/steam — redirect to Steam OpenID login.
*/
import { envOf, steamLoginRedirectUrl } from "./_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
if (!(env.SESSION_SECRET || "").trim() || !(env.STEAM_API_KEY || "").trim()) {
return new Response("Steam login not configured", { status: 503 });
}
const origin = new URL(context.request.url).origin;
return Response.redirect(steamLoginRedirectUrl(origin), 302);
}
@@ -0,0 +1,56 @@
/**
* GET /api/auth/steam/callback — Steam OpenID return_to.
*/
import {
createSessionToken,
envOf,
fetchSteamPersona,
sessionCookieHeaderForRequest,
steamId64ToAccountId,
verifySteamOpenId,
} from "../_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
const apiKey = (env.STEAM_API_KEY || "").trim();
const origin = new URL(context.request.url).origin;
if (!secret || !apiKey) {
return Response.redirect(`${origin}/?auth=unconfigured`, 302);
}
const url = new URL(context.request.url);
let verified;
try {
verified = await verifySteamOpenId(url.searchParams);
} catch {
return Response.redirect(`${origin}/?auth=error`, 302);
}
if (!verified.ok) {
return Response.redirect(`${origin}/?auth=denied`, 302);
}
const steamid = verified.steamid;
const accountId = steamId64ToAccountId(steamid);
if (!accountId) {
return Response.redirect(`${origin}/?auth=error`, 302);
}
const persona = await fetchSteamPersona(apiKey, steamid);
const token = await createSessionToken(secret, {
steamid,
account_id: accountId,
personaname: persona.personaname,
avatar: persona.avatar,
});
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/home`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, token),
"Cache-Control": "no-store",
},
});
}