Checkpoint: Pagination serveur des établissements et des messages, tri serveur, navigation utilisateur, limitation de débit sur connexion locale, messages et demandes, avec 46 tests Vitest, typage TypeScript et build validés.
This commit is contained in:
@@ -66,6 +66,16 @@ import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
||||
import { notifyOwner } from "./_core/notification";
|
||||
import {
|
||||
CHANNEL_MESSAGE_RULE,
|
||||
CONTACT_REQUEST_RULE,
|
||||
getClientIp,
|
||||
INTRODUCTION_REQUEST_RULE,
|
||||
LOGIN_IDENTIFIER_RULE,
|
||||
LOGIN_IP_RULE,
|
||||
rateLimiter,
|
||||
type RateLimitRule,
|
||||
} from "./rateLimit";
|
||||
import { getDb } from "./db";
|
||||
import { etablissements } from "../drizzle/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -106,19 +116,35 @@ const gestionnaireWriteProcedure = gestionnaireProcedure.use(({ ctx, next }) =>
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
/** Retourne un 429 cohérent, sans exposer l'état interne du limiteur. */
|
||||
function enforceRateLimit(rule: RateLimitRule, key: string, message: string) {
|
||||
const result = rateLimiter.consume(rule, key);
|
||||
if (!result.allowed) {
|
||||
const retryAfterMinutes = Math.max(1, Math.ceil(result.retryAfterMs / 60_000));
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `${message} Réessayez dans environ ${retryAfterMinutes} minute${retryAfterMinutes > 1 ? "s" : ""}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// ─── Canaux de Discussion ────────────────────────────────────────────────────
|
||||
const canauxRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getCanauxForUser(ctx.user.id, isGestionnaire(ctx.user));
|
||||
}),
|
||||
messages: protectedProcedure
|
||||
.input(z.object({ canalId: z.number().int() }))
|
||||
.input(z.object({
|
||||
canalId: z.number().int().positive(),
|
||||
page: z.number().int().positive().default(1),
|
||||
pageSize: z.number().int().min(10).max(100).default(50),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!isGestionnaire(ctx.user)) {
|
||||
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
|
||||
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
return getMessagesCanal(input.canalId);
|
||||
return getMessagesCanal(input.canalId, input.page, input.pageSize);
|
||||
}),
|
||||
membres: protectedProcedure
|
||||
.input(z.object({ canalId: z.number().int() }))
|
||||
@@ -136,6 +162,7 @@ const canauxRouter = router({
|
||||
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
|
||||
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
enforceRateLimit(CHANNEL_MESSAGE_RULE, String(ctx.user.id), "Trop de messages envoyés.");
|
||||
await sendMessageCanal({ canalId: input.canalId, auteurId: ctx.user.id, auteurNom: ctx.user.name ?? "Inconnu", contenu: input.contenu });
|
||||
return { success: true };
|
||||
}),
|
||||
@@ -171,6 +198,7 @@ const miseEnRelationRouter = router({
|
||||
etablissementDemandeurId: z.number().int().positive().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
enforceRateLimit(INTRODUCTION_REQUEST_RULE, String(ctx.user.id), "Trop de demandes de mise en relation ont été envoyées.");
|
||||
const id = await createDemandeMiseEnRelation({
|
||||
demandeurId: ctx.user.id, demandeurNom: ctx.user.name ?? "Inconnu",
|
||||
demandeurEmail: ctx.user.email ?? undefined,
|
||||
@@ -211,10 +239,15 @@ export const appRouter = router({
|
||||
password: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const normalizedIdentifier = input.email.toLocaleLowerCase("fr-FR");
|
||||
const clientIp = getClientIp(ctx.req.headers);
|
||||
enforceRateLimit(LOGIN_IDENTIFIER_RULE, normalizedIdentifier, "Trop de tentatives de connexion pour cet identifiant.");
|
||||
enforceRateLimit(LOGIN_IP_RULE, clientIp, "Trop de tentatives de connexion depuis cette adresse.");
|
||||
const user = await authenticateLocalUser(input.email, input.password);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" });
|
||||
}
|
||||
rateLimiter.reset(LOGIN_IDENTIFIER_RULE, normalizedIdentifier);
|
||||
|
||||
// Créer un token de session avec l'openId de l'utilisateur local
|
||||
// Le champ name doit être non vide pour passer la vérification JWT
|
||||
@@ -360,6 +393,10 @@ export const appRouter = router({
|
||||
typeActivite: z.string().optional(),
|
||||
tailleEffectifs: z.string().optional(),
|
||||
etatDeploiement: z.string().optional(),
|
||||
page: z.number().int().positive().default(1),
|
||||
pageSize: z.number().int().min(10).max(100).default(25),
|
||||
sortBy: z.enum(["nom", "region", "typeActivite", "tailleEffectifs"]).default("nom"),
|
||||
sortDirection: z.enum(["asc", "desc"]).default("asc"),
|
||||
}))
|
||||
.query(({ input, ctx }) => searchEtablissements({
|
||||
...input,
|
||||
@@ -504,6 +541,7 @@ export const appRouter = router({
|
||||
message: z.string().trim().min(1).max(5000),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
enforceRateLimit(CONTACT_REQUEST_RULE, String(ctx.user.id), "Trop de demandes de contact ont été envoyées.");
|
||||
const etab = await getEtablissementById(input.etablissementCibleId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user