feat: discord message
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
APP_BASE_URL="https://leonmorival.xyz"
|
||||||
|
|
||||||
|
# Discord webhook du salon où arrivent les candidatures Minecraft.
|
||||||
|
DISCORD_WHITELIST_WEBHOOK_URL=""
|
||||||
|
|
||||||
|
# Secret HMAC utilisé pour signer les liens Accepter/Refuser dans Discord.
|
||||||
|
# Minimum 32 caractères.
|
||||||
|
MINECRAFT_WHITELIST_REVIEW_SECRET=""
|
||||||
|
|
||||||
|
# Token Bearer optionnel pour un vrai bot Discord qui appelle
|
||||||
|
# POST /api/minecraft/whitelist/decision.
|
||||||
|
# Minimum 32 caractères.
|
||||||
|
MINECRAFT_WHITELIST_ADMIN_TOKEN=""
|
||||||
|
|
||||||
|
# RCON Minecraft. Garde ce port privé côté réseau si possible.
|
||||||
|
MINECRAFT_RCON_HOST="127.0.0.1"
|
||||||
|
MINECRAFT_RCON_PORT="25575"
|
||||||
|
MINECRAFT_RCON_PASSWORD=""
|
||||||
|
MINECRAFT_RCON_TIMEOUT_MS="5000"
|
||||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Minecraft whitelist
|
||||||
|
|
||||||
|
La page Minecraft contient un formulaire de candidature. Une demande valide est envoyée vers Discord avec deux boutons-lien signés :
|
||||||
|
|
||||||
|
- `Accepter` appelle `/api/minecraft/whitelist/review` et exécute `whitelist add <pseudo>` via RCON.
|
||||||
|
- `Refuser` marque la demande comme refusée et notifie Discord.
|
||||||
|
|
||||||
|
Un vrai bot Discord peut aussi appeler `POST /api/minecraft/whitelist/decision` avec `Authorization: Bearer <MINECRAFT_WHITELIST_ADMIN_TOKEN>` et un JSON :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "Leon",
|
||||||
|
"decision": "accept"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables nécessaires :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_BASE_URL="https://leonmorival.xyz"
|
||||||
|
DISCORD_WHITELIST_WEBHOOK_URL="https://discord.com/api/webhooks/..."
|
||||||
|
MINECRAFT_WHITELIST_REVIEW_SECRET="une-valeur-aleatoire-longue"
|
||||||
|
MINECRAFT_WHITELIST_ADMIN_TOKEN="une-autre-valeur-aleatoire-longue"
|
||||||
|
MINECRAFT_RCON_HOST="127.0.0.1"
|
||||||
|
MINECRAFT_RCON_PORT="25575"
|
||||||
|
MINECRAFT_RCON_PASSWORD="mot-de-passe-rcon"
|
||||||
|
```
|
||||||
|
|
||||||
|
Active RCON côté serveur Minecraft, idéalement sans exposer publiquement le port `25575`.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
First, run the development server:
|
First, run the development server:
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
applyWhitelistDecision,
|
||||||
|
parseWhitelistDecision,
|
||||||
|
} from "@/app/lib/minecraft-whitelist";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function assertAuthorized(request: Request) {
|
||||||
|
const token = process.env.MINECRAFT_WHITELIST_ADMIN_TOKEN;
|
||||||
|
const authorization = request.headers.get("authorization");
|
||||||
|
|
||||||
|
if (!token || token.length < 32) {
|
||||||
|
throw new Error("Token admin whitelist non configuré.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorization !== `Bearer ${token}`) {
|
||||||
|
throw new Error("Non autorisé.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
assertAuthorized(request);
|
||||||
|
|
||||||
|
const payload = await request.json();
|
||||||
|
const { username, decision } = parseWhitelistDecision(payload);
|
||||||
|
const result = await applyWhitelistDecision(username, decision);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Décision impossible.";
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
{ status: message === "Non autorisé." ? 401 : 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
parseWhitelistRequest,
|
||||||
|
sendWhitelistRequestToDiscord,
|
||||||
|
} from "@/app/lib/minecraft-whitelist";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await request.json();
|
||||||
|
const whitelistRequest = parseWhitelistRequest(payload);
|
||||||
|
|
||||||
|
await sendWhitelistRequestToDiscord(whitelistRequest);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
message: "Demande envoyée sur Discord.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Impossible d'envoyer la demande.";
|
||||||
|
const status = message.includes("configuré") ||
|
||||||
|
message.includes("APP_BASE_URL") ||
|
||||||
|
message.includes("Secret de revue")
|
||||||
|
? 503
|
||||||
|
: 400;
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
applyWhitelistDecision,
|
||||||
|
verifyReviewToken,
|
||||||
|
} from "@/app/lib/minecraft-whitelist";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlResponse(title: string, body: string, status = 200) {
|
||||||
|
return new Response(
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #090a0f;
|
||||||
|
color: #f2efe3;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(640px, calc(100% - 32px));
|
||||||
|
border: 4px solid #030303;
|
||||||
|
background: #252932;
|
||||||
|
box-shadow: 6px 6px 0 rgb(0 0 0 / 55%);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #ffd54a;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
<p>${escapeHtml(body)}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"X-Robots-Tag": "noindex, nofollow",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const review = verifyReviewToken(url.searchParams.get("token"));
|
||||||
|
const result = await applyWhitelistDecision(review.username, review.decision);
|
||||||
|
|
||||||
|
return htmlResponse(
|
||||||
|
review.decision === "accept" ? "Demande acceptée" : "Demande refusée",
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Action impossible.";
|
||||||
|
|
||||||
|
return htmlResponse("Action impossible", message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
type FormStatus = {
|
||||||
|
tone: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export default function MinecraftWhitelistForm() {
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [status, setStatus] = useState<FormStatus>(null);
|
||||||
|
|
||||||
|
async function submitRequest(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setStatus(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const payload = {
|
||||||
|
username: String(formData.get("username") ?? ""),
|
||||||
|
discord: String(formData.get("discord") ?? ""),
|
||||||
|
reason: String(formData.get("reason") ?? ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/minecraft/whitelist/request", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
ok?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !result.ok) {
|
||||||
|
throw new Error(result.message ?? "Demande refusée.");
|
||||||
|
}
|
||||||
|
|
||||||
|
event.currentTarget.reset();
|
||||||
|
setStatus({
|
||||||
|
tone: "success",
|
||||||
|
message: result.message ?? "Demande envoyée.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({
|
||||||
|
tone: "error",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Impossible d'envoyer la demande.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="whitelist-form block-panel" onSubmit={submitRequest}>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="minecraft-username">Pseudo Minecraft</label>
|
||||||
|
<input
|
||||||
|
id="minecraft-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={16}
|
||||||
|
pattern="[A-Za-z0-9_]{3,16}"
|
||||||
|
placeholder="Leon"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="minecraft-discord">Discord</label>
|
||||||
|
<input
|
||||||
|
id="minecraft-discord"
|
||||||
|
name="discord"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="leon"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-field form-field--full">
|
||||||
|
<label htmlFor="minecraft-reason">Message</label>
|
||||||
|
<textarea
|
||||||
|
id="minecraft-reason"
|
||||||
|
name="reason"
|
||||||
|
minLength={10}
|
||||||
|
maxLength={800}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Je voudrais rejoindre le serveur..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="whitelist-submit" type="submit" disabled={submitting}>
|
||||||
|
<span>{submitting ? "Envoi..." : "Envoyer la demande"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status ? (
|
||||||
|
<p className={`whitelist-message whitelist-message--${status.tone}`} role="status">
|
||||||
|
{status.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+114
-2
@@ -836,6 +836,112 @@ h2.pixel-heading {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whitelist-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: var(--font-pixel), var(--font-geist-mono), monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 4px solid #030303;
|
||||||
|
background: rgb(8 10 15 / 90%);
|
||||||
|
box-shadow:
|
||||||
|
inset -3px -3px 0 rgb(0 0 0 / 32%),
|
||||||
|
inset 3px 3px 0 rgb(255 255 255 / 8%);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field textarea {
|
||||||
|
min-height: 148px;
|
||||||
|
padding: 16px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input::placeholder,
|
||||||
|
.form-field textarea::placeholder {
|
||||||
|
color: rgb(242 239 227 / 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelist-submit {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 0 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 4px solid #030303;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow:
|
||||||
|
inset -3px -3px 0 rgb(0 0 0 / 32%),
|
||||||
|
inset 3px 3px 0 rgb(255 255 255 / 22%),
|
||||||
|
5px 5px 0 rgb(0 0 0 / 48%);
|
||||||
|
color: #041411;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-pixel), var(--font-geist-mono), monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelist-submit:hover:not(:disabled) {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow:
|
||||||
|
inset -3px -3px 0 rgb(0 0 0 / 32%),
|
||||||
|
inset 3px 3px 0 rgb(255 255 255 / 22%),
|
||||||
|
8px 8px 0 rgb(0 0 0 / 56%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelist-submit:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelist-message {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
align-items: center;
|
||||||
|
border: 4px solid #030303;
|
||||||
|
background: rgb(8 10 15 / 82%);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelist-message--success {
|
||||||
|
color: #7aff5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelist-message--error {
|
||||||
|
color: #ffce86;
|
||||||
|
}
|
||||||
|
|
||||||
.steps-grid {
|
.steps-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -981,7 +1087,7 @@ h2.pixel-heading {
|
|||||||
|
|
||||||
.minecraft-nav-links {
|
.minecraft-nav-links {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.minecraft-nav-links a {
|
.minecraft-nav-links a {
|
||||||
@@ -1121,10 +1227,16 @@ h2.pixel-heading {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-panel,
|
.status-panel,
|
||||||
.steps-grid {
|
.steps-grid,
|
||||||
|
.whitelist-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whitelist-submit,
|
||||||
|
.whitelist-message {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.status-item,
|
.status-item,
|
||||||
.status-item:nth-child(2),
|
.status-item:nth-child(2),
|
||||||
.status-item:nth-child(-n + 2) {
|
.status-item:nth-child(-n + 2) {
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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") {
|
||||||
|
throw new Error("Demande invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = payload as Partial<Record<keyof WhitelistRequest, unknown>>;
|
||||||
|
const username = String(input.username ?? "").trim();
|
||||||
|
const discord = String(input.discord ?? "").trim();
|
||||||
|
const reason = String(input.reason ?? "").trim();
|
||||||
|
|
||||||
|
if (!usernamePattern.test(username)) {
|
||||||
|
throw new Error("Pseudo Minecraft invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discord.length < 2 || discord.length > maxDiscordLength) {
|
||||||
|
throw new Error("Identifiant Discord invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.length < 10 || reason.length > maxReasonLength) {
|
||||||
|
throw new Error("Message trop court ou trop long.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { username, discord, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWhitelistDecision(payload: unknown): {
|
||||||
|
username: string;
|
||||||
|
decision: WhitelistDecision;
|
||||||
|
} {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
throw new Error("Décision invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWhitelistRequestToDiscord(request: WhitelistRequest) {
|
||||||
|
const webhookUrl = process.env.DISCORD_WHITELIST_WEBHOOK_URL;
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
throw new Error("Webhook Discord 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",
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: "Candidature Minecraft",
|
||||||
|
color: 0x33e879,
|
||||||
|
fields: [
|
||||||
|
{ name: "Pseudo", value: request.username, inline: true },
|
||||||
|
{ name: "Discord", value: request.discord, inline: true },
|
||||||
|
{ name: "Motivation", 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: "❌" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Discord a refusé la demande (${response.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -0,0 +1,164 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import CopyAddressButton from "../components/CopyAddressButton";
|
import CopyAddressButton from "../components/CopyAddressButton";
|
||||||
import MinecraftStatus from "../components/MinecraftStatus";
|
import MinecraftStatus from "../components/MinecraftStatus";
|
||||||
|
import MinecraftWhitelistForm from "../components/MinecraftWhitelistForm";
|
||||||
import { minecraftServer } from "../lib/server-config";
|
import { minecraftServer } from "../lib/server-config";
|
||||||
|
|
||||||
const joinSteps = [
|
const joinSteps = [
|
||||||
@@ -38,6 +39,7 @@ export default function MinecraftPage() {
|
|||||||
<div className="minecraft-nav-links">
|
<div className="minecraft-nav-links">
|
||||||
<Link href="/">Accueil</Link>
|
<Link href="/">Accueil</Link>
|
||||||
<a href="#statut">Statut</a>
|
<a href="#statut">Statut</a>
|
||||||
|
<a href="#acces">Accès</a>
|
||||||
<a href="#rejoindre">Rejoindre</a>
|
<a href="#rejoindre">Rejoindre</a>
|
||||||
<a href={minecraftServer.mapUrl} target="_blank" rel="noopener noreferrer">
|
<a href={minecraftServer.mapUrl} target="_blank" rel="noopener noreferrer">
|
||||||
Carte
|
Carte
|
||||||
@@ -81,6 +83,15 @@ export default function MinecraftPage() {
|
|||||||
<div className="minecraft-content">
|
<div className="minecraft-content">
|
||||||
<MinecraftStatus />
|
<MinecraftStatus />
|
||||||
|
|
||||||
|
<section id="acces" className="minecraft-section whitelist-section">
|
||||||
|
<div className="section-heading pixel-heading">
|
||||||
|
<h2>Demande d'accès</h2>
|
||||||
|
<p>La candidature est envoyée sur Discord pour validation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MinecraftWhitelistForm />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="rejoindre" className="minecraft-section">
|
<section id="rejoindre" className="minecraft-section">
|
||||||
<div className="section-heading pixel-heading">
|
<div className="section-heading pixel-heading">
|
||||||
<h2>Comment rejoindre</h2>
|
<h2>Comment rejoindre</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user