Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -613,5 +613,10 @@
"radial_menu": {
"delete_unit_title": "Delete Unit",
"delete_unit_description": "Click to delete the nearest unit"
},
"replay": {
"not_found": "Replay data not found",
"error": "Failed to read replay data",
"invalid": "Failed to parse replay data"
}
}
3 changes: 3 additions & 0 deletions src/client/JoinPrivateLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ export class JoinPrivateLobbyModal extends LitElement {
}),
);

return true;
} else if (archiveData.error) {
this.message = translateText(archiveData.error);
return true;
}

Expand Down
13 changes: 13 additions & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,16 @@ export const GameRecordSchema = AnalyticsRecordSchema.extend({
turns: TurnSchema.array(),
});
export type GameRecord = z.infer<typeof GameRecordSchema>;

export const RedactedGameRecordSchema = GameRecordSchema.omit({
info: true,
}).extend({
info: GameEndInfoSchema.omit({
players: true,
}).extend({
players: PlayerRecordSchema.omit({
persistentID: true,
}).array(),
}),
});
export type RedactedGameRecord = z.infer<typeof RedactedGameRecordSchema>;
1 change: 1 addition & 0 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type ServerConfig = {
cloudflareCredsPath(): string;
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
replayUrl(gameId: GameID): string;
};

