Merge branch 'v27'

This commit is contained in:
evanpelle
2025-12-12 16:20:43 -08:00
22 changed files with 1025 additions and 709 deletions
+100 -114
View File
@@ -5,19 +5,14 @@ import {
PlayerStatsTree,
UserMeResponse,
} from "../core/ApiSchemas";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import "./components/Difficulties";
import "./components/PatternButton";
import {
discordLogin,
fetchPlayerById,
getApiBase,
getUserMe,
logOut,
} from "./jwt";
import { isInIframe, translateText } from "./Utils";
@customElement("account-modal")
@@ -30,10 +25,7 @@ export class AccountModal extends LitElement {
@state() private email: string = "";
@state() private isLoadingUser: boolean = false;
private loggedInEmail: string | null = null;
private loggedInDiscord: string | null = null;
private userMeResponse: UserMeResponse | null = null;
private playerId: string | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
@@ -44,8 +36,7 @@ export class AccountModal extends LitElement {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
this.userMeResponse = customEvent.detail as UserMeResponse;
this.playerId = this.userMeResponse?.player?.publicId;
if (this.playerId === undefined) {
if (this.userMeResponse?.player?.publicId === undefined) {
this.statsTree = null;
this.recentGames = [];
}
@@ -67,31 +58,90 @@ export class AccountModal extends LitElement {
id="account-modal"
title="${translateText("account_modal.title") || "Account"}"
>
${this.renderInner()}
${this.isLoadingUser
? html`
<div
class="flex flex-col items-center justify-center p-6 text-white"
>
<p class="mb-2">
${translateText("account_modal.fetching_account")}
</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`
: this.renderInner()}
</o-modal>
`;
}
private renderInner() {
if (this.isLoadingUser) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">${translateText("account_modal.fetching_account")}</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
}
if (this.loggedInDiscord) {
return this.renderLoggedInDiscord();
} else if (this.loggedInEmail) {
return this.renderLoggedInEmail();
if (this.userMeResponse?.user) {
return this.renderAccountInfo();
} else {
return this.renderLoginOptions();
}
}
private renderAccountInfo() {
return html`
<div class="p-6">
<div class="mb-4">
<p class="text-white mb-4 text-center">
${translateText("account_modal.player_id", {
id:
this.userMeResponse?.player?.publicId ??
translateText("account_modal.not_found"),
})}
</p>
</div>
<div class="mb-4 text-center">
<p class="text-white mb-4">${this.renderLoggedInAs()}</p>
</div>
<div class="flex flex-col items-center mt-2 mb-4">
<discord-user-header
.data=${this.userMeResponse?.user?.discord ?? null}
></discord-user-header>
</div>
${this.renderPlayerStats()}
</div>
`;
}
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
return html`<p>
${translateText("account_modal.linked_account", {
account_name: me.discord.global_name ?? "",
})}
</p>
${this.renderLogoutButton()}`;
} else if (me?.email) {
return html`<p>
${translateText("account_modal.linked_account", {
account_name: me.email,
})}
</p>
${this.renderLogoutButton()}`;
}
return this.renderLoginOptions();
}
private renderPlayerStats(): TemplateResult {
return html`
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
<hr class="w-2/3 border-gray-600 my-2" />
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
`;
}
private viewGame(gameId: string): void {
this.close();
const path = location.pathname;
@@ -103,46 +153,7 @@ export class AccountModal extends LitElement {
window.dispatchEvent(new HashChangeEvent("hashchange"));
}
private renderLoggedInDiscord() {
return html`
<div class="p-6">
<div class="mb-4 text-center">
<p class="text-white mb-4">
Logged in with Discord as ${this.loggedInDiscord}
</p>
${this.logoutButton()}
</div>
<div class="flex flex-col items-center mt-2 mb-4">
<discord-user-header
.data=${this.userMeResponse?.user?.discord ?? null}
></discord-user-header>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
<hr class="w-2/3 border-gray-600 my-2" />
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
</div>
</div>
`;
}
private renderLoggedInEmail(): TemplateResult {
return html`
<div class="p-6">
<div class="mb-4">
<p class="text-white text-center mb-4">
Logged in as ${this.loggedInEmail}
</p>
</div>
${this.logoutButton()}
</div>
`;
}
private logoutButton(): TemplateResult {
private renderLogoutButton(): TemplateResult {
return html`
<button
@click="${this.handleLogout}"
@@ -157,10 +168,6 @@ export class AccountModal extends LitElement {
return html`
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium text-white mb-4 text-center">
Choose your login method
</h3>
<!-- Discord Login Button -->
<div class="mb-6">
<button
@@ -195,7 +202,6 @@ export class AccountModal extends LitElement {
for="email"
class="block text-sm font-medium text-white mb-2"
>
Recover account by email
</label>
<input
type="email"
@@ -225,6 +231,12 @@ export class AccountModal extends LitElement {
</button>
</div>
</div>
<button
@click="${this.handleLogout}"
class="px-3 py-1 text-xs font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
>
${translateText("account_modal.clear_session")}
</button>
`;
}
@@ -235,41 +247,19 @@ export class AccountModal extends LitElement {
private async handleSubmit() {
if (!this.email) {
alert("Please enter an email address");
alert(translateText("account_modal.enter_email_address"));
return;
}
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/magic-link`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
redirectDomain: window.location.origin,
const success = await sendMagicLink(this.email);
if (success) {
alert(
translateText("account_modal.recovery_email_sent", {
email: this.email,
}),
});
if (response.ok) {
alert(
translateText("account_modal.recovery_email_sent", {
email: this.email,
}),
);
this.close();
} else {
console.error(
"Failed to send recovery email:",
response.status,
response.statusText,
);
alert("Failed to send recovery email. Please try again.");
}
} catch (error) {
console.error("Error sending recovery email:", error);
alert("Error sending recovery email. Please try again.");
);
} else {
alert(translateText("account_modal.failed_to_send_recovery_email"));
}
}
@@ -284,14 +274,10 @@ export class AccountModal extends LitElement {
void getUserMe()
.then((userMe) => {
if (userMe) {
this.loggedInEmail = userMe.user.email ?? null;
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
if (this.playerId) {
this.loadFromApi(this.playerId);
this.userMeResponse = userMe;
if (this.userMeResponse?.player?.publicId) {
this.loadPlayerProfile(this.userMeResponse.player.publicId);
}
} else {
this.loggedInEmail = null;
this.loggedInDiscord = null;
}
this.isLoadingUser = false;
this.requestUpdate();
@@ -315,9 +301,9 @@ export class AccountModal extends LitElement {
window.location.reload();
}
private async loadFromApi(playerId: string): Promise<void> {
private async loadPlayerProfile(publicId: string): Promise<void> {
try {
const data = await fetchPlayerById(playerId);
const data = await fetchPlayerById(publicId);
if (!data) {
this.requestUpdate();
return;
@@ -382,11 +368,11 @@ export class AccountButton extends LitElement {
let buttonTitle = "";
if (this.loggedInEmail) {
buttonTitle = translateText("account_modal.logged_in_as", {
email: this.loggedInEmail,
buttonTitle = translateText("account_modal.linked_account", {
account_name: this.loggedInEmail,
});
} else if (this.loggedInDiscord) {
buttonTitle = translateText("account_modal.logged_in_with_discord");
buttonTitle = translateText("account_modal.linked_account");
}
return html`
+144
View File
@@ -0,0 +1,144 @@
import { z } from "zod";
import {
PlayerProfile,
PlayerProfileSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { getAuthHeader, logOut, userAuth } from "./Auth";
export async function fetchPlayerById(
playerId: string,
): Promise<PlayerProfile | false> {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
const { jwt } = userAuthResult;
const url = `${getApiBase()}/player/${encodeURIComponent(playerId)}`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${jwt}`,
},
});
if (res.status !== 200) {
console.warn(
"fetchPlayerById: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = PlayerProfileSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchPlayerById: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchPlayerById: request failed", err);
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
const { jwt } = userAuthResult;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${jwt}`,
},
});
if (response.status === 401) {
await logOut();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
return result.data;
} catch (e) {
return false;
}
}
export async function createCheckoutSession(
priceId: string,
colorPaletteName: string | null,
): Promise<string | false> {
try {
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({
priceId: priceId,
hostname: window.location.origin,
colorPaletteName: colorPaletteName,
}),
},
);
if (!response.ok) {
console.error(
"createCheckoutSession: request failed",
response.status,
response.statusText,
);
return false;
}
const json = await response.json();
return json.url;
} catch (e) {
console.error("createCheckoutSession: request failed", e);
return false;
}
}
export function getApiBase() {
const domainname = getAudience();
if (domainname === "localhost") {
const apiDomain = process?.env?.API_DOMAIN;
if (apiDomain) {
return `https://${apiDomain}`;
}
return localStorage.getItem("apiHost") ?? "http://localhost:8787";
}
return `https://api.${domainname}`;
}
export function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
// Check if the user's account is linked to a Discord or email account.
export function hasLinkedAccount(
userMeResponse: UserMeResponse | false,
): boolean {
return (
userMeResponse !== false &&
(userMeResponse.user?.discord !== undefined ||
userMeResponse.user?.email !== undefined)
);
}
+222
View File
@@ -0,0 +1,222 @@
import { decodeJwt } from "jose";
import { z } from "zod";
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { base64urlToUuid } from "../core/Base64";
import { getApiBase, getAudience } from "./Api";
import { generateCryptoRandomUUID } from "./Utils";
export type UserAuth = { jwt: string; claims: TokenPayload } | false;
const PERSISTENT_ID_KEY = "player_persistent_id";
let __jwt: string | null = null;
export function discordLogin() {
const redirectUri = encodeURIComponent(window.location.href);
window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`;
}
export async function tempTokenLogin(token: string): Promise<string | null> {
const response = await fetch(
`${getApiBase()}/auth/login/token?login-token=${token}`,
{
credentials: "include",
},
);
if (response.status !== 200) {
console.error("Token login failed", response);
return null;
}
const json = await response.json();
const { email } = json;
return email;
}
export async function getAuthHeader(): Promise<string> {
const userAuthResult = await userAuth();
if (!userAuthResult) return "";
const { jwt } = userAuthResult;
return `Bearer ${jwt}`;
}
export async function logOut(allSessions: boolean = false): Promise<boolean> {
try {
const response = await fetch(
getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"),
{
method: "POST",
credentials: "include",
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
} catch (e) {
console.error("Logout failed", e);
return false;
} finally {
__jwt = null;
localStorage.removeItem(PERSISTENT_ID_KEY);
}
}
export async function isLoggedIn(): Promise<boolean> {
const userAuthResult = await userAuth();
return userAuthResult !== false;
}
export async function userAuth(
shouldRefresh: boolean = true,
): Promise<UserAuth> {
try {
const jwt = __jwt;
if (!jwt) {
if (!shouldRefresh) {
console.warn("No JWT found and shouldRefresh is false");
return false;
}
console.log("No JWT found");
await refreshJwt();
return userAuth(false);
}
// Verify the JWT (requires browser support)
// const jwks = createRemoteJWKSet(
// new URL(getApiBase() + "/.well-known/jwks.json"),
// );
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
// issuer: getApiBase(),
// audience: getAudience(),
// });
const payload = decodeJwt(jwt);
const { iss, aud, exp } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
console.error('unexpected "iss" claim value');
logOut();
return false;
}
const myAud = getAudience();
if (myAud !== "localhost" && aud !== myAud) {
// JWT was not issued for this website
console.error('unexpected "aud" claim value');
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp - 3 * 60) {
console.log("jwt expired or about to expire");
if (!shouldRefresh) {
console.error("jwt expired and shouldRefresh is false");
return false;
}
await refreshJwt();
// Try to get login info again after refreshing
return userAuth(false);
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid payload", error);
return false;
}
const claims = result.data;
return { jwt, claims };
} catch (e) {
console.error("isLoggedIn failed", e);
return false;
}
}
async function refreshJwt(): Promise<void> {
try {
console.log("Refreshing jwt");
const response = await fetch(getApiBase() + "/auth/refresh", {
method: "POST",
credentials: "include",
});
if (response.status !== 200) {
console.error("Refresh failed", response);
logOut();
return;
}
const json = await response.json();
const { jwt } = json;
console.log("Refresh succeeded");
__jwt = jwt;
} catch (e) {
console.error("Refresh failed", e);
logOut();
return;
}
}
export async function sendMagicLink(email: string): Promise<boolean> {
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/auth/magic-link`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
redirectDomain: window.location.origin,
email: email,
}),
});
if (response.ok) {
return true;
} else {
console.error(
"Failed to send recovery email:",
response.status,
response.statusText,
);
return false;
}
} catch (error) {
console.error("Error sending recovery email:", error);
return false;
}
}
// WARNING: DO NOT EXPOSE THIS ID
export async function getPlayToken(): Promise<string> {
const result = await userAuth();
if (result !== false) return result.jwt;
return getPersistentIDFromLocalStorage();
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentID(): string {
const jwt = __jwt;
if (!jwt) return getPersistentIDFromLocalStorage();
const payload = decodeJwt(jwt);
const sub = payload.sub;
if (!sub) return getPersistentIDFromLocalStorage();
return base64urlToUuid(sub);
}
// WARNING: DO NOT EXPOSE THIS ID
function getPersistentIDFromLocalStorage(): string {
// Try to get existing localStorage
const value = localStorage.getItem(PERSISTENT_ID_KEY);
if (value) return value;
// If no localStorage exists, create new ID and set localStorage
const newID = generateCryptoRandomUUID();
localStorage.setItem(PERSISTENT_ID_KEY, newID);
return newID;
}
+2 -3
View File
@@ -26,6 +26,7 @@ import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import { WorkerClient } from "../core/worker/WorkerClient";
import { getPersistentID } from "./Auth";
import {
AutoUpgradeEvent,
DoBoatAttackEvent,
@@ -36,7 +37,6 @@ import {
TickMetricsEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentID } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
SendAttackIntentEvent,
@@ -57,7 +57,6 @@ export interface LobbyConfig {
playerName: string;
clientID: ClientID;
gameID: GameID;
token: string;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
gameStartInfo?: GameStartInfo;
@@ -238,7 +237,7 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
private saveGame(update: WinUpdate) {
private async saveGame(update: WinUpdate) {
if (this.myPlayer === null) {
return;
}
+6 -29
View File
@@ -5,8 +5,7 @@ import {
CosmeticsSchema,
Pattern,
} from "../core/CosmeticSchemas";
import { getApiBase, getAuthHeader } from "./jwt";
import { getPersistentID } from "./Main";
import { createCheckoutSession, getApiBase } from "./Api";
export async function handlePurchase(
pattern: Pattern,
@@ -17,37 +16,15 @@ export async function handlePurchase(
return;
}
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: getAuthHeader(),
"X-Persistent-Id": getPersistentID(),
},
body: JSON.stringify({
priceId: pattern.product.priceId,
hostname: window.location.origin,
colorPaletteName: colorPalette?.name,
}),
},
const url = await createCheckoutSession(
pattern.product.priceId,
colorPalette?.name ?? null,
);
if (!response.ok) {
console.error(
`Error purchasing pattern:${response.status} ${response.statusText}`,
);
if (response.status === 401) {
alert("You are not logged in. Please log in to purchase a pattern.");
} else {
alert("Something went wrong. Please try again later.");
}
if (url === false) {
alert("Failed to create checkout session.");
return;
}
const { url } = await response.json();
// Redirect to Stripe checkout
window.location.href = url;
}
+1 -1
View File
@@ -4,10 +4,10 @@ import { translateText } from "../client/Utils";
import { GameInfo, GameRecordSchema } from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getApiBase } from "./Api";
import { JoinLobbyEvent } from "./Main";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { getApiBase } from "./jwt";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
+1 -1
View File
@@ -17,9 +17,9 @@ import {
getClanTag,
replacer,
} from "../core/Util";
import { getPersistentID } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import { getPersistentID } from "./Main";
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class LocalServer {
+5 -49
View File
@@ -7,6 +7,8 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { fetchCosmetics } from "./Cosmetics";
import "./DarkModeButton";
@@ -36,14 +38,9 @@ import { SendKickPlayerIntentEvent } from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import {
generateCryptoRandomUUID,
incrementGamesPlayed,
isInIframe,
} from "./Utils";
import { incrementGamesPlayed, isInIframe } from "./Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { getUserMe, isLoggedIn } from "./jwt";
import "./styles.css";
declare global {
@@ -115,7 +112,7 @@ class Client {
constructor() {}
initialize(): void {
async initialize(): Promise<void> {
// Prefetch turnstile token so it is available when
// the user joins a lobby.
this.turnstileTokenPromise = getTurnstileToken();
@@ -284,7 +281,7 @@ class Client {
}
};
if (isLoggedIn() === false) {
if ((await userAuth()) === false) {
// Not logged in
onUserMe(false);
} else {
@@ -498,7 +495,6 @@ class Client {
},
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
@@ -650,46 +646,6 @@ document.addEventListener("DOMContentLoaded", () => {
new Client().initialize();
});
// WARNING: DO NOT EXPOSE THIS ID
export function getPlayToken(): string {
const result = isLoggedIn();
if (result !== false) return result.token;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentID(): string {
const result = isLoggedIn();
if (result !== false) return result.claims.sub;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
function getPersistentIDFromCookie(): string {
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [cookieName, cookieValue] = cookie.split("=").map((c) => c.trim());
if (cookieName === COOKIE_NAME) {
return cookieValue;
}
}
// If no cookie exists, create new ID and set cookie
const newID = generateCryptoRandomUUID();
document.cookie = [
`${COOKIE_NAME}=${newID}`,
`max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years
"path=/",
"SameSite=Strict",
"Secure",
].join(";");
return newID;
}
async function getTurnstileToken(): Promise<{
token: string;
createdAt: number;
+4 -3
View File
@@ -2,9 +2,10 @@ import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { generateID } from "../core/Util";
import { getPlayToken } from "./Auth";
import "./components/Difficulties";
import "./components/PatternButton";
import { getPlayToken, JoinLobbyEvent } from "./Main";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@customElement("matchmaking-modal")
@@ -53,7 +54,7 @@ export class MatchmakingModal extends LitElement {
const config = await getServerConfigFromClient();
this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`);
this.socket.onopen = () => {
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
setTimeout(() => {
// Set a delay so the user can see the "connecting" message,
@@ -64,7 +65,7 @@ export class MatchmakingModal extends LitElement {
this.socket?.send(
JSON.stringify({
type: "auth",
playToken: getPlayToken(),
playToken: await getPlayToken(),
}),
);
};
+1 -1
View File
@@ -4,7 +4,7 @@ import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./jwt";
import { getApiBase } from "./Api";
import { translateText } from "./Utils";
@customElement("stats-modal")
+27 -12
View File
@@ -5,6 +5,7 @@ import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { hasLinkedAccount } from "./Api";
import "./components/Difficulties";
import "./components/PatternButton";
import { renderPatternPreview } from "./components/PatternButton";
@@ -55,7 +56,7 @@ export class TerritoryPatternsModal extends LitElement {
}
async onUserMe(userMeResponse: UserMeResponse | false) {
if (userMeResponse === false) {
if (!hasLinkedAccount(userMeResponse)) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null;
this.selectedColor = null;
@@ -134,17 +135,9 @@ export class TerritoryPatternsModal extends LitElement {
return html`
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
.showOnlyOwned
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>
${hasLinkedAccount(this.userMeResponse)
? this.renderMySkinsButton()
: this.renderNotLoggedInWarning()}
</div>
<div
class="flex flex-wrap gap-4 p-2"
@@ -164,6 +157,28 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
private renderMySkinsButton(): TemplateResult {
return html`<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
.showOnlyOwned
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>`;
}
private renderNotLoggedInWarning(): TemplateResult {
return html`<label
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg bg-red-500 text-white"
>
${translateText("territory_patterns.not_logged_in")}
</label>`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (
this.userMeResponse === false
+2 -2
View File
@@ -1,8 +1,8 @@
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators.js";
import { tempTokenLogin } from "./Auth";
import "./components/Difficulties";
import "./components/PatternButton";
import { tokenLogin } from "./jwt";
import { translateText } from "./Utils";
@customElement("token-login")
@@ -79,7 +79,7 @@ export class TokenLoginModal extends LitElement {
return;
}
try {
this.email = await tokenLogin(this.token);
this.email = await tempTokenLogin(this.token);
if (!this.email) {
return;
}
+5 -4
View File
@@ -25,6 +25,7 @@ import {
Winner,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { getPlayToken } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
@@ -388,25 +389,25 @@ export class Transport {
}
}
joinGame() {
async joinGame() {
this.sendMsg({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
token: await getPlayToken(),
} satisfies ClientJoinMessage);
}
rejoinGame(lastTurn: number) {
async rejoinGame(lastTurn: number) {
this.sendMsg({
type: "rejoin",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: lastTurn,
token: this.lobbyConfig.token,
token: await getPlayToken(),
} satisfies ClientRejoinMessage);
}
+1 -1
View File
@@ -10,13 +10,13 @@ import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
import "../../components/PatternButton";
import {
fetchCosmetics,
handlePurchase,
patternRelationship,
} from "../../Cosmetics";
import { getUserMe } from "../../jwt";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
-298
View File
@@ -1,298 +0,0 @@
import { decodeJwt } from "jose";
import { z } from "zod";
import {
PlayerProfile,
PlayerProfileSchema,
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
export function getApiBase() {
const domainname = getAudience();
if (domainname === "localhost") {
const apiDomain = process?.env?.API_DOMAIN;
if (apiDomain) {
return `https://${apiDomain}`;
}
return localStorage.getItem("apiHost") ?? "http://localhost:8787";
}
return `https://api.${domainname}`;
}
function getToken(): string | null {
// Check cookie
const cookie = document.cookie
.split(";")
.find((c) => c.trim().startsWith("token="))
?.trim()
.substring(6);
if (cookie !== undefined) {
return cookie;
}
// Check local storage
return localStorage.getItem("token");
}
async function clearToken() {
localStorage.removeItem("token");
__isLoggedIn = false;
const config = await getServerConfigFromClient();
const audience = config.jwtAudience();
const isSecure = window.location.protocol === "https:";
const secure = isSecure ? "; Secure" : "";
document.cookie = `token=logged_out; Path=/; Max-Age=0; Domain=${audience}${secure}`;
}
export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
export async function tokenLogin(token: string): Promise<string | null> {
const response = await fetch(
`${getApiBase()}/login/token?login-token=${token}`,
{
credentials: "include",
},
);
if (response.status !== 200) {
console.error("Token login failed", response);
return null;
}
const json = await response.json();
const { email } = json;
return email;
}
export function getAuthHeader(): string {
const token = getToken();
if (!token) return "";
return `Bearer ${token}`;
}
export async function logOut(allSessions: boolean = false) {
const token = getToken();
if (token === null) return;
clearToken();
const response = await fetch(
getApiBase() + (allSessions ? "/revoke" : "/logout"),
{
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
}
export type IsLoggedInResponse =
| { token: string; claims: TokenPayload }
| false;
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
export function isLoggedIn(): IsLoggedInResponse {
__isLoggedIn ??= _isLoggedIn();
return __isLoggedIn;
}
function _isLoggedIn(): IsLoggedInResponse {
try {
const token = getToken();
if (!token) {
// console.log("No token found");
return false;
}
// Verify the JWT (requires browser support)
// const jwks = createRemoteJWKSet(
// new URL(getApiBase() + "/.well-known/jwks.json"),
// );
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
// issuer: getApiBase(),
// audience: getAudience(),
// });
// Decode the JWT
const payload = decodeJwt(token);
const { iss, aud, exp, iat } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
console.error(
'unexpected "iss" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const myAud = getAudience();
if (myAud !== "localhost" && aud !== myAud) {
// JWT was not issued for this website
console.error(
'unexpected "aud" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp) {
// JWT expired
console.error(
'after "exp" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const refreshAge: number = 3 * 24 * 3600; // 3 days
if (iat !== undefined && now >= iat + refreshAge) {
console.log("Refreshing access token...");
postRefresh().then((success) => {
if (success) {
console.log("Refreshed access token successfully.");
} else {
console.error("Failed to refresh access token.");
// TODO: Update the UI to show logged out state
}
});
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
const error = z.prettifyError(result.error);
// Invalid response
console.error("Invalid payload", error);
return false;
}
const claims = result.data;
return { token, claims };
} catch (e) {
console.log(e);
return false;
}
}
export async function postRefresh(): Promise<boolean> {
try {
const token = getToken();
if (!token) return false;
// Refresh the JWT
const response = await fetch(getApiBase() + "/refresh", {
method: "POST",
credentials: "include",
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status === 401) {
clearToken();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = RefreshResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
localStorage.setItem("token", result.data.token);
// Clear the cached logged in state
// so that the next call to isLoggedIn() will refresh the token
__isLoggedIn = undefined;
return true;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const token = getToken();
if (!token) return false;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status === 401) {
clearToken();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
return result.data;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
export async function fetchPlayerById(
playerId: string,
): Promise<PlayerProfile | false> {
try {
const base = getApiBase();
const token = getToken();
if (!token) return false;
const url = `${base}/player/${encodeURIComponent(playerId)}`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (res.status !== 200) {
console.warn(
"fetchPlayerById: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = PlayerProfileSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchPlayerById: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchPlayerById: request failed", err);
return false;
}
}
+1 -1
View File
@@ -364,7 +364,7 @@ export class DefaultConfig implements Config {
trainSpawnRate(numPlayerFactories: number): number {
// hyperbolic decay, midpoint at 10 factories
// expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories)
return (numPlayerFactories + 10) * 16;
return (numPlayerFactories + 10) * 18;
}
trainGold(rel: "self" | "team" | "ally" | "other"): Gold {
switch (rel) {
-10
View File
@@ -138,8 +138,6 @@ export class MapPlaylist {
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const team1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const team2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
@@ -154,14 +152,6 @@ export class MapPlaylist {
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
return false;
}
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) {
return false;
}
}
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
return false;
}
}
return true;
}
+19 -9
View File
@@ -337,9 +337,14 @@ export async function startWorker() {
// Verify token signature
const result = await verifyClientToken(clientMsg.token, config);
if (result === false) {
log.warn("Unauthorized: Invalid token");
ws.close(1002, "Unauthorized");
if (result.type === "error") {
log.warn(`Invalid token: ${result.message}`, {
clientID: clientMsg.clientID,
});
ws.close(
1002,
`Unauthorized: invalid token for client ${clientMsg.clientID}`,
);
return;
}
const { persistentId, claims } = result;
@@ -374,13 +379,18 @@ export async function startWorker() {
} else {
// Verify token and get player permissions
const result = await getUserMe(clientMsg.token, config);
if (result === false) {
log.warn("Unauthorized: Invalid session");
ws.close(1002, "Unauthorized");
if (result.type === "error") {
log.warn(`Unauthorized: ${result.message}`, {
clientID: clientMsg.clientID,
});
ws.close(
1002,
`Unauthorized: user me fetch failed for client ${clientMsg.clientID}`,
);
return;
}
roles = result.player.roles;
flares = result.player.flares;
roles = result.response.player.roles;
flares = result.response.player.flares;
if (allowedFlares !== undefined) {
const allowed =
@@ -422,7 +432,7 @@ export async function startWorker() {
clientID: clientMsg.clientID,
reason: turnstileResult.reason,
});
ws.close(1002, "Unauthorized");
ws.close(1002, "Unauthorized: Turnstile token rejected");
return;
case "error":
// Fail open, allow the client to join.
+35 -17
View File
@@ -11,17 +11,18 @@ import { PersistentIdSchema } from "../core/Schemas";
type TokenVerificationResult =
| {
type: "success";
persistentId: string;
claims: TokenPayload | null;
}
| false;
| { type: "error"; message: string };
export async function verifyClientToken(
token: string,
config: ServerConfig,
): Promise<TokenVerificationResult> {
if (PersistentIdSchema.safeParse(token).success) {
return { persistentId: token, claims: null };
return { type: "success", persistentId: token, claims: null };
}
try {
const issuer = config.jwtIssuer();
@@ -34,22 +35,33 @@ export async function verifyClientToken(
});
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
const error = z.prettifyError(result.error);
console.warn("Error parsing token payload", error);
return false;
return {
type: "error",
message: z.prettifyError(result.error),
};
}
const claims = result.data;
const persistentId = claims.sub;
return { persistentId, claims };
return { type: "success", persistentId, claims };
} catch (e) {
return false;
const message =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "An unknown error occurred";
return { type: "error", message };
}
}
export async function getUserMe(
token: string,
config: ServerConfig,
): Promise<UserMeResponse | false> {
): Promise<
| { type: "success"; response: UserMeResponse }
| { type: "error"; message: string }
> {
try {
// Get the user object
const response = await fetch(config.jwtIssuer() + "/users/@me", {
@@ -57,19 +69,25 @@ export async function getUserMe(
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
if (response.status !== 200) {
return {
type: "error",
message: `Failed to fetch user me: ${response.statusText}`,
};
}
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
return {
type: "error",
message: `Invalid response: ${z.prettifyError(result.error)}`,
};
}
return result.data;
return { type: "success", response: result.data };
} catch (e) {
return false;
return {
type: "error",
message: `Failed to fetch user me: ${e}`,
};
}
}