diff --git a/scripts/test-multiplayer-sql.mjs b/scripts/test-multiplayer-sql.mjs index 6656394..b701260 100644 --- a/scripts/test-multiplayer-sql.mjs +++ b/scripts/test-multiplayer-sql.mjs @@ -102,7 +102,7 @@ try { "Guest", ]); const roster = (await act(moderationRoom, 1)).players; - const friend = roster.find((p) => p.name === "Account friend"); + let friend = roster.find((p) => p.name === "Account friend"); let guest = roster.find((p) => p.name === "Guest"); const community = async (who, action, payload = {}) => ( @@ -180,11 +180,33 @@ try { secret(3), "Guest", ]); + await community(1, "unadmin", { playerId: friend.id }); + await chat(2, "Signed-in player", randomUUID()); + await act(moderationRoom, 2, "leave"); + await db.query("select public.join_multiplayer_room($1,$2,$3,$4)", [ + moderationRoom, + secret(8), + secondAccount, + "Ignored", + ]); + await assert.rejects( + chat(8, "Different browser", randomUUID()), + /slow mode/, + ); + await db.query( + "update public.party_chat_account_cooldowns set last_message_at=clock_timestamp()-interval '11 seconds' where room_id=$1 and account_id=$2", + [moderationRoom, secondAccount], + ); + await chat(8, "Allowed after the delay", randomUUID()); + friend = (await act(moderationRoom, 1)).players.find( + (p) => p.name === "Account friend", + ); + assert.ok(friend); await community(1, "ban", { playerId: friend.id }); await assert.rejects( db.query("select public.join_multiplayer_room($1,$2,$3,$4)", [ moderationRoom, - secret(8), + secret(9), secondAccount, "Ignored", ]), @@ -194,10 +216,14 @@ try { 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`) + has_table_privilege('authenticated','public.party_chat_cooldowns','select') as authenticated_read, + has_table_privilege('anon','public.party_chat_account_cooldowns','select') as anon_account_read, + has_table_privilege('authenticated','public.party_chat_account_cooldowns','select') as authenticated_account_read`) ).rows[0]; assert.equal(privileges.anon_read, false); assert.equal(privileges.authenticated_read, false); + assert.equal(privileges.anon_account_read, false); + assert.equal(privileges.authenticated_account_read, false); await db.query("delete from public.multiplayer_rooms where id=$1", [ moderationRoom, ]); diff --git a/supabase/migrations/20261027120223_party_chat_account_cooldowns.sql b/supabase/migrations/20261027120223_party_chat_account_cooldowns.sql new file mode 100644 index 0000000..36f958a --- /dev/null +++ b/supabase/migrations/20261027120223_party_chat_account_cooldowns.sql @@ -0,0 +1,58 @@ +-- Serialize the backfill with room joins, leaves, and chat writes. +lock table public.multiplayer_rooms in exclusive mode; + +create table public.party_chat_account_cooldowns ( + room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, + account_id uuid not null references public.profiles(id) on delete cascade, + last_message_at timestamptz not null, + primary key(room_id, account_id) +); +create index party_chat_account_cooldowns_account on public.party_chat_account_cooldowns(account_id); +alter table public.party_chat_account_cooldowns enable row level security; +revoke all on public.party_chat_account_cooldowns from public, anon, authenticated; +grant all on public.party_chat_account_cooldowns to service_role; + +-- Include the retained browser cooldown even when a player's messages were deleted. +insert into public.party_chat_account_cooldowns(room_id, account_id, last_message_at) +select p.room_id, p.account_id, max(c.last_message_at) +from public.multiplayer_players p +join public.party_chat_cooldowns c on c.room_id=p.room_id and c.access_hash=p.access_hash +where p.account_id is not null +group by p.room_id, p.account_id; + +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 + 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)) + or (player.account_id is not null and exists(select 1 from public.party_chat_account_cooldowns + where room_id=p_room and account_id=player.account_id + 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); + 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; + if player.account_id is not null then + insert into public.party_chat_account_cooldowns(room_id,account_id,last_message_at) + values(p_room,player.account_id,clock_timestamp()) + on conflict(room_id,account_id) do update set last_message_at=excluded.last_message_at; + end if; + 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';