diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..86624e2 --- /dev/null +++ b/.env.example @@ -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" diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index e215bc4..2b3accd 100644 --- a/README.md +++ b/README.md @@ -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). +## 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 ` 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 ` 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 First, run the development server: diff --git a/app/api/minecraft/whitelist/decision/route.ts b/app/api/minecraft/whitelist/decision/route.ts new file mode 100644 index 0000000..692e34c --- /dev/null +++ b/app/api/minecraft/whitelist/decision/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/minecraft/whitelist/request/route.ts b/app/api/minecraft/whitelist/request/route.ts new file mode 100644 index 0000000..3090d62 --- /dev/null +++ b/app/api/minecraft/whitelist/request/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/minecraft/whitelist/review/route.ts b/app/api/minecraft/whitelist/review/route.ts new file mode 100644 index 0000000..a6fb6f4 --- /dev/null +++ b/app/api/minecraft/whitelist/review/route.ts @@ -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( + ` + + + + + + ${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+

${escapeHtml(body)}

+
+ +`, + { + 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); + } +} diff --git a/app/components/MinecraftWhitelistForm.tsx b/app/components/MinecraftWhitelistForm.tsx new file mode 100644 index 0000000..c85b4e2 --- /dev/null +++ b/app/components/MinecraftWhitelistForm.tsx @@ -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(null); + + async function submitRequest(event: FormEvent) { + 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 ( +
+
+ + +
+ +
+ + +
+ +
+ +