import type { Express, Request } from "express"; import fsPromises from "fs/promises"; import { parse } from "node-html-parser"; import path from "path"; import type { Logger } from "winston"; import { z } from "zod"; import { GAME_ID_REGEX, GameInfo } from "../core/Schemas"; import { replacer } from "../core/Util"; import type { GameManager } from "./GameManager"; import { buildPreview, escapeHtml, ExternalGameInfo, ExternalGameInfoSchema, } from "./GamePreviewBuilder"; import { setNoStoreHeaders } from "./NoStoreHeaders"; import { getAppShellContent, setHtmlNoCacheHeaders } from "./RenderHtml"; import { ServerEnv } from "./ServerEnv"; const requestOrigin = (req: Request): string => { const protoHeader = (req.headers["x-forwarded-proto"] as string) ?? ""; const proto = protoHeader.split(",")[0]?.trim() || req.protocol || "https"; const host = req.get("host") ?? `${ServerEnv.subdomain()}.${ServerEnv.domain()}`; // Force https only for the configured public domain (and its subdomains). // This avoids hardcoding hostnames while ensuring we don't force https on // localhost or arbitrary custom hosts. const hostname = host.split(":")[0].toLowerCase(); const domain = ServerEnv.domain().toLowerCase(); const forceHttps = hostname === domain || hostname.endsWith(`.${domain}`); return `${forceHttps ? "https" : proto}://${host}`; }; export function registerGamePreviewRoute(opts: { app: Express; gm: GameManager; workerId: number; log: Logger; baseDir: string; }) { const { app, gm, log, baseDir } = opts; const gameIDSchema = z.string().regex(GAME_ID_REGEX); const fetchPublicGameInfo = async ( gameID: string, ): Promise => { if (!gameIDSchema.safeParse(gameID).success) return null; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1500); try { const apiDomain = ServerEnv.jwtIssuer(); const encodedID = encodeURIComponent(gameID); const response = await fetch(`${apiDomain}/game/${encodedID}`, { headers: { "x-api-key": ServerEnv.apiKey(), }, signal: controller.signal, }); if (!response.ok) return null; const data = await response.json(); const parsed = ExternalGameInfoSchema.safeParse(data); if (!parsed.success) { log.warn("Invalid ExternalGameInfo from API", { gameID, issues: parsed.error.issues, }); return null; } return parsed.data; } catch (error) { log.warn("failed to fetch public game info", { gameID, error }); return null; } finally { clearTimeout(timeout); } }; app.get("/game/:id", async (req, res) => { const gameID = req.params.id; // Validate gameID format if (!GAME_ID_REGEX.test(gameID)) { return res.status(400).json({ error: "Invalid game ID format" }); } const game = gm.game(gameID); const lobby: GameInfo | null = game ? game.gameInfo() : null; try { const publicInfo = await fetchPublicGameInfo(gameID); // Fetch from central API (DB/Auth) // If we have neither live lobby info nor archived public info, we can't show anything if (!lobby && !publicInfo) { return res.redirect(302, "/"); } const origin = requestOrigin(req); const meta = await buildPreview( gameID, origin, ServerEnv.workerPath(gameID), lobby, publicInfo, ); // Always serve HTML with meta tags for /game/:id route const staticHtml = path.join(baseDir, "../../static/index.html"); const rootHtml = path.join(baseDir, "../../index.html"); let filePath: string | null = null; try { await fsPromises.access(staticHtml); filePath = staticHtml; } catch { try { await fsPromises.access(rootHtml); filePath = rootHtml; } catch { // Neither file exists } } if (filePath) { const html = await getAppShellContent(filePath); const root = parse(html); const head = root.querySelector("head"); if (head) { head .querySelectorAll('meta[property^="og:"], meta[name^="twitter:"]') .forEach((el) => el.remove()); const tagsToInject = [ ``, ``, ``, ``, ``, ``, ``, ``, ]; tagsToInject.forEach((tag) => head.insertAdjacentHTML("beforeend", tag), ); } setHtmlNoCacheHeaders(res); return res.status(200).send(root.toString()); } // Fallback to JSON if HTML file not found setNoStoreHeaders(res); res.setHeader("Content-Type", "application/json"); return res.send(JSON.stringify(lobby ?? publicInfo, replacer)); } catch (error) { log.error("failed to render join preview", { error }); return res.status(500).send("Unable to render lobby preview"); } }); }