feat: clear

This commit is contained in:
2026-07-08 20:27:54 +02:00
parent 4f16b5737f
commit ce1e57c5f0
16 changed files with 6341 additions and 561 deletions
+11 -194
View File
@@ -1,23 +1,12 @@
import crypto from "node:crypto";
import { sendRconCommand } from "./rcon";
export type WhitelistDecision = "accept" | "reject";
export type WhitelistRequest = {
username: string;
discord: string;
reason: string;
};
type ReviewTokenPayload = WhitelistRequest & {
decision: WhitelistDecision;
exp: number;
};
const usernamePattern = /^[A-Za-z0-9_]{3,16}$/;
const maxDiscordLength = 64;
const maxReasonLength = 800;
const reviewTokenTtlMs = 7 * 24 * 60 * 60 * 1000;
export function parseWhitelistRequest(payload: unknown): WhitelistRequest {
if (!payload || typeof payload !== "object") {
@@ -44,215 +33,43 @@ export function parseWhitelistRequest(payload: unknown): WhitelistRequest {
return { username, discord, reason };
}
export function parseWhitelistDecision(payload: unknown): {
username: string;
decision: WhitelistDecision;
} {
if (!payload || typeof payload !== "object") {
throw new Error("Décision invalide.");
}
async function readDiscordError(response: Response) {
const body = await response.text().catch(() => "");
const input = payload as Partial<Record<"username" | "decision", unknown>>;
const username = String(input.username ?? "").trim();
const decision = String(input.decision ?? "").trim();
if (!usernamePattern.test(username)) {
throw new Error("Pseudo Minecraft invalide.");
}
if (decision !== "accept" && decision !== "reject") {
throw new Error("Décision invalide.");
}
return { username, decision };
}
function getReviewSecret() {
const secret = process.env.MINECRAFT_WHITELIST_REVIEW_SECRET;
if (!secret || secret.length < 32) {
throw new Error("Secret de revue whitelist manquant ou trop court.");
}
return secret;
}
function getBaseUrl() {
const baseUrl = process.env.APP_BASE_URL ?? process.env.NEXT_PUBLIC_SITE_URL;
if (!baseUrl) {
throw new Error("APP_BASE_URL est requis pour générer les boutons Discord.");
}
return baseUrl.replace(/\/$/, "");
}
function signTokenPayload(encodedPayload: string) {
return crypto
.createHmac("sha256", getReviewSecret())
.update(encodedPayload)
.digest("base64url");
}
function createReviewToken(request: WhitelistRequest, decision: WhitelistDecision) {
const payload: ReviewTokenPayload = {
...request,
decision,
exp: Date.now() + reviewTokenTtlMs,
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = signTokenPayload(encodedPayload);
return `${encodedPayload}.${signature}`;
}
export function verifyReviewToken(token: string | null) {
if (!token) {
throw new Error("Token manquant.");
}
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) {
throw new Error("Token invalide.");
}
const expectedSignature = signTokenPayload(encodedPayload);
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (
signatureBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
) {
throw new Error("Signature invalide.");
}
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64url").toString("utf8"),
) as ReviewTokenPayload;
if (!usernamePattern.test(payload.username)) {
throw new Error("Pseudo Minecraft invalide.");
}
if (payload.decision !== "accept" && payload.decision !== "reject") {
throw new Error("Décision invalide.");
}
if (payload.exp < Date.now()) {
throw new Error("Token expiré.");
}
return payload;
}
function createReviewUrl(request: WhitelistRequest, decision: WhitelistDecision) {
const url = new URL("/api/minecraft/whitelist/review", getBaseUrl());
url.searchParams.set("token", createReviewToken(request, decision));
return url.toString();
return body
? `Le service de notification a refusé la demande (${response.status}) : ${body.slice(0, 200)}`
: `Le service de notification a refusé la demande (${response.status}).`;
}
export async function sendWhitelistRequestToDiscord(request: WhitelistRequest) {
const webhookUrl = process.env.DISCORD_WHITELIST_WEBHOOK_URL;
if (!webhookUrl) {
throw new Error("Webhook Discord non configuré.");
throw new Error("Service de notification non configuré.");
}
const acceptUrl = createReviewUrl(request, "accept");
const rejectUrl = createReviewUrl(request, "reject");
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: "Nouvelle demande whitelist Minecraft",
content: "Nouvelle demande d'accès Minecraft",
embeds: [
{
title: "Candidature Minecraft",
color: 0x33e879,
fields: [
{ name: "Pseudo", value: request.username, inline: true },
{ name: "Pseudo Minecraft", value: request.username, inline: true },
{ name: "Discord", value: request.discord, inline: true },
{ name: "Motivation", value: request.reason.slice(0, maxReasonLength) },
{ name: "Message", value: request.reason.slice(0, maxReasonLength) },
],
timestamp: new Date().toISOString(),
},
],
components: [
{
type: 1,
components: [
{
type: 2,
style: 5,
label: "Accepter",
url: acceptUrl,
emoji: { name: "✅" },
},
{
type: 2,
style: 5,
label: "Refuser",
url: rejectUrl,
emoji: { name: "❌" },
},
],
},
],
allowed_mentions: { parse: [] },
}),
});
if (!response.ok) {
throw new Error(`Discord a refusé la demande (${response.status}).`);
throw new Error(await readDiscordError(response));
}
}
export async function sendWhitelistDecisionToDiscord(
username: string,
decision: WhitelistDecision,
result?: string,
) {
const webhookUrl = process.env.DISCORD_WHITELIST_WEBHOOK_URL;
if (!webhookUrl) {
return;
}
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content:
decision === "accept"
? `${username} a été ajouté à la whitelist Minecraft.`
: `❌ La demande de ${username} a été refusée.`,
embeds: result
? [
{
title: "Réponse RCON",
description: result || "Commande exécutée.",
color: decision === "accept" ? 0x33e879 : 0xff5c5c,
},
]
: undefined,
}),
});
}
export async function applyWhitelistDecision(
username: string,
decision: WhitelistDecision,
) {
if (decision === "reject") {
await sendWhitelistDecisionToDiscord(username, decision);
return "Demande refusée.";
}
const result = await sendRconCommand(`whitelist add ${username}`);
await sendWhitelistDecisionToDiscord(username, decision, result);
return result || `${username} ajouté à la whitelist.`;
}
-164
View File
@@ -1,164 +0,0 @@
import net from "node:net";
type RconPacket = {
id: number;
type: number;
body: string;
};
const RCON_AUTH = 3;
const RCON_COMMAND = 2;
function getRconConfig() {
const host = process.env.MINECRAFT_RCON_HOST;
const password = process.env.MINECRAFT_RCON_PASSWORD;
const port = Number(process.env.MINECRAFT_RCON_PORT ?? "25575");
const timeoutMs = Number(process.env.MINECRAFT_RCON_TIMEOUT_MS ?? "5000");
if (!host || !password) {
throw new Error("RCON n'est pas configuré.");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Le port RCON est invalide.");
}
return { host, password, port, timeoutMs };
}
function encodePacket(id: number, type: number, body: string) {
const bodyBuffer = Buffer.from(body, "utf8");
const packet = Buffer.alloc(4 + 4 + bodyBuffer.length + 2);
packet.writeInt32LE(id, 0);
packet.writeInt32LE(type, 4);
bodyBuffer.copy(packet, 8);
packet.writeInt8(0, 8 + bodyBuffer.length);
packet.writeInt8(0, 8 + bodyBuffer.length + 1);
const length = Buffer.alloc(4);
length.writeInt32LE(packet.length, 0);
return Buffer.concat([length, packet]);
}
function decodePackets(buffer: Buffer<ArrayBufferLike>) {
const packets: RconPacket[] = [];
let offset = 0;
while (offset + 4 <= buffer.length) {
const length = buffer.readInt32LE(offset);
const packetStart = offset + 4;
const packetEnd = packetStart + length;
if (packetEnd > buffer.length) {
break;
}
const id = buffer.readInt32LE(packetStart);
const type = buffer.readInt32LE(packetStart + 4);
const body = buffer.subarray(packetStart + 8, packetEnd - 2).toString("utf8");
packets.push({ id, type, body });
offset = packetEnd;
}
return {
packets,
rest: buffer.subarray(offset),
};
}
function waitForPacket(
socket: net.Socket,
timeoutMs: number,
isExpected: (packet: RconPacket) => boolean,
) {
return new Promise<RconPacket>((resolve, reject) => {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const cleanup = () => {
socket.off("data", onData);
socket.off("error", onError);
clearTimeout(timer);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onData = (chunk: Buffer<ArrayBufferLike>) => {
buffer = Buffer.concat([buffer, chunk]);
const decoded = decodePackets(buffer);
buffer = decoded.rest;
const packet = decoded.packets.find(isExpected);
if (packet) {
cleanup();
resolve(packet);
}
};
const timer = setTimeout(() => {
cleanup();
reject(new Error("Timeout RCON."));
}, timeoutMs);
socket.on("data", onData);
socket.on("error", onError);
});
}
function connectRcon(host: string, port: number, timeoutMs: number) {
return new Promise<net.Socket>((resolve, reject) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error("Connexion RCON expirée."));
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
resolve(socket);
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
export async function sendRconCommand(command: string) {
const { host, password, port, timeoutMs } = getRconConfig();
const socket = await connectRcon(host, port, timeoutMs);
try {
const authId = 1;
const commandId = 2;
socket.write(encodePacket(authId, RCON_AUTH, password));
const authPacket = await waitForPacket(
socket,
timeoutMs,
(packet) => packet.id === authId || packet.id === -1,
);
if (authPacket.id === -1) {
throw new Error("Authentification RCON refusée.");
}
socket.write(encodePacket(commandId, RCON_COMMAND, command));
const responsePacket = await waitForPacket(
socket,
timeoutMs,
(packet) => packet.id === commandId,
);
return responsePacket.body.trim();
} finally {
socket.end();
socket.destroy();
}
}