Durcit la sécurité : plafond de score réaliste, REVOKE explicites, banque de questions du Char réservée aux juges
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
Un score falsifié envoyé directement à submit_icarus_score/submit_melon_score pouvait se convertir en vraies gloires au Tribunal (plafond abaissé de 1 000 000 à 5000, contrainte + RPC). Les fonctions cron-only award_*_points_if_due() n'étaient protégées que par un commentaire, pas un vrai REVOKE. Le bucket avatars n'avait aucune limite de taille/type. La banque complète des questions du Char (spoiler du jeu) était lisible par tout Citoyen via /char ou un appel direct — RLS restreinte aux juges, /char passe désormais par la RPC chariot_revealed_question(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+12
-15
@@ -6,44 +6,42 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { EntryRanking } from "./entry-ranking";
|
||||
import { GardienBar } from "./gardien-bar";
|
||||
import { RouletteSection } from "./roulette-section";
|
||||
import type { ProfileRow, QuestionRow, SettingsRow, SlotRow } from "./page";
|
||||
import type { ProfileRow, RevealedQuestion, SettingsRow, SlotRow } from "./page";
|
||||
import { SlotBoard } from "./slot-board";
|
||||
|
||||
export function CharView({
|
||||
isJudge,
|
||||
initialSlots,
|
||||
initialEntryCounts,
|
||||
initialQuestions,
|
||||
initialRevealedQuestion,
|
||||
initialSettings,
|
||||
initialProfiles,
|
||||
}: {
|
||||
isJudge: boolean;
|
||||
initialSlots: SlotRow[];
|
||||
initialEntryCounts: Record<string, number>;
|
||||
initialQuestions: QuestionRow[];
|
||||
initialRevealedQuestion: RevealedQuestion;
|
||||
initialSettings: SettingsRow;
|
||||
initialProfiles: ProfileRow[];
|
||||
}) {
|
||||
const [slots, setSlots] = useState(initialSlots);
|
||||
const [entryCounts, setEntryCounts] = useState(initialEntryCounts);
|
||||
const [questions, setQuestions] = useState(initialQuestions);
|
||||
const [revealedQuestion, setRevealedQuestion] = useState(initialRevealedQuestion);
|
||||
const [settings, setSettings] = useState(initialSettings);
|
||||
const [profiles, setProfiles] = useState(initialProfiles);
|
||||
|
||||
// Une seule page projetée (16:9) : on ré-interroge tout à chaque
|
||||
// changement, y compris chariot_questions/settings — la question révélée
|
||||
// est désormais pilotée depuis /char/questions (un autre appareil), donc
|
||||
// cette page doit réagir en direct à ces changements-là aussi.
|
||||
// changement, y compris settings — la question révélée est désormais
|
||||
// pilotée depuis /char/questions (un autre appareil), donc cette page
|
||||
// doit réagir en direct à ces changements-là aussi. La question révélée
|
||||
// passe par la RPC chariot_revealed_question() (jamais une lecture
|
||||
// directe de chariot_questions, réservée aux juges — spoiler sinon).
|
||||
const refetch = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const [{ data: sl }, { data: en }, { data: q }, { data: s }, { data: p }] = await Promise.all([
|
||||
supabase.from("chariot_slots").select("slot, user_id").order("slot", { ascending: true }),
|
||||
supabase.from("chariot_entries").select("user_id"),
|
||||
supabase
|
||||
.from("chariot_questions")
|
||||
.select("id, text, position, created_at")
|
||||
.order("position", { ascending: true })
|
||||
.order("created_at", { ascending: true }),
|
||||
supabase.rpc("chariot_revealed_question"),
|
||||
supabase
|
||||
.from("settings")
|
||||
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
|
||||
@@ -57,7 +55,7 @@ export function CharView({
|
||||
for (const entry of en) counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
|
||||
setEntryCounts(counts);
|
||||
}
|
||||
if (q) setQuestions(q);
|
||||
setRevealedQuestion((q?.[0] as RevealedQuestion) ?? null);
|
||||
if (s) setSettings(s);
|
||||
if (p) setProfiles(p);
|
||||
}, []);
|
||||
@@ -84,7 +82,6 @@ export function CharView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
const revealedQuestion = questions.find((q) => q.id === settings.chariot_revealed_question_id) ?? null;
|
||||
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
|
||||
|
||||
return (
|
||||
@@ -109,7 +106,7 @@ export function CharView({
|
||||
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
|
||||
>
|
||||
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
|
||||
Question {questions.findIndex((q) => q.id === revealedQuestion.id) + 1}
|
||||
Question {revealedQuestion.question_number}
|
||||
</p>
|
||||
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">{revealedQuestion.text}</p>
|
||||
</div>
|
||||
|
||||
+9
-10
@@ -10,12 +10,14 @@ export type ProfileRow = {
|
||||
points: number;
|
||||
};
|
||||
|
||||
export type QuestionRow = {
|
||||
// Uniquement la question révélée (jamais la banque complète, réservée aux
|
||||
// juges — voir la RPC chariot_revealed_question() et la RLS de
|
||||
// chariot_questions dans supabase/schema.sql, audit de sécurité).
|
||||
export type RevealedQuestion = {
|
||||
id: string;
|
||||
text: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
};
|
||||
question_number: number;
|
||||
} | null;
|
||||
|
||||
export type SlotRow = {
|
||||
slot: number;
|
||||
@@ -60,11 +62,8 @@ export default async function CharPage() {
|
||||
|
||||
const { data: entries } = await supabase.from("chariot_entries").select("user_id");
|
||||
|
||||
const { data: questions } = await supabase
|
||||
.from("chariot_questions")
|
||||
.select("id, text, position, created_at")
|
||||
.order("position", { ascending: true })
|
||||
.order("created_at", { ascending: true });
|
||||
const { data: revealedRows } = await supabase.rpc("chariot_revealed_question");
|
||||
const revealedQuestion: RevealedQuestion = (revealedRows?.[0] as RevealedQuestion) ?? null;
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from("settings")
|
||||
@@ -83,7 +82,7 @@ export default async function CharPage() {
|
||||
isJudge={isJudge}
|
||||
initialSlots={(slots ?? []) as SlotRow[]}
|
||||
initialEntryCounts={buildEntryCounts(entries ?? [])}
|
||||
initialQuestions={questions ?? []}
|
||||
initialRevealedQuestion={revealedQuestion}
|
||||
initialSettings={
|
||||
settings ?? { chariot_revealed_question_id: null, gardien_holder_id: null, gardien_expires_at: null }
|
||||
}
|
||||
|
||||
@@ -77,4 +77,7 @@ export const BOOST_DASH_DISTANCE = 22;
|
||||
export const SHATTER_ANIM_MS = 450;
|
||||
|
||||
export const MIN_SCORE = 0;
|
||||
export const MAX_SCORE = 1000000;
|
||||
// Doit rester aligné avec la contrainte icarus_scores_best_score_check
|
||||
// (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante
|
||||
// n'est qu'une valeur de référence côté client.
|
||||
export const MAX_SCORE = 5000;
|
||||
|
||||
@@ -42,7 +42,10 @@ export const FIXED_DT_MS = 1000 / 60;
|
||||
export const MAX_FRAME_DT_MS = 250;
|
||||
|
||||
export const MIN_SCORE = 0;
|
||||
export const MAX_SCORE = 1000000;
|
||||
// Doit rester aligné avec la contrainte melon_scores_best_score_check
|
||||
// (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante
|
||||
// n'est qu'une valeur de référence côté client.
|
||||
export const MAX_SCORE = 5000;
|
||||
|
||||
// Positions figées à la main pour certains Citoyens (id de profil → index
|
||||
// de palier, 0 = le plus petit) — les Citoyens restants remplissent les
|
||||
|
||||
Reference in New Issue
Block a user