export type NukeMagnitude = {
Expand Down
5 changes: 5 additions & 0 deletions src/core/configuration/DefaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ export abstract class DefaultServerConfig implements ServerConfig {
workerPortByIndex(index: number): number {
return 3001 + index;
}
replayUrl(gameId: GameID): string {
const url = new URL(this.jwtIssuer());
url.pathname = `/game/${gameId}`;
return url.toString();
}
}

export class DefaultConfig implements Config {
Expand Down
8 changes: 7 additions & 1 deletion src/core/configuration/DevConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
import { GameConfig, GameID } from "../Schemas";
import { GameEnv, ServerConfig } from "./Config";
import { UnitInfo, UnitType } from "../game/Game";
import { GameConfig } from "../Schemas";
import { UserSettings } from "../game/UserSettings";

export class DevServerConfig extends DefaultServerConfig {
Expand Down Expand Up @@ -42,6 +42,12 @@ export class DevServerConfig extends DefaultServerConfig {
subdomain(): string {
return "";
}

replayUrl(gameId: GameID): string {
const url = new URL("https://api.openfront.io");
url.pathname = `/game/${gameId}`;
return url.toString();
}
}

export class DevConfig extends DefaultConfig {
Expand Down
163 changes: 133 additions & 30 deletions src/server/Archive.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas";
import { AnalyticsRecord, GameID, GameRecord, RedactedGameRecord, RedactedGameRecordSchema } from "../core/Schemas";
import { S3 } from "@aws-sdk/client-s3";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { logger } from "./Logger";
import { replacer } from "../core/Util";
import { z } from "zod";

const config = getServerConfigFromServer();

Expand All @@ -28,14 +29,14 @@ export async function archive(gameRecord: GameRecord) {
try {
gameRecord.gitCommit = config.gitCommit();
// Archive to R2
await archiveAnalyticsToR2(gameRecord);
await archiveAnalyticsToR2(stripTurns(gameRecord));

// Archive full game if there are turns
if (gameRecord.turns.length > 0) {
log.info(
`${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`,
);
await archiveFullGameToR2(gameRecord);
await archiveFullGameToR2(stripPersistentIds(gameRecord));
}
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
Expand All @@ -56,7 +57,7 @@ export async function archive(gameRecord: GameRecord) {
}
}

async function archiveAnalyticsToR2(gameRecord: GameRecord) {
function stripTurns(gameRecord: GameRecord): AnalyticsRecord {
// Create analytics data object
const { info, version, gitCommit, subdomain, domain } = gameRecord;
const analyticsData: AnalyticsRecord = {
Expand All @@ -66,15 +67,57 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
subdomain,
domain,
};
return analyticsData;
}

function stripPersistentIds(gameRecord: GameRecord): RedactedGameRecord {
// Create replay object
const {
info: {
gameID,
config,
players: privatePlayers,
start,
end,
duration,
num_turns,
winner,
},
version,
gitCommit,
subdomain,
domain,
turns,
} = gameRecord;
const players = privatePlayers.map(
({ clientID, persistentID: _, username, pattern, flag }) => ({
clientID,
username,
pattern,
flag,
}),
);
const replayData: RedactedGameRecord = {
info: { gameID, config, players, start, end, duration, num_turns, winner },
version,
gitCommit,
subdomain,
domain,
turns,
};
return replayData;
}

async function archiveAnalyticsToR2(gameRecord: AnalyticsRecord) {
const { info } = gameRecord;
try {
// Store analytics data using just the game ID as the key
const analyticsKey = `${info.gameID}.json`;

await r2.putObject({
Bucket: bucket,
Key: `${analyticsFolder}/${analyticsKey}`,
Body: JSON.stringify(analyticsData, replacer),
Body: JSON.stringify(gameRecord, replacer),
ContentType: "application/json",
});

Expand All @@ -99,20 +142,12 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
}
}

async function archiveFullGameToR2(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = structuredClone(gameRecord);

// Players may see this so make sure to clear PII
recordCopy.info.players.forEach((p) => {
p.persistentID = "REDACTED";
});

async function archiveFullGameToR2(gameRecord: RedactedGameRecord) {
try {
await r2.putObject({
Bucket: bucket,
Key: `${gameFolder}/${recordCopy.info.gameID}`,
Body: JSON.stringify(recordCopy, replacer),
Key: `${gameFolder}/${gameRecord.info.gameID}`,
Body: JSON.stringify(gameRecord, replacer),
ContentType: "application/json",
});
} catch (error) {
Expand All @@ -125,37 +160,105 @@ async function archiveFullGameToR2(gameRecord: GameRecord) {

export async function readGameRecord(
gameId: GameID,
): Promise<GameRecord | null> {
): Promise<RedactedGameRecord | string> {
try {
// Check if file exists and download in one operation
const response = await r2.getObject({
Bucket: bucket,
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
});
// Parse the response body
if (response.Body === undefined) return null;
if (response.Body === undefined) {
log.warn(`${gameId}: Received empty response from R2`);
return readGameRecordFallback(gameId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a warning here?

}

const bodyContents = await response.Body.transformToString();
return JSON.parse(bodyContents) as GameRecord;
return validateRecord(JSON.parse(bodyContents), gameId);
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
log.info(
`${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`,
);
return null;
} else {
const { message, stack, name } = error;
// Log the error for monitoring purposes
log.info(`${gameId}: Error reading game record from R2: ${error}`, {
message,
stack,
name,
...(error && typeof error === "object" ? error : {}),
Comment on lines +187 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may be redundant to log both error and message

});
}
const { message, stack, name } = error;
// Log the error for monitoring purposes
log.error(`${gameId}: Error reading game record from R2: ${error}`, {
message,
stack,
name,
...(error && typeof error === "object" ? error : {}),
return readGameRecordFallback(gameId);
}
}

export async function readGameRecordFallback(
gameId: GameID,
): Promise<RedactedGameRecord | string> {
try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current return type (RedactedGameRecord | string) works, but it has a couple of drawbacks. It forces callers to do a typeof check, and it limits the amount of information we can pass back on failure.

One option to improve this is to use a discriminated union and to generate a Response from the error handler:

export type Result<T, E> =
  | { success: true; value: T; error: never; }
  | { success: false; value: never; error: E; };
Suggested change
): Promise<RedactedGameRecord | string> {
): Promise<Result<RedactedGameRecord, Response>> {
return { success: false, error: error(404) };

const response = await fetch(config.replayUrl(gameId), {
headers: {
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});

// Return null instead of throwing the error
return null;
if (!response.ok) {
if (response.status === 404) {
return "replay.not_found";
}

throw new Error(
`Http error: non-successful http status ${response.status}`,
);
}
Comment on lines +214 to +216
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider being consistent with error handling. Mixing throw with errors as values can be a bit confusing. In this case I think it is better to log/return from this point in the code.


const contentType = response.headers.get("Content-Type")?.split(";")?.[0];
if (contentType !== "application/json") {
throw new Error(
`Http error: unexpected content type "${response.headers.get("Content-Type")}"`,
);
}
Comment on lines +221 to +223
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above


return validateRecord(await response.json(), gameId);
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.info(
`${gameId}: Error reading game record from public api. Non-Error type: ${String(error)}`,
);
} else {
const { message, stack, name } = error;
log.info(
`${gameId}: Error reading game record from public api: ${error}`,
{
message,
stack,
name,
...(error && typeof error === "object" ? error : {}),
},
);
}
return "replay.error";
}
}

function validateRecord(
json: unknown,
gameId: GameID,
): RedactedGameRecord | string {
const parsed = RedactedGameRecordSchema.safeParse(json);

if (!parsed.success) {
const error = z.prettifyError(parsed.error);
log.error(`${gameId}: Error parsing game record: ${error}`);
return "replay.invalid";
}

return parsed.data;
}

export async function gameRecordExists(gameId: GameID): Promise<boolean> {
Expand Down
23 changes: 16 additions & 7 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { logger } from "./Logger";
import path from "path";
import { preJoinMessageHandler } from "./worker/websocket/handler/message/PreJoinHandler";
import rateLimit from "express-rate-limit";
import { replacer } from "../core/Util";
import { z } from "zod";

const config = getServerConfigFromServer();
Expand Down Expand Up @@ -239,9 +240,9 @@ export async function startWorker() {
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const gameRecord = await readGameRecord(req.params.id);

if (!gameRecord) {
if (typeof gameRecord === "string") {
return res.status(404).json({
error: "Game not found",
error: gameRecord,
exists: false,
success: false,
});
Expand All @@ -265,11 +266,19 @@ export async function startWorker() {
});
}

return res.status(200).json({
exists: true,
gameRecord,
success: true,
});
return res
.status(200)
.header("Content-Type", "application/json")
.send(
JSON.stringify(
{
exists: true,
gameRecord,
success: true,
},
replacer,
),
);
}),
);

Expand Down
3 changes: 3 additions & 0 deletions tests/util/TestServerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,7 @@ export class TestServerConfig implements ServerConfig {
r2SecretKey(): string {
throw new Error("Method not implemented.");
}
replayUrl(gameId: GameID): string {
throw new Error("Method not implemented.");
}
}
Loading