imprvd
This commit is contained in:
+226
@@ -0,0 +1,226 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import * as crypto from 'crypto';
|
||||
import { Settings } from './types';
|
||||
import { createSupabase, supabaseConfigured } from './auth_bridge';
|
||||
|
||||
function coerceId(id: unknown): number | null {
|
||||
if (id == null) return null;
|
||||
if (typeof id === 'bigint') return Number(id);
|
||||
if (typeof id === 'number' && Number.isFinite(id)) return id;
|
||||
if (typeof id === 'string') {
|
||||
const n = parseInt(id, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Insert a match row when a new room is created. First player in the room = red. */
|
||||
export async function insertMatchForNewRoom(settings: Settings, userRedId: number): Promise<number | null> {
|
||||
if (!supabaseConfigured(settings)) return null;
|
||||
const supabase = createSupabase(settings);
|
||||
const { data, error } = await supabase.from('matches').insert({ user_red: userRedId }).select('id').single();
|
||||
if (error || !data) {
|
||||
console.error('matches: insert failed', error?.message);
|
||||
return null;
|
||||
}
|
||||
return coerceId(data.id);
|
||||
}
|
||||
|
||||
/** Latest 10 matches for this user (by created_at); counts wins/losses from winner_id only. */
|
||||
export async function getPlayerLast10MatchRecord(
|
||||
settings: Settings,
|
||||
playerId: number
|
||||
): Promise<{ l10_wins: number; l10_losses: number }> {
|
||||
if (!supabaseConfigured(settings)) {
|
||||
return { l10_wins: 0, l10_losses: 0 };
|
||||
}
|
||||
const supabase = createSupabase(settings);
|
||||
const { data, error } = await supabase
|
||||
.from('matches')
|
||||
.select('winner_id')
|
||||
.or(`user_red.eq.${playerId},user_blue.eq.${playerId}`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error || !data) {
|
||||
console.error('matches: last10 fetch failed', error?.message);
|
||||
return { l10_wins: 0, l10_losses: 0 };
|
||||
}
|
||||
|
||||
let l10_wins = 0;
|
||||
let l10_losses = 0;
|
||||
for (const row of data as { winner_id: unknown }[]) {
|
||||
const wid = coerceId(row.winner_id);
|
||||
if (wid == null) continue;
|
||||
if (wid === playerId) l10_wins++;
|
||||
else l10_losses++;
|
||||
}
|
||||
return { l10_wins, l10_losses };
|
||||
}
|
||||
|
||||
/** Second player joining the room = blue. */
|
||||
export async function updateMatchUserBlue(settings: Settings, matchId: number, userBlueId: number): Promise<void> {
|
||||
if (!supabaseConfigured(settings)) return;
|
||||
const supabase = createSupabase(settings);
|
||||
const { error } = await supabase.from('matches').update({ user_blue: userBlueId }).eq('id', matchId);
|
||||
if (error) {
|
||||
console.error('matches: update user_blue failed', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function dedicatedServerConfigured(settings: Settings): boolean {
|
||||
return Boolean(settings.dedicated_server_secret?.trim());
|
||||
}
|
||||
|
||||
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||
try {
|
||||
const ba = Buffer.from(a, 'utf8');
|
||||
const bb = Buffer.from(b, 'utf8');
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** If true, response already sent — handler must return. */
|
||||
function rejectUnlessDedicatedServer(req: Request, res: Response, settings: Settings): boolean {
|
||||
if (!supabaseConfigured(settings)) {
|
||||
res.status(503).json({ ok: false, error: 'Database not configured' });
|
||||
return true;
|
||||
}
|
||||
if (!dedicatedServerConfigured(settings)) {
|
||||
res.status(503).json({ ok: false, error: 'dedicated_server_secret not set in settings' });
|
||||
return true;
|
||||
}
|
||||
const hdr = req.headers['x-dedicated-server-secret'];
|
||||
const expected = settings.dedicated_server_secret!.trim();
|
||||
if (typeof hdr !== 'string' || !timingSafeEqualStr(hdr, expected)) {
|
||||
res.status(403).json({ ok: false, error: 'Forbidden' });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerMatchRoutes(app: Express, settings: Settings): void {
|
||||
const jsonParser = express.json();
|
||||
|
||||
app.patch('/internal/match/:matchId', jsonParser, async (req: Request, res: Response) => {
|
||||
if (rejectUnlessDedicatedServer(req, res, settings)) return;
|
||||
|
||||
const matchId = coerceId(req.params.matchId);
|
||||
if (matchId == null || matchId < 1) {
|
||||
res.status(400).json({ ok: false, error: 'Invalid match id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const patch: Record<string, string | number | null> = {};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'red_joined_at')) {
|
||||
const v = body.red_joined_at;
|
||||
if (v === null) patch.red_joined_at = null;
|
||||
else if (typeof v === 'string') patch.red_joined_at = v;
|
||||
else {
|
||||
res.status(400).json({ ok: false, error: 'red_joined_at must be ISO string or null' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'blue_joined_at')) {
|
||||
const v = body.blue_joined_at;
|
||||
if (v === null) patch.blue_joined_at = null;
|
||||
else if (typeof v === 'string') patch.blue_joined_at = v;
|
||||
else {
|
||||
res.status(400).json({ ok: false, error: 'blue_joined_at must be ISO string or null' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'status')) {
|
||||
const v = body.status;
|
||||
if (v === null) patch.status = null;
|
||||
else if (typeof v === 'number' && Number.isInteger(v)) patch.status = v;
|
||||
else if (typeof v === 'string' && /^-?\d+$/.test(v)) patch.status = parseInt(v, 10);
|
||||
else {
|
||||
res.status(400).json({ ok: false, error: 'status must be integer or null' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
res.status(400).json({ ok: false, error: 'Body must include at least one of red_joined_at, blue_joined_at, status' });
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createSupabase(settings);
|
||||
const { data, error } = await supabase.from('matches').update(patch).eq('id', matchId).select('id').maybeSingle();
|
||||
|
||||
if (error) {
|
||||
res.status(500).json({ ok: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
res.status(404).json({ ok: false, error: 'Match not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, id: matchId });
|
||||
});
|
||||
|
||||
/** Unity dedicated server: set `winner_id` from match sides (`user_red` / `user_blue`). */
|
||||
app.patch('/internal/match/:matchId/winner', jsonParser, async (req: Request, res: Response) => {
|
||||
if (rejectUnlessDedicatedServer(req, res, settings)) return;
|
||||
|
||||
const matchId = coerceId(req.params.matchId);
|
||||
if (matchId == null || matchId < 1) {
|
||||
res.status(400).json({ ok: false, error: 'Invalid match id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const side = body.winner;
|
||||
if (side !== 'red' && side !== 'blue') {
|
||||
res.status(400).json({ ok: false, error: 'Body must include winner: "red" | "blue"' });
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createSupabase(settings);
|
||||
const { data: row, error: fetchErr } = await supabase
|
||||
.from('matches')
|
||||
.select('user_red, user_blue')
|
||||
.eq('id', matchId)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchErr) {
|
||||
res.status(500).json({ ok: false, error: fetchErr.message });
|
||||
return;
|
||||
}
|
||||
if (!row) {
|
||||
res.status(404).json({ ok: false, error: 'Match not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userRed = coerceId((row as { user_red: unknown }).user_red);
|
||||
const userBlue = coerceId((row as { user_blue: unknown }).user_blue);
|
||||
const winnerId = side === 'red' ? userRed : userBlue;
|
||||
if (winnerId == null) {
|
||||
res.status(400).json({ ok: false, error: `${side} slot has no user on this match` });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: updated, error: updErr } = await supabase
|
||||
.from('matches')
|
||||
.update({ winner_id: winnerId })
|
||||
.eq('id', matchId)
|
||||
.select('id')
|
||||
.maybeSingle();
|
||||
|
||||
if (updErr) {
|
||||
res.status(500).json({ ok: false, error: updErr.message });
|
||||
return;
|
||||
}
|
||||
if (!updated) {
|
||||
res.status(404).json({ ok: false, error: 'Match not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, id: matchId, winner_id: winnerId });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user