Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
web:
name: Web (typecheck, test, build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- run: npm test
# The build prerenders / and /sitemap.xml, which touch WCL/Redis. Those
# calls degrade gracefully when the env is absent (see FeaturedReports /
# kv-cache), so CI needs no secrets.
- run: npm run build
# Lint is non-blocking for now: the repo has pre-existing lint errors
# (components/ui/meteors.tsx, lib/analysis-engine.ts, …) unrelated to CI.
# Flip continue-on-error off once that debt is cleared.
- name: Lint (non-blocking)
run: npm run lint
continue-on-error: true

bot:
name: Bot (build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: bot/package-lock.json
- run: npm ci
working-directory: bot
- run: npm run build
working-directory: bot
207 changes: 207 additions & 0 deletions PRODUCTION_HARDENING.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion app/api/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { wclQuery } from "@/lib/wcl-client";
import { cachedApiHandler, parseBody } from "@/lib/api-utils";
import { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils";
import { checkRateLimit } from "@/lib/rate-limit";
import {
PLAYER_FULL_DATA_QUERY,
PLAYER_FULL_DATA_QUERY_HEALING,
Expand All @@ -14,7 +15,7 @@
import { flattenPlayerDetails, parsePlayerSpec } from "@/lib/wcl-helpers";
import {
AnalyzeRequest,
AnalysisResult,

Check warning on line 18 in app/api/analyze/route.ts

View workflow job for this annotation

GitHub Actions / Web (typecheck, test, build)

'AnalysisResult' is defined but never used
WCLRankingsData,
WCLPlayerDetails,
WCLDamageEntry,
Expand Down Expand Up @@ -60,6 +61,16 @@
const body = parsed.body;
const { reportCode, fightId, sourceId } = body;

const limited = await checkRateLimit(request, "analyze");
if (limited) return limited;

// Validate before building the cache key / querying WCL. Number.isInteger
// rejects non-numbers too, and 0 stays valid (fight/source slots are 0-indexed).
if (!isValidReportCode(reportCode)) return badRequest("Invalid report code.");
if (!Number.isInteger(fightId) || !Number.isInteger(sourceId)) {
return badRequest("Invalid fight or source id — expected integers.");
}

return cachedApiHandler(`analyze-${reportCode}-${fightId}-${sourceId}`, async () => {
// Step 1: We need player details first to detect role, so fetch with DPS query initially
// and re-fetch with healing query if needed
Expand Down
33 changes: 26 additions & 7 deletions app/api/cla/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
buildCLABuffUptimeQuery,
} from "@/lib/wcl-queries";
import { buildCLAResult, type CLAEngineInput } from "@/lib/cla-engine";
import { getWowheadDomain } from "@/lib/constants";
import { getWowheadDomain, MAX_CLA_FIGHTS } from "@/lib/constants";
import { flattenPlayerDetails } from "@/lib/wcl-helpers";
import { mapPool } from "@/lib/async-pool";
import { cachedApiHandler, parseBody } from "@/lib/api-utils";
import { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils";
import { checkRateLimit } from "@/lib/rate-limit";
import type { CLAFightMeta } from "@/lib/cla-types";
import type {
WCLPlayerDetails,
Expand Down Expand Up @@ -51,13 +52,31 @@ export async function POST(request: NextRequest) {
if ("error" in parsed) return parsed.error;
const { reportCode, fightIds } = parsed.body;

if (fightIds.length === 0) {
const limited = await checkRateLimit(request, "cla");
if (limited) return limited;

// Validate BEFORE any use. A non-array `fightIds` (e.g. "abc") would otherwise
// throw on .length/.filter → unhandled 500; non-integer or unbounded lists
// pollute the cache key and can fan out into the shared WCL budget.
if (!isValidReportCode(reportCode)) return badRequest("Invalid report code.");
if (!Array.isArray(fightIds) || fightIds.length === 0) {
return NextResponse.json({ error: "No fights specified" }, { status: 400 });
}
if (!fightIds.every((id) => Number.isInteger(id))) {
return badRequest("Invalid fight id — expected integers.");
}
// Dedupe (collapses accidental repeats) then cap: each fight fans out to
// multiple WCL queries, so an unbounded list could drain the daily budget.
const uniqueFightIds = [...new Set(fightIds)];
if (uniqueFightIds.length > MAX_CLA_FIGHTS) {
return badRequest(
`Too many fights selected — please select ${MAX_CLA_FIGHTS} or fewer.`,
);
}

// Sort a copy numerically — sort() is in-place and lexicographic, which would
// mutate the caller's array and order [2,10] as "10,2".
const cacheFightIds = [...fightIds].sort((a, b) => a - b);
const cacheFightIds = [...uniqueFightIds].sort((a, b) => a - b);
return cachedApiHandler(`cla-${reportCode}-${cacheFightIds.join(",")}`, async () => {
// Step 1: Fetch report metadata (fights + players)
const metaData = await wclQuery<ReportMetaResponse>(REPORT_META_QUERY, {
Expand All @@ -67,8 +86,8 @@ export async function POST(request: NextRequest) {
const zoneName = report.zone?.name;
const wowheadDomain = getWowheadDomain(zoneName, report.zone?.expansion?.id);

// Filter to requested fights
const selectedFights = report.fights.filter((f) => fightIds.includes(f.id));
// Filter to requested fights (deduped + capped set)
const selectedFights = report.fights.filter((f) => uniqueFightIds.includes(f.id));
if (selectedFights.length === 0) {
return NextResponse.json({ error: "No matching fights found" }, { status: 404 });
}
Expand Down Expand Up @@ -116,7 +135,7 @@ export async function POST(request: NextRequest) {
// Fetch player details using all fight IDs
const playerDetailsPromise = wclQuery<PlayerDetailsResponse>(
playerDetailsQuery,
{ code: reportCode, fightIDs: fightIds }
{ code: reportCode, fightIDs: uniqueFightIds }
);

// Batch source IDs into groups of BATCH_SIZE (same for every fight)
Expand Down
11 changes: 10 additions & 1 deletion app/api/raid-overview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
} from "@/lib/wcl-queries";
import { buildRaidOverview } from "@/lib/raid-overview-engine";
import { flattenPlayerDetails } from "@/lib/wcl-helpers";
import { cachedApiHandler, parseBody } from "@/lib/api-utils";
import { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils";
import { checkRateLimit } from "@/lib/rate-limit";
import type {
WCLPlayerDetails,
WCLCombatantInfoEvent,
Expand Down Expand Up @@ -113,6 +114,14 @@ export async function POST(request: NextRequest) {
if ("error" in parsed) return parsed.error;
const { reportCode, fightId } = parsed.body;

const limited = await checkRateLimit(request, "raid-overview");
if (limited) return limited;

if (!isValidReportCode(reportCode)) return badRequest("Invalid report code.");
if (!Number.isInteger(fightId)) {
return badRequest("Invalid fight id — expected an integer.");
}

return cachedApiHandler(`rpb-${reportCode}-${fightId}`, async () => {
const [overviewData, combatantData, deathEventsData] = await Promise.all([
wclQuery<RaidOverviewResponse>(RAID_OVERVIEW_QUERY, {
Expand Down
4 changes: 4 additions & 0 deletions app/api/report/[code]/players/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { wclQuery } from "@/lib/wcl-client";
import { errorResponse } from "@/lib/api-utils";
import { checkRateLimit } from "@/lib/rate-limit";
import type { WCLPlayerDetails } from "@/lib/wcl-types";

interface PlayerDetailsResponse {
Expand Down Expand Up @@ -39,6 +40,9 @@ export async function GET(
return NextResponse.json({ error: "Missing fightId" }, { status: 400 });
}

const limited = await checkRateLimit(request, "report-players");
if (limited) return limited;

try {
const data = await wclQuery<PlayerDetailsResponse>(QUERY, {
code,
Expand Down
6 changes: 5 additions & 1 deletion app/api/report/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { wclQuery, WCLError } from "@/lib/wcl-client";
import { REPORT_META_QUERY } from "@/lib/wcl-queries";
import { errorResponse } from "@/lib/api-utils";
import { checkRateLimit } from "@/lib/rate-limit";
import { mapReportMeta } from "@/lib/report-meta";
import { WCLReportData } from "@/lib/wcl-types";

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ code: string }> }
) {
const { code } = await params;
Expand All @@ -15,6 +16,9 @@ export async function GET(
return NextResponse.json({ error: "Invalid report code" }, { status: 400 });
}

const limited = await checkRateLimit(request, "report");
if (limited) return limited;

try {
const data = await wclQuery<{ reportData: { report: WCLReportData } }>(
REPORT_META_QUERY,
Expand Down
18 changes: 9 additions & 9 deletions app/components/PostHogProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ export default function PostHogProvider({ children }: { children: React.ReactNod
// Session replay
disable_session_recording: false,
session_recording: {
maskAllInputs: false,
maskInputFn: (text, element) => {
// Only mask actual sensitive fields, not the report URL input
const el = element as HTMLInputElement | null;
if (el?.type === "password") return "*".repeat(text.length);
return text;
},
// Mask all inputs by default. This masks the report-URL input too, which
// is an acceptable trade for a privacy-safe default (vs. the previous
// un-masking that could capture whatever a user typed).
maskAllInputs: true,
},
// Console log capture
enable_recording_console_log: true,
// Don't capture console logs into replays — they can hoover up anything
// logged client-side.
enable_recording_console_log: false,
// TODO: serving EU users with session replay ultimately needs a consent
// banner — that's a product decision, out of scope here.
// Autocapture clicks, inputs, form submits
autocapture: true,
loaded: (ph) => {
Expand Down
31 changes: 20 additions & 11 deletions app/og/route.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server";
import { CLASS_COLORS } from "@/lib/constants";
import { isValidReportCode } from "@/lib/api-utils";
import type { AnalysisResult, ReportMeta } from "@/lib/wcl-types";

// Dynamic Open Graph image for shared analyze links. Only hit by link unfurlers
Expand Down Expand Up @@ -184,24 +185,32 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const reportCode = searchParams.get("report");
const fight = searchParams.get("fight");
const source = searchParams.get("source");
const origin = new URL(request.url).origin;
const fightRaw = searchParams.get("fight");
const sourceRaw = searchParams.get("source");
// Fetch our own API by an absolute origin. Pin to the canonical host in
// production (an attacker can't steer us via a spoofed Host/origin), and
// only fall back to the request origin in local dev.
const origin =
process.env.NODE_ENV === "production"
? "https://parseforge.gg"
: new URL(request.url).origin;

if (!reportCode) {
// Invalid/absent code → branded fallback, never fetch.
if (!isValidReportCode(reportCode)) {
return new ImageResponse(<ReportCard meta={null} reportCode="" />, { ...size, headers });
}

// Player scorecard when we have a specific fight + player.
if (fight && source) {
// Player scorecard only when fight + source are valid non-negative integers.
const fightId = fightRaw != null ? Number.parseInt(fightRaw, 10) : NaN;
const sourceId = sourceRaw != null ? Number.parseInt(sourceRaw, 10) : NaN;
if (
Number.isInteger(fightId) && fightId >= 0 &&
Number.isInteger(sourceId) && sourceId >= 0
) {
const data = await fetchJson<AnalysisResult>(`${origin}/api/analyze`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
reportCode,
fightId: Number(fight),
sourceId: Number(source),
}),
body: JSON.stringify({ reportCode, fightId, sourceId }),
});
if (data?.playerName) {
return new ImageResponse(<PlayerCard data={data} />, { ...size, headers });
Expand Down
8 changes: 8 additions & 0 deletions bot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ RUN npm run build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
# Drop root — run as the image's built-in unprivileged `node` user. `dist` is
# copied root-owned and read-only, which is all the app needs.
USER node
# NOTE: the bot exits on fatal startup errors, so the runtime must restart it —
# set a restart policy (`restart: unless-stopped` in compose, or an equivalent
# Kubernetes restartPolicy). Phase 1.4 hardened the request paths so runtime
# errors no longer crash it; only startup fatals exit, for a clean restart.
CMD ["node", "dist/index.js"]
37 changes: 35 additions & 2 deletions bot/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import type { ReportMeta, RaidOverviewResult, AnalysisResult } from "./types.js"

const API_URL = process.env.PARSEFORGE_API_URL || "https://parseforge.gg";

/** A failed ParseForge API call, carrying the status and a clean, postable message. */
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}

async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...options,
Expand All @@ -12,13 +23,35 @@ async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
});

if (!res.ok) {
const text = await res.text().catch(() => "Unknown error");
throw new Error(`API ${res.status}: ${text}`);
// Never echo raw upstream bodies into Discord. Surface only the API's
// `error` field when the body is JSON, otherwise a generic message.
let message = "Something went wrong. Please try again.";
const text = await res.text().catch(() => "");
if (text) {
try {
const parsed = JSON.parse(text) as { error?: unknown };
if (typeof parsed.error === "string" && parsed.error) message = parsed.error;
} catch {
// Non-JSON body — keep the generic message.
}
}
throw new ApiError(res.status, message);
}

return res.json() as Promise<T>;
}

/** User-facing text for an API failure, always safe to post in a Discord reply. */
export function describeApiError(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return "ParseForge is handling a lot of requests right now — give it a few seconds and try again.";
}
return err.message;
}
return "Something went wrong. Please try again.";
}

export function fetchReportMeta(reportCode: string): Promise<ReportMeta> {
return apiFetch<ReportMeta>(`/api/report/${reportCode}`);
}
Expand Down
5 changes: 2 additions & 3 deletions bot/src/commands/analyze.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js";
import { parseWCLUrl } from "../util/parse-url.js";
import { fetchReportMeta, fetchRaidOverview, fetchAnalysis } from "../api.js";
import { fetchReportMeta, fetchRaidOverview, fetchAnalysis, describeApiError } from "../api.js";
import { buildAnalyzeEmbed } from "../embeds/analyze-embed.js";
import { buildRaidEmbed } from "../embeds/raid-embed.js";

Expand Down Expand Up @@ -57,7 +57,6 @@ export async function handleAnalyze(
...reply,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
await interaction.editReply(`Failed to fetch analysis: ${message}`);
await interaction.editReply(describeApiError(err));
}
}
5 changes: 2 additions & 3 deletions bot/src/commands/raid.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js";
import { parseWCLUrl } from "../util/parse-url.js";
import { fetchReportMeta, fetchRaidOverview } from "../api.js";
import { fetchReportMeta, fetchRaidOverview, describeApiError } from "../api.js";
import { buildRaidEmbed } from "../embeds/raid-embed.js";

export async function handleRaid(
Expand Down Expand Up @@ -34,7 +34,6 @@ export async function handleRaid(
const reply = buildRaidEmbed(result, parsed.code, fightId);
await interaction.editReply(reply);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
await interaction.editReply(`Failed to fetch raid data: ${message}`);
await interaction.editReply(describeApiError(err));
}
}
Loading
Loading