From 9c314ecb4e102ade5d5685af7985f189dc963212 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Tue, 27 Oct 2026 12:54:15 +0500 Subject: [PATCH] Add tournament and multiplayer community chat moderation --- app/layout.tsx | 4 + app/tournaments.css | 31 +++ components/BackNavigationTransition.tsx | 46 ++++ components/MultiplayerRoom.tsx | 33 ++- components/RoomChat.tsx | 16 +- components/TournamentLobby.tsx | 17 ++ components/TournamentModeration.tsx | 249 ++++++++++++++++++ lib/multiplayer-api.ts | 58 ++++ lib/tournament-api.ts | 75 +++++- scripts/test-multiplayer-sql.mjs | 123 +++++++++ scripts/test-tournaments-sql.mjs | 57 ++++ shared/database.ts | 27 ++ shared/locales/ru.json | 18 +- shared/multiplayer.ts | 1 + ...27060825_tournament_community_controls.sql | 126 +++++++++ ...0261027061048_party_community_controls.sql | 106 ++++++++ ...027062211_community_moderation_indexes.sql | 7 + ...27075127_party_chat_slow_mode_identity.sql | 55 ++++ tests/tournaments.test.ts | 37 +++ 19 files changed, 1070 insertions(+), 16 deletions(-) create mode 100644 components/BackNavigationTransition.tsx create mode 100644 components/TournamentModeration.tsx create mode 100644 supabase/migrations/20261027060825_tournament_community_controls.sql create mode 100644 supabase/migrations/20261027061048_party_community_controls.sql create mode 100644 supabase/migrations/20261027062211_community_moderation_indexes.sql create mode 100644 supabase/migrations/20261027075127_party_chat_slow_mode_identity.sql diff --git a/app/layout.tsx b/app/layout.tsx index c57bc7b..b859fc6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -27,6 +27,7 @@ import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { initialSiteSettings } from "@/lib/site-settings"; import { rawUser, accountState } from "@/lib/supabase"; import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice"; +import { BackNavigationTransition } from "@/components/BackNavigationTransition"; import { Suspense } from "react"; export async function generateMetadata(): Promise { const { t } = await getI18n(); @@ -120,6 +121,9 @@ export default async function RootLayout({ detectedLocale={detectedLocale} > + + + {children} diff --git a/app/tournaments.css b/app/tournaments.css index 8234ae3..f08f4ad 100644 --- a/app/tournaments.css +++ b/app/tournaments.css @@ -103,6 +103,37 @@ .tournament-form .tournament-checkbox { grid-column: 1/-1; } +.community-moderation > ul, +.community-moderation > h3 { + grid-column: 1 / -1; +} +.community-moderation .multiplayer-roster li { + flex-wrap: wrap; + gap: 12px; +} +.community-moderation form, +.community-moderation .inline-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: end; +} +.community-moderation-floating { + position: fixed; + z-index: 71; + right: max(20px, env(safe-area-inset-right)); + top: max(70px, env(safe-area-inset-top)); +} +.community-moderation-floating > summary { + cursor: pointer; +} +.community-moderation-floating .community-moderation { + width: min(640px, calc(100vw - 40px)); + max-height: min(70vh, 700px); + overflow: auto; + background: var(--bg); + box-shadow: 0 8px 32px #0008; +} .tournament-form .tournament-checkbox { display: flex; align-items: center; diff --git a/components/BackNavigationTransition.tsx b/components/BackNavigationTransition.tsx new file mode 100644 index 0000000..83eedc6 --- /dev/null +++ b/components/BackNavigationTransition.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +/** Animate the new content after browser history navigation, without delaying it. */ +export function BackNavigationTransition() { + const pathname = usePathname(); + const search = useSearchParams(); + const route = `${pathname}?${search.toString()}`; + const historyNavigation = useRef(false); + const previousPath = useRef(route); + + useEffect(() => { + const onPopState = () => { + historyNavigation.current = + `${window.location.pathname}?${new URLSearchParams(window.location.search).toString()}` !== + previousPath.current; + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + useEffect(() => { + if (previousPath.current === route) return; + previousPath.current = route; + const fromHistory = historyNavigation.current; + historyNavigation.current = false; + if ( + !fromHistory || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) + return; + const main = document.getElementById("main-content"); + if (!main || typeof main.animate !== "function") return; + const animation = main.animate( + [ + { opacity: 0.7, transform: "translateY(8px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: 200, easing: "cubic-bezier(0.23, 1, 0.32, 1)" }, + ); + return () => animation.cancel(); + }, [route]); + return null; +} diff --git a/components/MultiplayerRoom.tsx b/components/MultiplayerRoom.tsx index 3cdc1d3..70407ca 100644 --- a/components/MultiplayerRoom.tsx +++ b/components/MultiplayerRoom.tsx @@ -7,6 +7,7 @@ import { Copy, Users, Crown, Trophy, MessageCircle } from "lucide-react"; import { Brand } from "./Brand"; import { MultiplayerGame } from "./MultiplayerGame"; import { RoomChat } from "./RoomChat"; +import { TournamentModeration } from "./TournamentModeration"; import { useI18n } from "./I18nProvider"; import { api } from "@/lib/client-api"; import { roundClock } from "@/shared/round-clock"; @@ -154,6 +155,16 @@ export function MultiplayerRoom({ id }: { id: string }) { available={room.status !== "closed"} playing /> + !p.left) + .map((p) => ({ accountId: p.id, name: p.name }))} + ownerId={room.hostId} + you={room.you} + /> ); const owner = !!room?.you && room.you === room.hostId; @@ -167,7 +178,11 @@ export function MultiplayerRoom({ id }: { id: string }) { {t("Play & maps")} -
+
{error && (

{t(error)}{" "} @@ -184,16 +199,15 @@ export function MultiplayerRoom({ id }: { id: string }) { {t("Multiplayer")} · {room.name}

- {t("Difficulty")}: + {t("Difficulty")}:{" "} + {room.difficultySettings.id in difficulties ? t(room.difficultySettings.name) : room.difficultySettings.name} {" · "} {room.difficultySettings.seconds} {t("s per round")} ·{" "} - {room.difficultySettings.zoom - ? t("Zoom allowed") - : t("No zoom")} + {room.difficultySettings.zoom ? t("Zoom allowed") : t("No zoom")}

{finished @@ -248,6 +262,15 @@ export function MultiplayerRoom({ id }: { id: string }) { )} {room.you && room.status === "lobby" && ( <> + !p.left) + .map((p) => ({ accountId: p.id, name: p.name }))} + ownerId={room.hostId} + you={room.you} + />

{owner ? t( diff --git a/components/RoomChat.tsx b/components/RoomChat.tsx index 4753d96..c9b5d2c 100644 --- a/components/RoomChat.tsx +++ b/components/RoomChat.tsx @@ -15,11 +15,13 @@ export function RoomChat({ playerId, available, playing, + endpoint, }: { id: string; playerId: string | null; available: boolean; playing: boolean; + endpoint?: string; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); @@ -38,12 +40,7 @@ export function RoomChat({ const followLatest = useRef(true); const enabled = !!playerId && available; const accept = useCallback((view: ChatView) => { - setMessages((previous) => - previous.length === view.messages.length && - previous.at(-1)?.id === view.messages.at(-1)?.id - ? previous - : view.messages, - ); + setMessages(view.messages); if (!initialized.current) { initialized.current = true; setLastRead(BigInt(view.messages.at(-1)?.id ?? "0")); @@ -54,12 +51,12 @@ export function RoomChat({ if (inFlight.current) return; const current = ++version.current; try { - const view = await api(`rooms/${id}/chat`); + const view = await api(endpoint ?? `rooms/${id}/chat`); if (current === version.current) accept(view); } catch (failure) { if (current === version.current) setError((failure as Error).message); } - }, [id, accept]); + }, [id, endpoint, accept]); useEffect(() => { if (!enabled) return; let stopped = false; @@ -111,7 +108,7 @@ export function RoomChat({ setError(""); const current = ++version.current; try { - const view = await api(`rooms/${id}/chat`, body); + const view = await api(endpoint ?? `rooms/${id}/chat`, body); if (current === version.current) { accept(view); followLatest.current = true; @@ -199,6 +196,7 @@ export function RoomChat({ > + {message.tag && · {message.tag}} {message.playerId === playerId && ` ${t("(you)")}`} {message.text} diff --git a/components/TournamentLobby.tsx b/components/TournamentLobby.tsx index 79b3347..3d862ae 100644 --- a/components/TournamentLobby.tsx +++ b/components/TournamentLobby.tsx @@ -12,6 +12,8 @@ import { useRouter } from "next/navigation"; import { Trophy, Users, CalendarClock, Plus, Copy } from "lucide-react"; import { api } from "@/lib/client-api"; import { useI18n } from "./I18nProvider"; +import { RoomChat } from "./RoomChat"; +import { TournamentModeration } from "./TournamentModeration"; import dynamic from "next/dynamic"; const MultiplayerGame = dynamic( () => import("./MultiplayerGame").then((module) => module.MultiplayerGame), @@ -576,6 +578,14 @@ export function TournamentLobby({ )} + {joined && ( + + )} {owner && event.announceDiscord && (

{t("Discord announcement")}:{" "} @@ -754,6 +764,13 @@ export function TournamentLobby({ + ); } diff --git a/components/TournamentModeration.tsx b/components/TournamentModeration.tsx new file mode 100644 index 0000000..ed2b549 --- /dev/null +++ b/components/TournamentModeration.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/client-api"; +import { useI18n } from "./I18nProvider"; + +type Community = { + owner: boolean; + moderator: boolean; + slowSeconds: number; + moderators: string[]; + tags: Record; + bans: { accountId?: string; id?: string; name: string }[]; +}; + +export function TournamentModeration({ + id, + players, + ownerId, + you, + party = false, + playing = false, +}: { + id: string; + players: { accountId: string; name: string }[]; + ownerId: string | null; + you: string | null; + party?: boolean; + playing?: boolean; +}) { + const { t } = useI18n(); + const [community, setCommunity] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [tags, setTags] = useState>({}); + const endpoint = `${party ? "rooms" : "tournaments"}/${id}/community`; + const load = useCallback(async () => { + try { + setCommunity(await api(endpoint)); + } catch (failure) { + setCommunity(null); + setError((failure as Error).message); + } + }, [endpoint]); + useEffect(() => { + void load(); + const timer = window.setInterval(() => { + if (!document.hidden) void load(); + }, 5000); + return () => window.clearInterval(timer); + }, [load]); + async function act(action: string, fields: object = {}) { + if (busy) return; + setBusy(true); + setError(""); + try { + await api(endpoint, { action, ...fields }); + await load(); + } catch (failure) { + setError((failure as Error).message); + } finally { + setBusy(false); + } + } + if (!community) return null; + if (!community.moderator) { + const tagged = players.filter((player) => community.tags[player.accountId]); + return tagged.length ? ( +

+ {tagged + .map( + (player) => `${player.name} · ${community.tags[player.accountId]}`, + ) + .join(" | ")} +

+ ) : null; + } + const panel = ( +
+

{t(party ? "Party moderation" : "Tournament moderation")}

+ {error && ( +

+ {t(error)} +

+ )} +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + void act("slow", { seconds: Number(data.get("seconds")) }); + }} + > + + +
+
    + {players + .filter( + (player) => + player.accountId !== ownerId && + player.accountId !== you && + (community.owner || + !community.moderators.includes(player.accountId)), + ) + .map((player) => ( +
  • + + {player.name} + {community.moderators.includes(player.accountId) && + ` · ${t("Moderator")}`} + {community.tags[player.accountId] && + ` · ${community.tags[player.accountId]}`} + +
    + {community.owner && ( + + )} + + +
    { + event.preventDefault(); + void act("tag", { + [party ? "playerId" : "accountId"]: player.accountId, + tag: + tags[player.accountId] ?? + community.tags[player.accountId] ?? + "", + }); + }} + > + + +
    +
    +
  • + ))} +
+ {community.bans.length > 0 && ( + <> +

{t("Banned players")}

+
    + {community.bans.map((player) => ( +
  • + {player.name}{" "} + +
  • + ))} +
+ + )} +
+ ); + if (!playing) return panel; + return ( +
+ {t("Party moderation")} + {panel} +
+ ); +} diff --git a/lib/multiplayer-api.ts b/lib/multiplayer-api.ts index 71294ec..f6cadd1 100644 --- a/lib/multiplayer-api.ts +++ b/lib/multiplayer-api.ts @@ -93,6 +93,64 @@ export async function multiplayerApi( 409, ); const action = path[2] ?? "view"; + if ( + action === "community" && + path.length === 3 && + ["GET", "POST"].includes(request.method) + ) { + if (!savedSecret) + throw new ServiceError("Room membership permission required", 403); + const input = + request.method === "POST" ? await jsonBody(request) : { action: "view" }; + const schema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("view") }).strict(), + z + .object({ + action: z.literal("slow"), + seconds: z.number().int().min(0).max(300), + }) + .strict(), + ...(["admin", "unadmin", "kick", "ban"] as const).map((action) => + z + .object({ action: z.literal(action), playerId: z.string().uuid() }) + .strict(), + ), + z + .object({ action: z.literal("unban"), banId: z.string().uuid() }) + .strict(), + z + .object({ + action: z.literal("tag"), + playerId: z.string().uuid(), + tag: z + .string() + .trim() + .max(32) + .refine( + (value) => + !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test( + value, + ), + ), + }) + .strict(), + ]); + const body = schema.parse(input); + if (request.method === "POST") { + if (body.action === "view") throw new ServiceError("Not found", 404); + await limit(`multiplayer:moderation:${accessHash}`, 30, 60); + } + return respond( + required( + await client.rpc("party_community", { + p_room: id, + p_access_hash: accessHash, + p_action: body.action, + p_payload: body, + }), + ), + ); + } if (action === "discord" && path.length === 3 && request.method === "POST") { const view = required( await client.rpc("multiplayer_action", { diff --git a/lib/tournament-api.ts b/lib/tournament-api.ts index 53ea87f..db812c7 100644 --- a/lib/tournament-api.ts +++ b/lib/tournament-api.ts @@ -13,7 +13,11 @@ import { panoramaPath, viewpointSchema, } from "@/shared/protocol"; -import { roomGuessSchema, roomRoundSchema } from "@/shared/multiplayer"; +import { + chatMessageSchema, + roomGuessSchema, + roomRoundSchema, +} from "@/shared/multiplayer"; import { ServiceError, checkServiceError } from "./service-error"; type Dependencies = { @@ -82,6 +86,75 @@ export async function tournamentApi( throw new ServiceError("Not found", 404); const id = z.string().uuid().parse(path[1]); const action = path[2] ?? "view"; + if (["community", "chat"].includes(action)) { + if (!["GET", "POST"].includes(request.method)) + throw new ServiceError("Not found", 404); + const account = await deps.account(); + if (!account) + throw new ServiceError("Sign in to join or play a tournament", 401); + if (action === "chat") { + const body = + request.method === "POST" + ? chatMessageSchema.strict().parse(await deps.jsonBody(request)) + : null; + if (body) await deps.limit(`tournaments:chat:${account.id}`, 60, 60); + return respond( + unwrap( + await db.rpc("tournament_chat", { + p_tournament: id, + p_account: account.id, + p_text: body?.text ?? null, + p_client_id: body?.clientId ?? null, + }), + ), + ); + } + const schema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("slow"), + seconds: z.number().int().min(0).max(300), + }) + .strict(), + ...(["admin", "unadmin", "kick", "ban", "unban"] as const).map((action) => + z + .object({ action: z.literal(action), accountId: z.string().uuid() }) + .strict(), + ), + z + .object({ + action: z.literal("tag"), + accountId: z.string().uuid(), + tag: z + .string() + .trim() + .max(32) + .refine( + (value) => + !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test( + value, + ), + ), + }) + .strict(), + ]); + const body = + request.method === "POST" + ? schema.parse(await deps.jsonBody(request)) + : { action: "view" as const }; + if (request.method === "POST") + await deps.limit(`tournaments:moderation:${account.id}`, 30, 60); + return respond( + unwrap( + await db.rpc("tournament_community", { + p_tournament: id, + p_account: account.id, + p_action: body.action, + p_payload: body, + }), + ), + ); + } const read = request.method === "GET" && (action === "view" || action === "media"); const write = diff --git a/scripts/test-multiplayer-sql.mjs b/scripts/test-multiplayer-sql.mjs index 91dfa96..6656394 100644 --- a/scripts/test-multiplayer-sql.mjs +++ b/scripts/test-multiplayer-sql.mjs @@ -88,6 +88,129 @@ try { [dataset, secret(1), account], ) ).rows[0].id; + { + const moderationRoom = await accountRoom(); + await db.query("select public.join_multiplayer_room($1,$2,$3,$4)", [ + moderationRoom, + secret(2), + secondAccount, + "Ignored", + ]); + await db.query("select public.join_multiplayer_room($1,$2,null,$3)", [ + moderationRoom, + secret(3), + "Guest", + ]); + const roster = (await act(moderationRoom, 1)).players; + const friend = roster.find((p) => p.name === "Account friend"); + let guest = roster.find((p) => p.name === "Guest"); + const community = async (who, action, payload = {}) => + ( + await db.query("select public.party_community($1,$2,$3,$4) as value", [ + moderationRoom, + secret(who), + action, + JSON.stringify(payload), + ]) + ).rows[0].value; + const chat = (who, text, clientId) => + db.query("select public.multiplayer_chat($1,$2,$3,$4)", [ + moderationRoom, + secret(who), + text, + clientId, + ]); + await assert.rejects( + community(2, "ban", { playerId: guest.id }), + /moderator permission/, + ); + await community(1, "admin", { playerId: friend.id }); + await assert.rejects( + community(2, "ban", { playerId: roster[0].id }), + /owner or yourself/, + ); + await community(2, "tag", { playerId: guest.id, tag: "Explorer" }); + await community(2, "slow", { seconds: 10 }); + const chatId = randomUUID(); + await chat(3, "Hello", chatId); + await chat(3, "Hello", chatId); + await assert.rejects(chat(3, "Too fast", randomUUID()), /slow mode/); + await db.query( + "update public.party_chat_cooldowns set last_message_at=clock_timestamp()-interval '11 seconds' where room_id=$1 and access_hash=$2", + [moderationRoom, secret(3)], + ); + await db.query( + "update public.multiplayer_messages set created_at=clock_timestamp()-interval '11 seconds' where room_id=$1 and player_id=$2", + [moderationRoom, guest.id], + ); + await chat(3, "Hello", chatId); + await chat(3, "A new message", randomUUID()); + await act(moderationRoom, 3, "leave"); + await db.query("select public.join_multiplayer_room($1,$2,null,$3)", [ + moderationRoom, + secret(3), + "Guest", + ]); + guest = (await act(moderationRoom, 1)).players.find( + (p) => p.name === "Guest", + ); + assert.ok(guest); + await assert.rejects( + chat(3, "Rejoined too fast", randomUUID()), + /slow mode/, + ); + await db.query( + "update public.party_chat_cooldowns set last_message_at=clock_timestamp()-interval '11 seconds' where room_id=$1 and access_hash=$2", + [moderationRoom, secret(3)], + ); + await assert.rejects(chat(3, "", randomUUID()), /Invalid chat message/); + await chat(3, "Cooldown expired", randomUUID()); + const state = await community(2, "ban", { playerId: guest.id }); + await assert.rejects( + db.query("select public.join_multiplayer_room($1,$2,null,$3)", [ + moderationRoom, + secret(3), + "Guest", + ]), + /banned/, + ); + await community(2, "unban", { banId: state.bans[0].id }); + await db.query("select public.join_multiplayer_room($1,$2,null,$3)", [ + moderationRoom, + secret(3), + "Guest", + ]); + await community(1, "ban", { playerId: friend.id }); + await assert.rejects( + db.query("select public.join_multiplayer_room($1,$2,$3,$4)", [ + moderationRoom, + secret(8), + secondAccount, + "Ignored", + ]), + /banned/, + ); + await assert.rejects(community(2, "slow", { seconds: 0 }), /membership/); + const privileges = ( + await db.query(`select + has_table_privilege('anon','public.party_chat_cooldowns','select') as anon_read, + has_table_privilege('authenticated','public.party_chat_cooldowns','select') as authenticated_read`) + ).rows[0]; + assert.equal(privileges.anon_read, false); + assert.equal(privileges.authenticated_read, false); + await db.query("delete from public.multiplayer_rooms where id=$1", [ + moderationRoom, + ]); + assert.equal( + ( + await db.query( + "select count(*)::integer as n from public.party_chat_cooldowns where room_id=$1", + [moderationRoom], + ) + ).rows[0].n, + 0, + ); + } await assert.rejects( db.query( "select public.create_map_room($1,'normal','Guest host',4,$2,null)", diff --git a/scripts/test-tournaments-sql.mjs b/scripts/test-tournaments-sql.mjs index fcd60d1..d8b2582 100644 --- a/scripts/test-tournaments-sql.mjs +++ b/scripts/test-tournaments-sql.mjs @@ -68,6 +68,63 @@ try { JSON.stringify({ ...base, ...payload }), ]) )[0].id; + const communityEvent = await create(); + const eventAction = (account, action, payload = {}) => + q("select public.tournament_action($1,$2,$3,$4)", [ + communityEvent, + account, + action, + JSON.stringify(payload), + ]); + const community = (account, action, payload = {}) => + q("select public.tournament_community($1,$2,$3,$4) value", [ + communityEvent, + account, + action, + JSON.stringify(payload), + ]); + const chat = (account, text = null, clientId = null) => + q("select public.tournament_chat($1,$2,$3,$4) value", [ + communityEvent, + account, + text, + clientId, + ]); + await eventAction(users[1], "join"); + await eventAction(users[2], "join"); + await assert.rejects( + community(users[1], "ban", { accountId: users[2] }), + /moderator permission/, + ); + await community(users[0], "admin", { accountId: users[1] }); + await assert.rejects( + community(users[1], "ban", { accountId: users[0] }), + /owner or yourself/, + ); + await community(users[1], "tag", { accountId: users[2], tag: "Explorer" }); + await community(users[1], "slow", { seconds: 10 }); + const messageId = randomUUID(); + await chat(users[2], "Hello", messageId); + const repeated = await chat(users[2], "Hello", messageId); + assert.equal(repeated[0].value.messages.length, 1); + assert.equal(repeated[0].value.messages[0].tag, "Explorer"); + await assert.rejects(chat(users[2], "Too fast", randomUUID()), /slow mode/); + await community(users[1], "ban", { accountId: users[2] }); + await assert.rejects(chat(users[2]), /membership/); + await assert.rejects(eventAction(users[2], "join"), /banned/); + await community(users[1], "unban", { accountId: users[2] }); + await eventAction(users[2], "join"); + await eventAction(users[1], "leave"); + await assert.rejects( + community(users[1], "slow", { seconds: 0 }), + /membership/, + ); + await eventAction(users[1], "join"); + await assert.rejects( + community(users[1], "slow", { seconds: 0 }), + /moderator permission/, + ); + await q("delete from public.tournaments where id=$1", [communityEvent]); const voidEvent = await create({ difficulty: "void", roundTimeSeconds: 10, diff --git a/shared/database.ts b/shared/database.ts index f262262..7190f13 100644 --- a/shared/database.ts +++ b/shared/database.ts @@ -857,6 +857,33 @@ export type Database = { [_ in never]: never; }; Functions: { + party_community: { + Args: { + p_room: string; + p_access_hash: string; + p_action: string; + p_payload?: Json; + }; + Returns: Json; + }; + tournament_community: { + Args: { + p_tournament: string; + p_account: string; + p_action: string; + p_payload?: Json; + }; + Returns: Json; + }; + tournament_chat: { + Args: { + p_tournament: string; + p_account: string; + p_text?: string | null; + p_client_id?: string | null; + }; + Returns: Json; + }; create_tournament: { Args: { p_owner: string; p_payload: Json }; Returns: string; diff --git a/shared/locales/ru.json b/shared/locales/ru.json index bedbd09..f4054a5 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -1081,5 +1081,21 @@ "Alpha": "Альфа", "Beta": "Бета", "Production": "Релиз", - "Bug fix": "Исправления ошибок" + "Bug fix": "Исправления ошибок", + "Party moderation": "Управление группой", + "Tournament moderation": "Управление турниром", + "Chat slow mode (seconds)": "Медленный режим чата (секунды)", + "Save slow mode": "Сохранить режим чата", + "Remove moderator": "Снять модератора", + "Make moderator": "Назначить модератором", + "Kick": "Исключить", + "Ban": "Заблокировать", + "Ban this player?": "Заблокировать этого игрока?", + "Player tag": "Метка игрока", + "Save tag": "Сохранить метку", + "Banned players": "Заблокированные игроки", + "Unban": "Разблокировать", + "Chat slow mode: wait before sending another message": "Медленный режим чата: подождите перед отправкой следующего сообщения", + "You are banned from this tournament": "Вы заблокированы в этом турнире", + "You are banned from this party": "Вы заблокированы в этой группе" } diff --git a/shared/multiplayer.ts b/shared/multiplayer.ts index 8fb4405..5e0a187 100644 --- a/shared/multiplayer.ts +++ b/shared/multiplayer.ts @@ -33,6 +33,7 @@ export type RoomChat = { name: string; appearance?: import("./profile-appearance").ProfileAppearance; text: string; + tag?: string | null; createdAt: string; }[]; }; diff --git a/supabase/migrations/20261027060825_tournament_community_controls.sql b/supabase/migrations/20261027060825_tournament_community_controls.sql new file mode 100644 index 0000000..a1c9f81 --- /dev/null +++ b/supabase/migrations/20261027060825_tournament_community_controls.sql @@ -0,0 +1,126 @@ +-- Event-scoped moderation and chat are private to the trusted server. +create table public.tournament_moderators ( + tournament_id uuid not null references public.tournaments(id) on delete cascade, + account_id uuid not null references public.profiles(id) on delete cascade, + primary key(tournament_id,account_id) +); +create table public.tournament_bans ( + tournament_id uuid not null references public.tournaments(id) on delete cascade, + account_id uuid not null references public.profiles(id) on delete cascade, + primary key(tournament_id,account_id) +); +create table public.tournament_tags ( + tournament_id uuid not null references public.tournaments(id) on delete cascade, + account_id uuid not null references public.profiles(id) on delete cascade, + tag text not null check(length(tag) between 1 and 32 and tag=btrim(tag) and tag !~ '[[:cntrl:]]' and tag !~ U&'[\0080-\009F\202A-\202E\2066-\2069]'), + primary key(tournament_id,account_id) +); +alter table public.tournaments add column chat_slow_seconds integer not null default 0 check(chat_slow_seconds between 0 and 300); +create table public.tournament_messages ( + id bigint generated always as identity primary key, + tournament_id uuid not null references public.tournaments(id) on delete cascade, + account_id uuid not null references public.profiles(id) on delete cascade, + client_id uuid not null, body text not null check(length(btrim(body)) between 1 and 500 + and body !~ '[[:cntrl:]]' and body !~ U&'[\0080-\009F\202A-\202E\2066-\2069]'), + created_at timestamptz not null default clock_timestamp(), + unique(tournament_id,account_id,client_id) +); +create index tournament_messages_history on public.tournament_messages(tournament_id,id desc); +create index tournament_messages_rate on public.tournament_messages(tournament_id,account_id,created_at desc); +alter table public.tournament_moderators enable row level security; +alter table public.tournament_bans enable row level security; +alter table public.tournament_tags enable row level security; +alter table public.tournament_messages enable row level security; +revoke all on public.tournament_moderators,public.tournament_bans,public.tournament_tags,public.tournament_messages from public,anon,authenticated; +revoke all on sequence public.tournament_messages_id_seq from public,anon,authenticated; +grant all on public.tournament_moderators,public.tournament_bans,public.tournament_tags,public.tournament_messages to service_role; +grant usage,select on sequence public.tournament_messages_id_seq to service_role; + +create function public.guard_tournament_ban() returns trigger language plpgsql security invoker set search_path='' as $$ +begin + if exists(select 1 from public.tournament_bans where tournament_id=new.tournament_id and account_id=new.account_id) then + raise exception 'You are banned from this tournament'; + end if; + return new; +end; $$; +create trigger guard_tournament_ban before insert on public.tournament_members for each row execute function public.guard_tournament_ban(); +create function public.clear_tournament_member_roles() returns trigger language plpgsql security invoker set search_path='' as $$ +begin + delete from public.tournament_moderators where tournament_id=old.tournament_id and account_id=old.account_id; + delete from public.tournament_tags where tournament_id=old.tournament_id and account_id=old.account_id; + return old; +end; $$; +create trigger clear_tournament_member_roles after delete on public.tournament_members for each row execute function public.clear_tournament_member_roles(); + +create function public.tournament_community(p_tournament uuid,p_account uuid,p_action text,p_payload jsonb default '{}') returns jsonb +language plpgsql security invoker set search_path='' as $$ +declare event public.tournaments; target uuid; moderator boolean; owner boolean; seconds integer; label text; +begin + select * into event from public.tournaments where id=p_tournament for update; + if not found then raise exception 'Tournament not found'; end if; + if not exists(select 1 from public.profiles where id=p_account and account_status='active') then raise exception 'Active account required'; end if; + if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=p_account) then raise exception 'Tournament membership required'; end if; + owner := p_account=event.owner_id; + moderator := owner or exists(select 1 from public.tournament_moderators where tournament_id=p_tournament and account_id=p_account); + if p_action='view' then null; + elsif not moderator then raise exception 'Tournament moderator permission required'; + elsif event.status in('finished','cancelled') then raise exception 'Tournament has ended'; + elsif p_action='slow' then + seconds:=(p_payload->>'seconds')::integer; + if seconds is null or seconds not between 0 and 300 then raise exception 'Invalid slow mode'; end if; + update public.tournaments set chat_slow_seconds=seconds where id=p_tournament; + elsif p_action in('admin','unadmin','kick','ban','unban','tag') then + target:=(p_payload->>'accountId')::uuid; + if target is null or target=event.owner_id or target=p_account then raise exception 'Cannot moderate the owner or yourself'; end if; + if p_action in('admin','unadmin') and not owner then raise exception 'Only the owner can manage moderators'; end if; + if not owner and exists(select 1 from public.tournament_moderators where tournament_id=p_tournament and account_id=target) then raise exception 'Only the owner can moderate a moderator'; end if; + if p_action='admin' then + if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=target) then raise exception 'Player must join first'; end if; + insert into public.tournament_moderators values(p_tournament,target) on conflict do nothing; + elsif p_action='unadmin' then delete from public.tournament_moderators where tournament_id=p_tournament and account_id=target; + elsif p_action='unban' then delete from public.tournament_bans where tournament_id=p_tournament and account_id=target; + elsif p_action='tag' then + if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=target) then raise exception 'Player must join first'; end if; + label:=btrim(p_payload->>'tag'); + if label is null or length(label)>32 or label ~ '[[:cntrl:]]' or label ~ U&'[\0080-\009F\202A-\202E\2066-\2069]' then raise exception 'Invalid tag'; end if; + if label='' then delete from public.tournament_tags where tournament_id=p_tournament and account_id=target; + else insert into public.tournament_tags values(p_tournament,target,label) on conflict(tournament_id,account_id) do update set tag=excluded.tag; end if; + else + if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=target) then raise exception 'Player must join first'; end if; + if p_action='ban' then insert into public.tournament_bans values(p_tournament,target) on conflict do nothing; end if; + delete from public.tournament_moderators where tournament_id=p_tournament and account_id=target; + update public.multiplayer_players set has_left=true where account_id=target and room_id in(select room_id from public.tournament_rounds where tournament_id=p_tournament); + delete from public.tournament_members where tournament_id=p_tournament and account_id=target; + end if; + else raise exception 'Invalid tournament action'; end if; + return jsonb_build_object('owner',owner,'moderator',moderator,'slowSeconds',event.chat_slow_seconds, + 'moderators',coalesce((select jsonb_agg(account_id) from public.tournament_moderators where tournament_id=p_tournament),'[]'::jsonb), + 'tags',coalesce((select jsonb_object_agg(account_id::text,tag) from public.tournament_tags where tournament_id=p_tournament),'{}'::jsonb), + 'bans',case when moderator then coalesce((select jsonb_agg(jsonb_build_object('accountId',b.account_id,'name',p.display_name)) from public.tournament_bans b join public.profiles p on p.id=b.account_id where b.tournament_id=p_tournament),'[]'::jsonb) else '[]'::jsonb end); +end; $$; + +create function public.tournament_chat(p_tournament uuid,p_account uuid,p_text text default null,p_client_id uuid default null) returns jsonb +language plpgsql security invoker set search_path='' as $$ +declare event public.tournaments; player public.profiles; delay_seconds integer; +begin + select * into event from public.tournaments where id=p_tournament for update; + if not found then raise exception 'Tournament not found'; end if; + select * into player from public.profiles where id=p_account and account_status='active'; + if not found or not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=p_account) then raise exception 'Tournament membership required'; end if; + if event.status='cancelled' then raise exception 'Chat is unavailable'; end if; + if p_text is not null or p_client_id is not null then + if event.status='finished' then raise exception 'Tournament has ended'; end if; + if p_text is null or p_client_id is null or length(btrim(p_text)) not between 1 and 500 or p_text ~ '[[:cntrl:]]' or p_text ~ U&'[\0080-\009F\202A-\202E\2066-\2069]' then raise exception 'Invalid chat message'; end if; + if not exists(select 1 from public.tournament_messages where tournament_id=p_tournament and account_id=p_account and client_id=p_client_id) then + delay_seconds:=case when p_account=event.owner_id or exists(select 1 from public.tournament_moderators where tournament_id=p_tournament and account_id=p_account) then 1 else greatest(1,event.chat_slow_seconds) end; + if exists(select 1 from public.tournament_messages where tournament_id=p_tournament and account_id=p_account and created_at>clock_timestamp()-make_interval(secs=>delay_seconds)) or + (select count(*) from public.tournament_messages where tournament_id=p_tournament and account_id=p_account and created_at>clock_timestamp()-interval '1 minute')>=30 then raise exception 'Chat slow mode: wait before sending another message'; end if; + insert into public.tournament_messages(tournament_id,account_id,client_id,body) values(p_tournament,p_account,p_client_id,btrim(p_text)); + end if; + end if; + return jsonb_build_object('messages',coalesce((select jsonb_agg(jsonb_build_object('id',m.id::text,'playerId',m.account_id,'name',p.display_name,'text',m.body,'createdAt',m.created_at,'tag',tag.tag) order by m.id) + from (select * from public.tournament_messages where tournament_id=p_tournament order by id desc limit 100) m join public.profiles p on p.id=m.account_id left join public.tournament_tags tag on tag.tournament_id=m.tournament_id and tag.account_id=m.account_id),'[]'::jsonb)); +end; $$; +revoke all on function public.guard_tournament_ban(),public.clear_tournament_member_roles(),public.tournament_community(uuid,uuid,text,jsonb),public.tournament_chat(uuid,uuid,text,uuid) from public,anon,authenticated; +grant execute on function public.guard_tournament_ban(),public.clear_tournament_member_roles(),public.tournament_community(uuid,uuid,text,jsonb),public.tournament_chat(uuid,uuid,text,uuid) to service_role; +notify pgrst,'reload schema'; diff --git a/supabase/migrations/20261027061048_party_community_controls.sql b/supabase/migrations/20261027061048_party_community_controls.sql new file mode 100644 index 0000000..4ff182b --- /dev/null +++ b/supabase/migrations/20261027061048_party_community_controls.sql @@ -0,0 +1,106 @@ +create table public.party_moderators ( + room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, + player_id uuid not null references public.multiplayer_players(id) on delete cascade, + primary key(room_id,player_id) +); +create table public.party_bans ( + id uuid primary key default gen_random_uuid(), room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, + access_hash text not null, account_id uuid references public.profiles(id) on delete cascade, display_name text not null, + unique(room_id,access_hash) +); +create index party_bans_account on public.party_bans(room_id,account_id) where account_id is not null; +create table public.party_tags ( + room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, + player_id uuid not null references public.multiplayer_players(id) on delete cascade, + tag text not null check(length(tag) between 1 and 32 and tag=btrim(tag) and tag !~ '[[:cntrl:]]' and tag !~ U&'[\0080-\009F\202A-\202E\2066-\2069]'), + primary key(room_id,player_id) +); +alter table public.multiplayer_rooms add column chat_slow_seconds integer not null default 0 check(chat_slow_seconds between 0 and 300); +alter table public.party_moderators enable row level security; +alter table public.party_bans enable row level security; +alter table public.party_tags enable row level security; +revoke all on public.party_moderators,public.party_bans,public.party_tags from public,anon,authenticated; +grant all on public.party_moderators,public.party_bans,public.party_tags to service_role; + +create or replace function public.join_multiplayer_room(p_room uuid,p_access_hash text,p_owner uuid,p_name text) +returns jsonb language plpgsql security invoker set search_path='' as $$ +declare account_name text; result jsonb; +begin + perform 1 from public.multiplayer_rooms where id=p_room for update; + if exists(select 1 from public.party_bans where room_id=p_room and (access_hash=p_access_hash or (p_owner is not null and account_id=p_owner))) then raise exception 'You are banned from this party'; end if; + if p_owner is not null then + select display_name into account_name from public.profiles where id=p_owner; + if not found then raise exception 'Account not found'; end if; + else account_name:=p_name; end if; + result:=public.multiplayer_action(p_room,p_access_hash,'join',jsonb_build_object('name',account_name)); + if p_owner is not null then update public.multiplayer_players set account_id=p_owner where room_id=p_room and access_hash=p_access_hash and account_id is null and not has_left; end if; + return result; +end; $$; + +create function public.party_community(p_room uuid,p_access_hash text,p_action text,p_payload jsonb default '{}') returns jsonb +language plpgsql security invoker set search_path='' as $$ +declare room public.multiplayer_rooms; actor public.multiplayer_players; target public.multiplayer_players; mod boolean; owner boolean; target_id uuid; seconds integer; label text; +begin + select * into room from public.multiplayer_rooms where id=p_room for update; + if not found or room.tournament_id is not null then raise exception 'Party not found'; end if; + select * into actor from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash and not has_left; + if not found then raise exception 'Room membership permission required'; end if; + owner:=actor.id=room.host_player_id; + mod:=owner or exists(select 1 from public.party_moderators where room_id=p_room and player_id=actor.id); + if p_action='view' then null; + elsif not mod then raise exception 'Party moderator permission required'; + elsif room.status in('finished','closed') then raise exception 'Party has ended'; + elsif p_action='slow' then + seconds:=(p_payload->>'seconds')::integer; + if seconds is null or seconds not between 0 and 300 then raise exception 'Invalid slow mode'; end if; + update public.multiplayer_rooms set chat_slow_seconds=seconds where id=p_room returning * into room; + elsif p_action='unban' then + target_id:=(p_payload->>'banId')::uuid; + delete from public.party_bans where room_id=p_room and id=target_id; + elsif p_action in('admin','unadmin','kick','ban','tag') then + target_id:=(p_payload->>'playerId')::uuid; + select * into target from public.multiplayer_players where room_id=p_room and id=target_id and not has_left; + if not found or target.id=actor.id or target.id=room.host_player_id then raise exception 'Cannot moderate the owner or yourself'; end if; + if p_action in('admin','unadmin') and not owner then raise exception 'Only the owner can manage moderators'; end if; + if not owner and exists(select 1 from public.party_moderators where room_id=p_room and player_id=target.id) then raise exception 'Only the owner can moderate a moderator'; end if; + if p_action='admin' then insert into public.party_moderators values(p_room,target.id) on conflict do nothing; + elsif p_action='unadmin' then delete from public.party_moderators where room_id=p_room and player_id=target.id; + elsif p_action='tag' then + label:=btrim(p_payload->>'tag'); + if label is null or length(label)>32 or label ~ '[[:cntrl:]]' or label ~ U&'[\0080-\009F\202A-\202E\2066-\2069]' then raise exception 'Invalid tag'; end if; + if label='' then delete from public.party_tags where room_id=p_room and player_id=target.id; + else insert into public.party_tags values(p_room,target.id,label) on conflict(room_id,player_id) do update set tag=excluded.tag; end if; + else + if p_action='ban' then insert into public.party_bans(room_id,access_hash,account_id,display_name) values(p_room,target.access_hash,target.account_id,target.display_name) on conflict(room_id,access_hash) do nothing; end if; + delete from public.party_moderators where room_id=p_room and player_id=target.id; + if room.status='lobby' then delete from public.multiplayer_players where id=target.id; + else update public.multiplayer_players set has_left=true where id=target.id; end if; + end if; + else raise exception 'Invalid party action'; end if; + return jsonb_build_object('owner',owner,'moderator',mod,'slowSeconds',room.chat_slow_seconds, + 'moderators',coalesce((select jsonb_agg(player_id) from public.party_moderators where room_id=p_room),'[]'::jsonb), + 'tags',coalesce((select jsonb_object_agg(player_id::text,tag) from public.party_tags where room_id=p_room),'{}'::jsonb), + 'bans',case when mod then coalesce((select jsonb_agg(jsonb_build_object('id',id,'name',display_name)) from public.party_bans where room_id=p_room),'[]'::jsonb) else '[]'::jsonb end); +end; $$; + +-- The existing function remains the only writer; serialize slow-mode checks under the same room lock. +alter function public.multiplayer_chat(uuid,text,text,uuid) rename to multiplayer_chat_unchecked; +create function public.multiplayer_chat(p_room uuid,p_access_hash text,p_text text default null,p_client_id uuid default null) +returns jsonb language plpgsql security invoker set search_path='' as $$ +declare room public.multiplayer_rooms; player public.multiplayer_players; result jsonb; +begin + select * into room from public.multiplayer_rooms where id=p_room for update; + select * into player from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash and not has_left; + if player.id is not null and p_text is not null and room.chat_slow_seconds>0 and player.id<>room.host_player_id + and not exists(select 1 from public.party_moderators where room_id=p_room and player_id=player.id) + and not exists(select 1 from public.multiplayer_messages where room_id=p_room and player_id=player.id and client_id=p_client_id) + and exists(select 1 from public.multiplayer_messages where room_id=p_room and player_id=player.id and created_at>clock_timestamp()-make_interval(secs=>room.chat_slow_seconds)) + then raise exception 'Chat slow mode: wait before sending another message'; end if; + result:=public.multiplayer_chat_unchecked(p_room,p_access_hash,p_text,p_client_id); + return jsonb_set(result,'{messages}',coalesce((select jsonb_agg(msg || jsonb_build_object('tag',tag.tag) order by ord) + from jsonb_array_elements(result->'messages') with ordinality x(msg,ord) + left join public.party_tags tag on tag.room_id=p_room and tag.player_id=(msg->>'playerId')::uuid),'[]'::jsonb)); +end; $$; +revoke all on function public.party_community(uuid,text,text,jsonb),public.multiplayer_chat(uuid,text,text,uuid),public.multiplayer_chat_unchecked(uuid,text,text,uuid) from public,anon,authenticated; +grant execute on function public.party_community(uuid,text,text,jsonb),public.multiplayer_chat(uuid,text,text,uuid),public.multiplayer_chat_unchecked(uuid,text,text,uuid) to service_role; +notify pgrst,'reload schema'; diff --git a/supabase/migrations/20261027062211_community_moderation_indexes.sql b/supabase/migrations/20261027062211_community_moderation_indexes.sql new file mode 100644 index 0000000..b1b0620 --- /dev/null +++ b/supabase/migrations/20261027062211_community_moderation_indexes.sql @@ -0,0 +1,7 @@ +create index party_bans_profile_fk on public.party_bans(account_id); +create index party_moderators_player_fk on public.party_moderators(player_id); +create index party_tags_player_fk on public.party_tags(player_id); +create index tournament_bans_profile_fk on public.tournament_bans(account_id); +create index tournament_messages_profile_fk on public.tournament_messages(account_id); +create index tournament_moderators_profile_fk on public.tournament_moderators(account_id); +create index tournament_tags_profile_fk on public.tournament_tags(account_id); diff --git a/supabase/migrations/20261027075127_party_chat_slow_mode_identity.sql b/supabase/migrations/20261027075127_party_chat_slow_mode_identity.sql new file mode 100644 index 0000000..6234ddf --- /dev/null +++ b/supabase/migrations/20261027075127_party_chat_slow_mode_identity.sql @@ -0,0 +1,55 @@ +-- Wait for in-flight room actions and block new ones until backfill and function +-- replacement are committed, so accepted messages cannot fall between them. +lock table public.multiplayer_rooms in exclusive mode; + +-- Player rows and their messages disappear when a player leaves a lobby. +-- Keep the cooldown on the room's existing access identity instead. +create table public.party_chat_cooldowns ( + room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, + access_hash text not null, + last_message_at timestamptz not null, + primary key (room_id, access_hash) +); +alter table public.party_chat_cooldowns enable row level security; +revoke all on public.party_chat_cooldowns from public, anon, authenticated; +grant all on public.party_chat_cooldowns to service_role; + +-- Preserve the cooldown of players who are already in a room at deployment. +insert into public.party_chat_cooldowns (room_id, access_hash, last_message_at) +select m.room_id, p.access_hash, max(m.created_at) +from public.multiplayer_messages m +join public.multiplayer_players p on p.room_id=m.room_id and p.id=m.player_id +join public.multiplayer_rooms r on r.id=m.room_id +where r.tournament_id is null +group by m.room_id, p.access_hash; + +create or replace function public.multiplayer_chat(p_room uuid,p_access_hash text,p_text text default null,p_client_id uuid default null) +returns jsonb language plpgsql security invoker set search_path='' as $$ +declare room public.multiplayer_rooms; player public.multiplayer_players; result jsonb; duplicate_message boolean; +begin + -- The room lock also serializes joining, leaving, and concurrent chat sends. + select * into room from public.multiplayer_rooms where id=p_room for update; + select * into player from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash and not has_left; + duplicate_message := player.id is not null and p_client_id is not null and exists( + select 1 from public.multiplayer_messages where room_id=p_room and player_id=player.id and client_id=p_client_id + ); + if player.id is not null and p_text is not null and room.chat_slow_seconds>0 and player.id<>room.host_player_id + and not exists(select 1 from public.party_moderators where room_id=p_room and player_id=player.id) + and not duplicate_message + and exists(select 1 from public.party_chat_cooldowns where room_id=p_room and access_hash=p_access_hash + and last_message_at>clock_timestamp()-make_interval(secs=>room.chat_slow_seconds)) + then raise exception 'Chat slow mode: wait before sending another message'; end if; + result:=public.multiplayer_chat_unchecked(p_room,p_access_hash,p_text,p_client_id); + -- Only accepted new messages advance the cooldown; reads and retries do not. + if player.id is not null and p_text is not null and p_client_id is not null and not duplicate_message then + insert into public.party_chat_cooldowns(room_id,access_hash,last_message_at) + values(p_room,p_access_hash,clock_timestamp()) + on conflict(room_id,access_hash) do update set last_message_at=excluded.last_message_at; + end if; + return jsonb_set(result,'{messages}',coalesce((select jsonb_agg(msg || jsonb_build_object('tag',tag.tag) order by ord) + from jsonb_array_elements(result->'messages') with ordinality x(msg,ord) + left join public.party_tags tag on tag.room_id=p_room and tag.player_id=(msg->>'playerId')::uuid),'[]'::jsonb)); +end; $$; +revoke all on function public.multiplayer_chat(uuid,text,text,uuid) from public,anon,authenticated; +grant execute on function public.multiplayer_chat(uuid,text,text,uuid) to service_role; +notify pgrst,'reload schema'; diff --git a/tests/tournaments.test.ts b/tests/tournaments.test.ts index a46c3a0..bf07a7e 100644 --- a/tests/tournaments.test.ts +++ b/tests/tournaments.test.ts @@ -128,6 +128,43 @@ const manifest = { })), assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 }], }; +test("community and chat derive the actor from the signed-in account and reject forged fields", async () => { + const guest = fixture({ signedIn: false }); + await assert.rejects(guest.run(`tournaments/${tournament}/chat`), /Sign in/); + const f = fixture(); + await assert.rejects( + f.run(`tournaments/${tournament}/community`, "POST", { + action: "admin", + accountId: map, + actor: map, + }), + ); + await assert.rejects( + f.run(`tournaments/${tournament}/chat`, "POST", { + clientId: room, + text: "hello", + accountId: map, + }), + ); + f.reply("tournament_community", { moderator: true }); + await f.run(`tournaments/${tournament}/community`, "POST", { + action: "ban", + accountId: map, + }); + assert.equal( + (f.calls.at(-1)?.args[0] as { p_account: string }).p_account, + member, + ); + f.reply("tournament_chat", { messages: [] }); + await f.run(`tournaments/${tournament}/chat`, "POST", { + clientId: room, + text: "hello", + }); + assert.equal( + (f.calls.at(-1)?.args[0] as { p_account: string }).p_account, + member, + ); +}); function mediaReplies(f: ReturnType, view = playingView()) { f.reply("tournament_action", view); f.reply("multiplayer_rooms", { revision_id: revision });