Add tournament and multiplayer community chat moderation
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
This commit is contained in:
@@ -27,6 +27,7 @@ import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
|||||||
import { initialSiteSettings } from "@/lib/site-settings";
|
import { initialSiteSettings } from "@/lib/site-settings";
|
||||||
import { rawUser, accountState } from "@/lib/supabase";
|
import { rawUser, accountState } from "@/lib/supabase";
|
||||||
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
|
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
|
||||||
|
import { BackNavigationTransition } from "@/components/BackNavigationTransition";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
@@ -120,6 +121,9 @@ export default async function RootLayout({
|
|||||||
detectedLocale={detectedLocale}
|
detectedLocale={detectedLocale}
|
||||||
>
|
>
|
||||||
<SiteSettingsProvider initialSettings={settings}>
|
<SiteSettingsProvider initialSettings={settings}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BackNavigationTransition />
|
||||||
|
</Suspense>
|
||||||
{children}
|
{children}
|
||||||
<SiteCursor />
|
<SiteCursor />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -103,6 +103,37 @@
|
|||||||
.tournament-form .tournament-checkbox {
|
.tournament-form .tournament-checkbox {
|
||||||
grid-column: 1/-1;
|
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 {
|
.tournament-form .tournament-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
46
components/BackNavigationTransition.tsx
Normal file
46
components/BackNavigationTransition.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Copy, Users, Crown, Trophy, MessageCircle } from "lucide-react";
|
|||||||
import { Brand } from "./Brand";
|
import { Brand } from "./Brand";
|
||||||
import { MultiplayerGame } from "./MultiplayerGame";
|
import { MultiplayerGame } from "./MultiplayerGame";
|
||||||
import { RoomChat } from "./RoomChat";
|
import { RoomChat } from "./RoomChat";
|
||||||
|
import { TournamentModeration } from "./TournamentModeration";
|
||||||
import { useI18n } from "./I18nProvider";
|
import { useI18n } from "./I18nProvider";
|
||||||
import { api } from "@/lib/client-api";
|
import { api } from "@/lib/client-api";
|
||||||
import { roundClock } from "@/shared/round-clock";
|
import { roundClock } from "@/shared/round-clock";
|
||||||
@@ -154,6 +155,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
available={room.status !== "closed"}
|
available={room.status !== "closed"}
|
||||||
playing
|
playing
|
||||||
/>
|
/>
|
||||||
|
<TournamentModeration
|
||||||
|
party
|
||||||
|
playing
|
||||||
|
id={id}
|
||||||
|
players={room.players
|
||||||
|
.filter((p) => !p.left)
|
||||||
|
.map((p) => ({ accountId: p.id, name: p.name }))}
|
||||||
|
ownerId={room.hostId}
|
||||||
|
you={room.you}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const owner = !!room?.you && room.you === room.hostId;
|
const owner = !!room?.you && room.you === room.hostId;
|
||||||
@@ -167,7 +178,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
{t("Play & maps")}
|
{t("Play & maps")}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
<main className="section page-content multiplayer-lobby" id="main-content" tabIndex={-1}>
|
<main
|
||||||
|
className="section page-content multiplayer-lobby"
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="error-message" role="alert">
|
<p className="error-message" role="alert">
|
||||||
{t(error)}{" "}
|
{t(error)}{" "}
|
||||||
@@ -184,16 +199,15 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
<Users size={16} /> {t("Multiplayer")} · {room.name}
|
<Users size={16} /> {t("Multiplayer")} · {room.name}
|
||||||
</span>
|
</span>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{t("Difficulty")}: <strong>
|
{t("Difficulty")}:{" "}
|
||||||
|
<strong>
|
||||||
{room.difficultySettings.id in difficulties
|
{room.difficultySettings.id in difficulties
|
||||||
? t(room.difficultySettings.name)
|
? t(room.difficultySettings.name)
|
||||||
: room.difficultySettings.name}
|
: room.difficultySettings.name}
|
||||||
</strong>
|
</strong>
|
||||||
{" · "}
|
{" · "}
|
||||||
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
|
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
|
||||||
{room.difficultySettings.zoom
|
{room.difficultySettings.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||||
? t("Zoom allowed")
|
|
||||||
: t("No zoom")}
|
|
||||||
</p>
|
</p>
|
||||||
<h1>
|
<h1>
|
||||||
{finished
|
{finished
|
||||||
@@ -248,6 +262,15 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
{room.you && room.status === "lobby" && (
|
{room.you && room.status === "lobby" && (
|
||||||
<>
|
<>
|
||||||
|
<TournamentModeration
|
||||||
|
party
|
||||||
|
id={id}
|
||||||
|
players={room.players
|
||||||
|
.filter((p) => !p.left)
|
||||||
|
.map((p) => ({ accountId: p.id, name: p.name }))}
|
||||||
|
ownerId={room.hostId}
|
||||||
|
you={room.you}
|
||||||
|
/>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
{owner
|
{owner
|
||||||
? t(
|
? t(
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ export function RoomChat({
|
|||||||
playerId,
|
playerId,
|
||||||
available,
|
available,
|
||||||
playing,
|
playing,
|
||||||
|
endpoint,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
playerId: string | null;
|
playerId: string | null;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
endpoint?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -38,12 +40,7 @@ export function RoomChat({
|
|||||||
const followLatest = useRef(true);
|
const followLatest = useRef(true);
|
||||||
const enabled = !!playerId && available;
|
const enabled = !!playerId && available;
|
||||||
const accept = useCallback((view: ChatView) => {
|
const accept = useCallback((view: ChatView) => {
|
||||||
setMessages((previous) =>
|
setMessages(view.messages);
|
||||||
previous.length === view.messages.length &&
|
|
||||||
previous.at(-1)?.id === view.messages.at(-1)?.id
|
|
||||||
? previous
|
|
||||||
: view.messages,
|
|
||||||
);
|
|
||||||
if (!initialized.current) {
|
if (!initialized.current) {
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
setLastRead(BigInt(view.messages.at(-1)?.id ?? "0"));
|
setLastRead(BigInt(view.messages.at(-1)?.id ?? "0"));
|
||||||
@@ -54,12 +51,12 @@ export function RoomChat({
|
|||||||
if (inFlight.current) return;
|
if (inFlight.current) return;
|
||||||
const current = ++version.current;
|
const current = ++version.current;
|
||||||
try {
|
try {
|
||||||
const view = await api<ChatView>(`rooms/${id}/chat`);
|
const view = await api<ChatView>(endpoint ?? `rooms/${id}/chat`);
|
||||||
if (current === version.current) accept(view);
|
if (current === version.current) accept(view);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
if (current === version.current) setError((failure as Error).message);
|
if (current === version.current) setError((failure as Error).message);
|
||||||
}
|
}
|
||||||
}, [id, accept]);
|
}, [id, endpoint, accept]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -111,7 +108,7 @@ export function RoomChat({
|
|||||||
setError("");
|
setError("");
|
||||||
const current = ++version.current;
|
const current = ++version.current;
|
||||||
try {
|
try {
|
||||||
const view = await api<ChatView>(`rooms/${id}/chat`, body);
|
const view = await api<ChatView>(endpoint ?? `rooms/${id}/chat`, body);
|
||||||
if (current === version.current) {
|
if (current === version.current) {
|
||||||
accept(view);
|
accept(view);
|
||||||
followLatest.current = true;
|
followLatest.current = true;
|
||||||
@@ -199,6 +196,7 @@ export function RoomChat({
|
|||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<Username name={message.name} appearance={message.appearance} />
|
<Username name={message.name} appearance={message.appearance} />
|
||||||
|
{message.tag && <small> · {message.tag}</small>}
|
||||||
{message.playerId === playerId && ` ${t("(you)")}`}
|
{message.playerId === playerId && ` ${t("(you)")}`}
|
||||||
</strong>
|
</strong>
|
||||||
<span>{message.text}</span>
|
<span>{message.text}</span>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Trophy, Users, CalendarClock, Plus, Copy } from "lucide-react";
|
import { Trophy, Users, CalendarClock, Plus, Copy } from "lucide-react";
|
||||||
import { api } from "@/lib/client-api";
|
import { api } from "@/lib/client-api";
|
||||||
import { useI18n } from "./I18nProvider";
|
import { useI18n } from "./I18nProvider";
|
||||||
|
import { RoomChat } from "./RoomChat";
|
||||||
|
import { TournamentModeration } from "./TournamentModeration";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
const MultiplayerGame = dynamic(
|
const MultiplayerGame = dynamic(
|
||||||
() => import("./MultiplayerGame").then((module) => module.MultiplayerGame),
|
() => import("./MultiplayerGame").then((module) => module.MultiplayerGame),
|
||||||
@@ -576,6 +578,14 @@ export function TournamentLobby({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{joined && (
|
||||||
|
<TournamentModeration
|
||||||
|
id={id}
|
||||||
|
players={event.players}
|
||||||
|
ownerId={event.ownerId}
|
||||||
|
you={event.you}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{owner && event.announceDiscord && (
|
{owner && event.announceDiscord && (
|
||||||
<p role="status" className="muted">
|
<p role="status" className="muted">
|
||||||
{t("Discord announcement")}:{" "}
|
{t("Discord announcement")}:{" "}
|
||||||
@@ -754,6 +764,13 @@ export function TournamentLobby({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RoomChat
|
||||||
|
id={id}
|
||||||
|
playerId={event.you}
|
||||||
|
endpoint={`tournaments/${id}/chat`}
|
||||||
|
available={joined && ["scheduled", "running"].includes(event.status)}
|
||||||
|
playing={event.status === "running"}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
249
components/TournamentModeration.tsx
Normal file
249
components/TournamentModeration.tsx
Normal file
@@ -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<string, string>;
|
||||||
|
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<Community | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [tags, setTags] = useState<Record<string, string>>({});
|
||||||
|
const endpoint = `${party ? "rooms" : "tournaments"}/${id}/community`;
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setCommunity(await api<Community>(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 ? (
|
||||||
|
<p className="muted">
|
||||||
|
{tagged
|
||||||
|
.map(
|
||||||
|
(player) => `${player.name} · ${community.tags[player.accountId]}`,
|
||||||
|
)
|
||||||
|
.join(" | ")}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
const panel = (
|
||||||
|
<section
|
||||||
|
className="panel tournament-form community-moderation"
|
||||||
|
aria-label={t(party ? "Party moderation" : "Tournament moderation")}
|
||||||
|
>
|
||||||
|
<h2>{t(party ? "Party moderation" : "Tournament moderation")}</h2>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error-message">
|
||||||
|
{t(error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
void act("slow", { seconds: Number(data.get("seconds")) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
{t("Chat slow mode (seconds)")}
|
||||||
|
<input
|
||||||
|
key={community.slowSeconds}
|
||||||
|
name="seconds"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="300"
|
||||||
|
defaultValue={community.slowSeconds}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="button" disabled={busy}>
|
||||||
|
{t("Save slow mode")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ul className="multiplayer-roster">
|
||||||
|
{players
|
||||||
|
.filter(
|
||||||
|
(player) =>
|
||||||
|
player.accountId !== ownerId &&
|
||||||
|
player.accountId !== you &&
|
||||||
|
(community.owner ||
|
||||||
|
!community.moderators.includes(player.accountId)),
|
||||||
|
)
|
||||||
|
.map((player) => (
|
||||||
|
<li key={player.accountId}>
|
||||||
|
<span>
|
||||||
|
{player.name}
|
||||||
|
{community.moderators.includes(player.accountId) &&
|
||||||
|
` · ${t("Moderator")}`}
|
||||||
|
{community.tags[player.accountId] &&
|
||||||
|
` · ${community.tags[player.accountId]}`}
|
||||||
|
</span>
|
||||||
|
<div className="inline-actions">
|
||||||
|
{community.owner && (
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(
|
||||||
|
community.moderators.includes(player.accountId)
|
||||||
|
? "unadmin"
|
||||||
|
: "admin",
|
||||||
|
{
|
||||||
|
[party ? "playerId" : "accountId"]: player.accountId,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
community.moderators.includes(player.accountId)
|
||||||
|
? "Remove moderator"
|
||||||
|
: "Make moderator",
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act("kick", {
|
||||||
|
[party ? "playerId" : "accountId"]: player.accountId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Kick")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t("Ban this player?")))
|
||||||
|
void act("ban", {
|
||||||
|
[party ? "playerId" : "accountId"]: player.accountId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Ban")}
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void act("tag", {
|
||||||
|
[party ? "playerId" : "accountId"]: player.accountId,
|
||||||
|
tag:
|
||||||
|
tags[player.accountId] ??
|
||||||
|
community.tags[player.accountId] ??
|
||||||
|
"",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<span className="sr-only">{t("Player tag")}</span>
|
||||||
|
<input
|
||||||
|
maxLength={32}
|
||||||
|
value={
|
||||||
|
tags[player.accountId] ??
|
||||||
|
community.tags[player.accountId] ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTags({
|
||||||
|
...tags,
|
||||||
|
[player.accountId]: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("Player tag")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="button" disabled={busy}>
|
||||||
|
{t("Save tag")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{community.bans.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3>{t("Banned players")}</h3>
|
||||||
|
<ul>
|
||||||
|
{community.bans.map((player) => (
|
||||||
|
<li key={player.id ?? player.accountId}>
|
||||||
|
{player.name}{" "}
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(
|
||||||
|
"unban",
|
||||||
|
party
|
||||||
|
? { banId: player.id }
|
||||||
|
: { accountId: player.accountId },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Unban")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
if (!playing) return panel;
|
||||||
|
return (
|
||||||
|
<details className="community-moderation-floating">
|
||||||
|
<summary className="button">{t("Party moderation")}</summary>
|
||||||
|
{panel}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,6 +93,64 @@ export async function multiplayerApi(
|
|||||||
409,
|
409,
|
||||||
);
|
);
|
||||||
const action = path[2] ?? "view";
|
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") {
|
if (action === "discord" && path.length === 3 && request.method === "POST") {
|
||||||
const view = required(
|
const view = required(
|
||||||
await client.rpc("multiplayer_action", {
|
await client.rpc("multiplayer_action", {
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
panoramaPath,
|
panoramaPath,
|
||||||
viewpointSchema,
|
viewpointSchema,
|
||||||
} from "@/shared/protocol";
|
} from "@/shared/protocol";
|
||||||
import { roomGuessSchema, roomRoundSchema } from "@/shared/multiplayer";
|
import {
|
||||||
|
chatMessageSchema,
|
||||||
|
roomGuessSchema,
|
||||||
|
roomRoundSchema,
|
||||||
|
} from "@/shared/multiplayer";
|
||||||
import { ServiceError, checkServiceError } from "./service-error";
|
import { ServiceError, checkServiceError } from "./service-error";
|
||||||
|
|
||||||
type Dependencies = {
|
type Dependencies = {
|
||||||
@@ -82,6 +86,75 @@ export async function tournamentApi(
|
|||||||
throw new ServiceError("Not found", 404);
|
throw new ServiceError("Not found", 404);
|
||||||
const id = z.string().uuid().parse(path[1]);
|
const id = z.string().uuid().parse(path[1]);
|
||||||
const action = path[2] ?? "view";
|
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 =
|
const read =
|
||||||
request.method === "GET" && (action === "view" || action === "media");
|
request.method === "GET" && (action === "view" || action === "media");
|
||||||
const write =
|
const write =
|
||||||
|
|||||||
@@ -88,6 +88,129 @@ try {
|
|||||||
[dataset, secret(1), account],
|
[dataset, secret(1), account],
|
||||||
)
|
)
|
||||||
).rows[0].id;
|
).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(
|
await assert.rejects(
|
||||||
db.query(
|
db.query(
|
||||||
"select public.create_map_room($1,'normal','Guest host',4,$2,null)",
|
"select public.create_map_room($1,'normal','Guest host',4,$2,null)",
|
||||||
|
|||||||
@@ -68,6 +68,63 @@ try {
|
|||||||
JSON.stringify({ ...base, ...payload }),
|
JSON.stringify({ ...base, ...payload }),
|
||||||
])
|
])
|
||||||
)[0].id;
|
)[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({
|
const voidEvent = await create({
|
||||||
difficulty: "void",
|
difficulty: "void",
|
||||||
roundTimeSeconds: 10,
|
roundTimeSeconds: 10,
|
||||||
|
|||||||
@@ -857,6 +857,33 @@ export type Database = {
|
|||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Functions: {
|
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: {
|
create_tournament: {
|
||||||
Args: { p_owner: string; p_payload: Json };
|
Args: { p_owner: string; p_payload: Json };
|
||||||
Returns: string;
|
Returns: string;
|
||||||
|
|||||||
@@ -1081,5 +1081,21 @@
|
|||||||
"Alpha": "Альфа",
|
"Alpha": "Альфа",
|
||||||
"Beta": "Бета",
|
"Beta": "Бета",
|
||||||
"Production": "Релиз",
|
"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": "Вы заблокированы в этой группе"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type RoomChat = {
|
|||||||
name: string;
|
name: string;
|
||||||
appearance?: import("./profile-appearance").ProfileAppearance;
|
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||||
text: string;
|
text: string;
|
||||||
|
tag?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
106
supabase/migrations/20261027061048_party_community_controls.sql
Normal file
106
supabase/migrations/20261027061048_party_community_controls.sql
Normal file
@@ -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';
|
||||||
@@ -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);
|
||||||
@@ -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';
|
||||||
@@ -128,6 +128,43 @@ const manifest = {
|
|||||||
})),
|
})),
|
||||||
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 }],
|
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<typeof fixture>, view = playingView()) {
|
function mediaReplies(f: ReturnType<typeof fixture>, view = playingView()) {
|
||||||
f.reply("tournament_action", view);
|
f.reply("tournament_action", view);
|
||||||
f.reply("multiplayer_rooms", { revision_id: revision });
|
f.reply("multiplayer_rooms", { revision_id: revision });
|
||||||
|
|||||||
Reference in New Issue
Block a user