From 870f13b15026a0aca4b5c190034c8731efa75032 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 20:11:49 -0400 Subject: [PATCH] Capture team chat and expose moderation match archives --- docs/guides/moderation.md | 16 ++ src/lib/game-chat.ts | 15 +- src/lib/match-history-query.ts | 11 +- src/lib/match-history.ts | 1 - src/lib/server/fleet/chat.ts | 56 +++- src/lib/server/fleet/match-history-access.ts | 16 ++ src/lib/server/fleet/match-history.ts | 14 +- src/lib/server/moderation/evidence.ts | 1 + src/lib/ui/components/ChatPanel.svelte | 6 +- src/lib/ui/components/MatchHistory.svelte | 65 ++++- .../moderation/EvidenceMessages.svelte | 1 + src/routes/api/matches/+server.ts | 17 +- .../api/servers/[name]/matches/+server.ts | 25 +- src/routes/matches/+page.server.ts | 18 +- src/routes/matches/+page.svelte | 14 +- src/routes/matches/[id]/+page.server.ts | 29 ++- src/routes/matches/[id]/+page.svelte | 103 ++++++-- src/routes/moderation/+page.svelte | 1 + src/routes/moderation/cases/[id]/+page.svelte | 2 +- src/routes/servers/[name]/+page.server.ts | 28 +- tests/client/match-archive.test.ts | 95 +++++++ tests/client/ui-match-results.test.ts | 26 ++ tests/unit/game-chat.test.ts | 33 ++- tests/unit/moderation-chat-archive.test.ts | 239 ++++++++++++++++++ tests/unit/moderation-evidence.test.ts | 30 ++- tests/unit/server-chat-refresh.test.ts | 108 +++++++- tests/unit/server-chat.test.ts | 41 ++- tests/unit/ui-match-chat.test.ts | 13 + 28 files changed, 943 insertions(+), 81 deletions(-) create mode 100644 src/lib/server/fleet/match-history-access.ts create mode 100644 tests/client/match-archive.test.ts create mode 100644 tests/unit/moderation-chat-archive.test.ts diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index 9fb4be9..85d5dc1 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -206,6 +206,22 @@ Snapshots remain readable after source-match cleanup. Unknown or estimated timestamps remain such; the snapshot does not turn them into exact observations. Unarchived or already deleted chat cannot be attached as verified evidence. +Open **Chat archive** from Moderation to include all recorded matches, including +round-one games, interrupted games, and games missing an end report. Archive +access requires moderation read permission. Filter by server, game, map, status, +or date and open a match to select its preserved messages. Incomplete matches +show their observed state rather than final results. Ordinary match history and +leaderboard qualification are unchanged. + +Both public (`say`) and team (`sayteam`) log messages are captured. Team messages +carry a **Team chat** label in chat and saved excerpts. Collection runs without a live +viewer and checks surviving logs once after startup, including idle servers. +Archive coverage still depends on the source log and accepted match telemetry: +logs that were deleted or rotated before collection, or old clock segments that +cannot safely be associated with a match, cannot be reconstructed. The collector +retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical +log import. Already saved case excerpts survive later source cleanup. + Case decisions, edits, and restrictions check the current revision; refresh before retrying a decision if another staff member has changed the case. Evidence, restrictions and append-only moderation events survive source-match cleanup and diff --git a/src/lib/game-chat.ts b/src/lib/game-chat.ts index c57dc8c..69c3ff9 100644 --- a/src/lib/game-chat.ts +++ b/src/lib/game-chat.ts @@ -4,6 +4,8 @@ export interface ChatMessage { slot: number; player: string; message: string; + /** Omitted for public chat to preserve existing archived message identities. */ + channel?: 'team'; } export type ChatEntry = @@ -98,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde previousSeconds = seconds; } - const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line); + const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line); if (!match) continue; - const message = plainText(match[5]); + const message = plainText(match[6]); if (!message) continue; push({ type: 'message', message: { time: match[1], - guid: match[2], - slot: Number(match[3]), - player: plainText(match[4]) || 'Unknown player', - message + guid: match[3], + slot: Number(match[4]), + player: plainText(match[5]) || 'Unknown player', + message, + ...(match[2] === 'sayteam' ? { channel: 'team' as const } : {}) } }); } diff --git a/src/lib/match-history-query.ts b/src/lib/match-history-query.ts index ccf6210..2e00e69 100644 --- a/src/lib/match-history-query.ts +++ b/src/lib/match-history-query.ts @@ -10,6 +10,7 @@ export interface MatchHistoryQuery { sort: MatchSort; direction: 'asc' | 'desc'; page: number; + archive: boolean; } export const matchQueryKeys = [ 'game', @@ -19,7 +20,8 @@ export const matchQueryKeys = [ 'status', 'sort', 'direction', - 'page' + 'page', + 'archive' ] as const; export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { const game = params.get('game') ?? ''; @@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { const sort = params.get('sort') || 'started'; const direction = params.get('direction') || 'desc'; const page = Number(params.get('page') || 1); + const archive = params.get('archive') ?? ''; if ( !['', 't5', 't6'].includes(game) || map.length > 1024 || @@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { !['asc', 'desc'].includes(direction) || !Number.isSafeInteger(page) || page < 1 || - page > 1000000 + page > 1000000 || + !['', '0', '1'].includes(archive) ) throw new Error('Invalid match history filters.'); return { @@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { status: status as MatchHistoryQuery['status'], sort: sort as MatchSort, direction: direction as 'asc' | 'desc', - page + page, + archive: archive === '1' }; } /** Namespaced filters also work inside an instance's Matches tab. */ diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index 764951c..c6930de 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -47,7 +47,6 @@ export interface MatchPlayer { } export interface MatchDetail extends ServerMatch { - status: 'completed'; game: string; startObserved: boolean; players: MatchPlayer[]; diff --git a/src/lib/server/fleet/chat.ts b/src/lib/server/fleet/chat.ts index 5a3d2b1..e146c19 100644 --- a/src/lib/server/fleet/chat.ts +++ b/src/lib/server/fleet/chat.ts @@ -212,10 +212,14 @@ interface ChatReadState { entries: ContextualChatEntry[]; contextVersion?: string; wasOccupied: boolean; + needsDrain: boolean; + bytesRead: number; } const readers = new Map(); const failures = new Map(); const CHAT_READ_MIN_AGE_MS = 15_000; +const MAX_IDLE_STALLED_READS = 3; +const idleStalls = new Map(); type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown }); const lastReads = new Map(); @@ -249,6 +253,7 @@ export function collectServerChat( const key = chatReadKey(server); const existing = pending.get(key); if (existing) return existing; + let progressed = false; const work = (async () => { const path = resolveLogPath(server.name, gameLogKey(server.name)); if (!path) throw new Error('Invalid server log path'); @@ -263,7 +268,9 @@ export function collectServerChat( partial: Buffer.alloc(0), skipLine: false, entries: [], - wasOccupied: false + wasOccupied: false, + needsDrain: false, + bytesRead: 0 }; const tail = await readLogChunk(path, state.cursor); const current = getServer(server.name); @@ -290,7 +297,10 @@ export function collectServerChat( state.entries = []; } state.cursor = tail.cursor; - const bytes = Buffer.concat([state.partial, Buffer.from(tail.contentBase64, 'base64')]); + const content = Buffer.from(tail.contentBase64, 'base64'); + progressed = content.length > 0; + state.bytesRead += content.length; + const bytes = Buffer.concat([state.partial, content]); const end = bytes.lastIndexOf(10) + 1; if (end) { const lines = bytes @@ -318,14 +328,17 @@ export function collectServerChat( state.skipLine = true; } // File mtime anchors the final line only after the backlog has been read. - if (tail.cursor.offset < tail.size || state.partial.length) { + state.needsDrain = tail.cursor.offset < tail.size || state.partial.length > 0; + if (state.needsDrain) { state.signature = undefined; readers.set(server.name, state); return { entries: currentChatEntries(current, state.entries), missing: false }; } } else { // Older agents remain readable during a rolling update. + state.needsDrain = false; changed = state.signature !== `${tail.size}:${tail.mtime}`; + progressed = changed; if (changed) state.lines = tail.lines; } const signature = `${tail.size}:${tail.mtime}`; @@ -354,6 +367,9 @@ export function collectServerChat( })() .then( (result) => { + const state = readers.get(server.name); + if (progressed || result.missing || (state?.key === key && !state.needsDrain)) + idleStalls.delete(key); lastReads.set(key, { completedAt: Date.now(), missing: result.missing }); return result; }, @@ -377,25 +393,53 @@ export async function collectFleetChat(): Promise { for (const name of readers.keys()) if (!names.has(name)) readers.delete(name); for (const name of failures.keys()) if (!names.has(name)) failures.delete(name); for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key); + for (const key of idleStalls.keys()) if (!keys.has(key)) idleStalls.delete(key); const servers = live.filter((server) => { if (server.host_id && !hosts.get(server.host_id)?.connected) return false; if (server.container_state && !['running', 'unknown'].includes(server.container_state)) return false; const players = observe(server).last_players ?? server.last_players; - // One final read after players leave retains messages written at match end. - return players !== 0 || readers.get(server.name)?.wasOccupied; + const key = chatReadKey(server); + if (players !== 0) { + idleStalls.delete(key); + return true; + } + const stalls = idleStalls.get(key) ?? 0; + if (stalls >= MAX_IDLE_STALLED_READS) return false; + const last = lastReads.get(key); + if (stalls > 0 && last && Date.now() - last.completedAt < CHAT_READ_MIN_AGE_MS) + return false; + const state = readers.get(server.name); + // Pick up surviving final logs after a collector restart, then finish any + // unread chunks even after players leave. Errors and incomplete final lines + // have bounded idle retries rather than polling an empty server forever. + return ( + !last || + 'error' in last || + (state?.key === key && (state.wasOccupied || state.needsDrain)) + ); }); // Bound agent traffic and isolate failures to their own server. async function collect() { for (let server = servers.shift(); server; server = servers.shift()) { + const key = chatReadKey(server); + const before = readers.get(server.name); try { await collectServerChat(server); const state = readers.get(server.name); - if (state) + if (state?.key === key) { state.wasOccupied = (observe(server).last_players ?? server.last_players ?? 0) > 0; + if (!state.wasOccupied && state.needsDrain) { + const progressed = + before?.key !== key || state.bytesRead > before.bytesRead; + idleStalls.set(key, progressed ? 0 : (idleStalls.get(key) ?? 0) + 1); + } else idleStalls.delete(key); + } failures.delete(server.name); } catch (error) { + if ((observe(server).last_players ?? server.last_players) === 0) + idleStalls.set(key, (idleStalls.get(key) ?? 0) + 1); if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) { console.error(`[chat] Could not collect chat for ${server.name}`, error); failures.set(server.name, Date.now()); diff --git a/src/lib/server/fleet/match-history-access.ts b/src/lib/server/fleet/match-history-access.ts new file mode 100644 index 0000000..62bed70 --- /dev/null +++ b/src/lib/server/fleet/match-history-access.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import type { AuthUser } from '../../auth.js'; +import { AuthError } from '../auth/index.js'; +import { moderationActor } from '../moderation/core.js'; + +export type MatchArchiveLocals = { user: AuthUser | null }; + +/** Recheck current account privileges before returning the expanded moderation archive. */ +export function requireMatchArchiveAccess(user: Pick | null | undefined): void { + try { + moderationActor(user?.id ?? ''); + } catch (cause) { + if (cause instanceof AuthError) error(cause.status, cause.message); + throw cause; + } +} diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index 61eb4cf..98ae081 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -32,7 +32,7 @@ export function serverMatchHistory( now = Date.now() ): MatchHistoryResponse { const handle = db(); - const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`]; + const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`]; const parameters: Array = []; if (server !== undefined) { clauses.push('m.server = ?'); @@ -93,14 +93,18 @@ export function serverMatchHistory( } /** Read durable match records without depending on a current server or scoreboard. */ -export function getMatchDetail(id: number): MatchDetail | null { +export function getMatchDetail(id: number, archive = false): MatchDetail | null { if (!Number.isSafeInteger(id) || id < 1) return null; const handle = db(); const match = handle .prepare( `SELECT ${matchSummary}, m.start_observed AS startObserved - FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed' + FROM leaderboard_matches m WHERE m.id = ?${ + archive + ? '' + : ` AND m.status = 'completed' AND m.highest_round >= ${MIN_RECORDED_ROUND}` + }` ) .get(id) as | (Omit & { startObserved: number }) @@ -116,7 +120,7 @@ export function getMatchDetail(id: number): MatchDetail | null { (SELECT identity.name FROM leaderboard_players identity WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name, p.has_stats AS hasStats, - CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd, + CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd, p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt, CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound, CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived, @@ -131,7 +135,7 @@ export function getMatchDetail(id: number): MatchDetail | null { FROM leaderboard_match_players p WHERE p.match_id = ? ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid` ) - .all(match.game, match.reporterMatchKey, id) as Array< + .all(match.game, match.status, match.reporterMatchKey, id) as Array< Omit & { hasStats: number; presentAtEnd: number; diff --git a/src/lib/server/moderation/evidence.ts b/src/lib/server/moderation/evidence.ts index a50104d..6bb63b6 100644 --- a/src/lib/server/moderation/evidence.ts +++ b/src/lib/server/moderation/evidence.ts @@ -81,6 +81,7 @@ export function archivedEvidenceSnapshots( entry.message.guid === old.guid && entry.message.time === old.time && entry.message.player === old.player && + entry.message.channel === old.channel && entry.message.message === old.message ); if (original) attached.add(original.messageId!); diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index 671e914..5a7a2ba 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -9,6 +9,7 @@ import { pollVisible } from '$ui/poll.js'; import { timeFormat } from '$ui/stores/time-zone.js'; import Button from './primitives/Button.svelte'; + import Badge from './primitives/Badge.svelte'; import Freshness from './Freshness.svelte'; import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte'; @@ -337,7 +338,10 @@ Select evidence {/if} - {item.entry.message.player}{item.entry.message + .player}{#if item.entry.message.channel === 'team'} + Team chat{/if}{@render chatContext(item.entry.context)} {#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')} { const params = matchApiParams($page.url); params.set('limit', String(matchPageSize)); @@ -131,6 +133,19 @@
+ {#if canViewArchive}
+ + {#if filters.archive}

+ Includes round-one, interrupted, and unfinished matches for evidence review. +

{/if} +
{/if} {#snippet filterFields(suffix: string)}
{#if server === undefined} @@ -357,9 +372,12 @@ - {#if match.status === 'completed'}{match.map} · Match #{match.id} @@ -379,7 +397,8 @@ ? ` · ${duration}` : ''} - {#if notReporting}Last report {status}{#if notReporting}{#if notReporting || (filters.archive && match.status !== 'completed')}Last report About match history

Players counts unique players across all reports. A dash means the value is unavailable, - not zero. In-progress matches show elapsed time since their recorded start and link to - the live server while reports are current. Otherwise, they show Not reporting with the - last report time. Completed durations require a recorded start and completed ending. End - report missing means a newer game was reported before this game's end report arrived. - Missing report counts show gaps in the reporter's sequence and can decrease as delayed - reports arrive. Only completed games contribute to the leaderboard. Round shows the - final round for completed matches and the highest observed round for unfinished matches. - Open a completed match for exact timestamps and starting and ending player counts. + not zero. In-progress matches show elapsed time since their recorded start. {#if filters.archive}Archive + links open retained match reports and chat.{:else}They link to the live server while + reports are current.{/if} Otherwise, they show Not reporting with the last report time. + Completed durations require a recorded start and completed ending. End report missing means + a newer game was reported before this game's end report arrived. Missing report counts show + gaps in the reporter's sequence and can decrease as delayed reports arrive. Only completed + games contribute to the leaderboard. Round shows the final round for completed matches and + the highest observed round for unfinished matches. Open a {filters.archive + ? 'recorded' + : 'completed'} match for timestamps and participant details.

@@ -487,6 +508,26 @@ gap: var(--space-4); min-width: 0; } + .archive-control { + display: grid; + gap: var(--space-2); + font-size: var(--text-sm); + } + .archive-control label { + display: flex; + align-items: center; + gap: var(--space-2); + width: fit-content; + cursor: pointer; + } + .archive-control input { + accent-color: var(--accent-primary); + } + .archive-control p { + margin: 0; + color: var(--text-secondary); + line-height: var(--leading-body); + } .filters { display: flex; align-items: flex-start; diff --git a/src/lib/ui/components/moderation/EvidenceMessages.svelte b/src/lib/ui/components/moderation/EvidenceMessages.svelte index 523d42b..cfbd8cc 100644 --- a/src/lib/ui/components/moderation/EvidenceMessages.svelte +++ b/src/lib/ui/components/moderation/EvidenceMessages.svelte @@ -27,6 +27,7 @@
  • {entry.message.player} + {#if entry.message.channel === 'team'}Team chat{/if} {#if highlighted}Subject message {:else if selected}Selected context {:else}Surrounding context{/if} diff --git a/src/routes/api/matches/+server.ts b/src/routes/api/matches/+server.ts index db38a27..64024a7 100644 --- a/src/routes/api/matches/+server.ts +++ b/src/routes/api/matches/+server.ts @@ -1,8 +1,13 @@ import { readMatchQuery } from '$lib/match-history-query.js'; import { json } from '@sveltejs/kit'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; +import { + requireMatchArchiveAccess, + type MatchArchiveLocals +} from '$lib/server/fleet/match-history-access.js'; +import { authError } from '$lib/server/auth/http.js'; -export function GET({ url }: { url: URL }) { +export function GET({ url, locals }: { url: URL; locals?: MatchArchiveLocals }) { const limit = Number(url.searchParams.get('limit') ?? 25); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) { return json({ error: 'limit must be between 1 and 10000' }, { status: 400 }); @@ -13,5 +18,13 @@ export function GET({ url }: { url: URL }) { } catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); } - return json(serverMatchHistory(undefined, limit, query)); + try { + if (query.archive) requireMatchArchiveAccess(locals?.user); + return json( + serverMatchHistory(undefined, limit, query), + query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined + ); + } catch (cause) { + return authError(cause); + } } diff --git a/src/routes/api/servers/[name]/matches/+server.ts b/src/routes/api/servers/[name]/matches/+server.ts index 37ccf9f..7cc621d 100644 --- a/src/routes/api/servers/[name]/matches/+server.ts +++ b/src/routes/api/servers/[name]/matches/+server.ts @@ -1,8 +1,21 @@ import { readMatchQuery } from '$lib/match-history-query.js'; import { json } from '@sveltejs/kit'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; +import { + requireMatchArchiveAccess, + type MatchArchiveLocals +} from '$lib/server/fleet/match-history-access.js'; +import { authError } from '$lib/server/auth/http.js'; -export function GET({ params, url }: { params: { name: string }; url: URL }) { +export function GET({ + params, + url, + locals +}: { + params: { name: string }; + url: URL; + locals?: MatchArchiveLocals; +}) { const limit = Number(url.searchParams.get('limit') ?? 25); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) { return json({ error: 'limit must be between 1 and 10000' }, { status: 400 }); @@ -13,5 +26,13 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) { } catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); } - return json(serverMatchHistory(params.name, limit, query)); + try { + if (query.archive) requireMatchArchiveAccess(locals?.user); + return json( + serverMatchHistory(params.name, limit, query), + query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined + ); + } catch (cause) { + return authError(cause); + } } diff --git a/src/routes/matches/+page.server.ts b/src/routes/matches/+page.server.ts index 20b3d62..80ee8e0 100644 --- a/src/routes/matches/+page.server.ts +++ b/src/routes/matches/+page.server.ts @@ -1,12 +1,28 @@ import { error } from '@sveltejs/kit'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js'; -export function load({ url }: { url: URL }) { +import { + requireMatchArchiveAccess, + type MatchArchiveLocals +} from '$lib/server/fleet/match-history-access.js'; +export function load({ + url, + locals, + setHeaders +}: { + url: URL; + locals?: MatchArchiveLocals; + setHeaders?: (headers: Record) => void; +}) { let query; try { query = readMatchQuery(matchApiParams(url)); } catch { error(400, 'Invalid match history filters.'); } + if (query.archive) { + setHeaders?.({ 'Cache-Control': 'no-store' }); + requireMatchArchiveAccess(locals?.user); + } return { history: serverMatchHistory(undefined, matchPageSize, query) }; } diff --git a/src/routes/matches/+page.svelte b/src/routes/matches/+page.svelte index c2c6f9d..8de905b 100644 --- a/src/routes/matches/+page.svelte +++ b/src/routes/matches/+page.svelte @@ -2,12 +2,22 @@ let { data }: { data: import('./$types').PageData } = $props(); import MatchHistory from '$ui/components/MatchHistory.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; + import { page } from '$app/stores'; + const archive = $derived($page.url.searchParams.get('matchArchive') === '1'); -Matches · Electric Cherry +{archive ? 'Moderation match archive' : 'Matches'} · Electric Cherry
    - +
    diff --git a/src/routes/matches/[id]/+page.server.ts b/src/routes/matches/[id]/+page.server.ts index 7143a8b..875fc4c 100644 --- a/src/routes/matches/[id]/+page.server.ts +++ b/src/routes/matches/[id]/+page.server.ts @@ -1,9 +1,34 @@ import { error } from '@sveltejs/kit'; import { getMatchDetail } from '$lib/server/fleet/match-history.js'; +import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js'; +import { + requireMatchArchiveAccess, + type MatchArchiveLocals +} from '$lib/server/fleet/match-history-access.js'; -export function load({ params }: { params: { id: string } }) { +export function load({ + params, + url, + locals, + setHeaders +}: { + params: { id: string }; + url?: URL; + locals?: MatchArchiveLocals; + setHeaders?: (headers: Record) => void; +}) { + let query; + try { + query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams()); + } catch { + error(400, 'Invalid match history filters.'); + } + if (query.archive) { + setHeaders?.({ 'Cache-Control': 'no-store' }); + requireMatchArchiveAccess(locals?.user); + } if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found'); - const match = getMatchDetail(Number(params.id)); + const match = getMatchDetail(Number(params.id), query.archive); if (!match) error(404, 'Match not found'); return { match }; } diff --git a/src/routes/matches/[id]/+page.svelte b/src/routes/matches/[id]/+page.svelte index b6894fa..04749bc 100644 --- a/src/routes/matches/[id]/+page.svelte +++ b/src/routes/matches/[id]/+page.svelte @@ -1,6 +1,10 @@