Files
mountycrawl/server.js
T

200 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires.
* Node pur, aucune dépendance. Stockage : un fichier JSON (LEVELS_FILE). */
"use strict";
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const PORT = process.env.PORT || 80;
const ROOT = __dirname;
const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json";
const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large
const MAX_LEVELS = 500;
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
};
/* ---------- Stockage ---------- */
function loadLevels() {
try { return JSON.parse(fs.readFileSync(LEVELS_FILE, "utf8")); }
catch { return []; }
}
function saveLevels(levels) {
fs.mkdirSync(path.dirname(LEVELS_FILE), { recursive: true });
const tmp = LEVELS_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(levels));
fs.renameSync(tmp, LEVELS_FILE);
}
/* ---------- Validation d'un niveau ---------- */
const MAP_W = 28, MAP_H = 20;
function validateLevel(l) {
if (!l || typeof l !== "object") return "niveau invalide";
if (typeof l.name !== "string" || !l.name.trim() || l.name.length > 40) return "nom invalide (140 caractères)";
if (typeof l.author !== "string" || !l.author.trim() || l.author.length > 30) return "auteur invalide (130 caractères)";
if (!Array.isArray(l.grid) || l.grid.length !== MAP_H) return "grille invalide";
for (const row of l.grid) {
if (typeof row !== "string" || row.length !== MAP_W || /[^#.>]/.test(row)) return "ligne de grille invalide";
}
if (!l.start || !inBounds(l.start) || tile(l, l.start) === "#") return "point de départ manquant ou dans un mur";
if (!Array.isArray(l.monsters) || l.monsters.length > 60) return "monstres invalides (max 60)";
for (const m of l.monsters) {
if (!inBounds(m) || tile(l, m) === "#") return "monstre hors-sol";
if (m.boss !== true && !(Number.isInteger(m.type) && m.type >= 0 && Number.isInteger(m.tpl) && m.tpl >= 0)) return "type de monstre invalide";
}
if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)";
for (const i of l.items) {
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol";
if (!["potion", "scroll", "gear", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide";
}
if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides";
const doors = l.doors || [];
if (doors.length > 10) return "portes invalides (max 10)";
for (const d of doors) {
if (!inBounds(d) || tile(l, d) === "#") return "porte hors-sol";
if (typeof d.target !== "string" || !/^[a-f0-9]{12}$/.test(d.target)) return "cible de porte invalide";
}
const hasExit = l.grid.some(row => row.includes(">"));
if (l.monsters.length === 0 && doors.length === 0 && !hasExit)
return "place au moins un monstre, une porte ou une sortie, sinon pas de défi !";
return null;
}
const inBounds = p => p && Number.isInteger(p.x) && Number.isInteger(p.y) && p.x >= 0 && p.x < MAP_W && p.y >= 0 && p.y < MAP_H;
const tile = (l, p) => l.grid[p.y][p.x];
/* ---------- API ---------- */
function sendJSON(res, code, data) {
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
/* Le secret d'auteur (renvoyé à la publication, gardé côté client) ne sort jamais en GET. */
function publicLevel(l) {
const { secret, ...pub } = l;
return pub;
}
function cleanLevel(level) {
return {
name: level.name.trim(), author: level.author.trim(),
grid: level.grid, start: { x: level.start.x, y: level.start.y },
monsters: level.monsters, items: level.items, doors: level.doors || [],
};
}
function readBody(req, res, cb) {
let body = "";
req.on("data", chunk => {
body += chunk;
if (body.length > MAX_BODY) { sendJSON(res, 413, { error: "niveau trop gros" }); req.destroy(); }
});
req.on("end", () => {
let data;
try { data = JSON.parse(body); } catch { return sendJSON(res, 400, { error: "JSON invalide" }); }
cb(data);
});
}
function handleAPI(req, res, url) {
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
if (req.method === "GET" && url.pathname === "/api/levels") {
const list = loadLevels().map(l => ({
id: l.id, name: l.name, author: l.author, date: l.date,
monsters: l.monsters.length, doors: (l.doors || []).length, plays: l.plays || 0,
}));
return sendJSON(res, 200, list);
}
if (req.method === "GET" && idMatch) {
const levels = loadLevels();
const level = levels.find(l => l.id === idMatch[1]);
if (!level) return sendJSON(res, 404, { error: "niveau introuvable" });
level.plays = (level.plays || 0) + 1;
saveLevels(levels);
return sendJSON(res, 200, publicLevel(level));
}
if (req.method === "POST" && url.pathname === "/api/levels") {
return readBody(req, res, level => {
const err = validateLevel(level);
if (err) return sendJSON(res, 400, { error: err });
const levels = loadLevels();
if (levels.length >= MAX_LEVELS) return sendJSON(res, 507, { error: "trop de niveaux stockés" });
const record = {
id: crypto.randomBytes(6).toString("hex"),
secret: crypto.randomBytes(16).toString("hex"),
date: new Date().toISOString().slice(0, 10),
plays: 0,
...cleanLevel(level),
};
levels.push(record);
saveLevels(levels);
return sendJSON(res, 201, { id: record.id, secret: record.secret });
});
}
if (req.method === "PUT" && idMatch) {
return readBody(req, res, level => {
const levels = loadLevels();
const existing = levels.find(l => l.id === idMatch[1]);
if (!existing) return sendJSON(res, 404, { error: "niveau introuvable" });
const provided = req.headers["x-level-secret"];
if (!existing.secret || !provided || provided !== existing.secret)
return sendJSON(res, 403, { error: "clé d'auteur invalide : tu ne peux modifier que tes propres niveaux" });
const err = validateLevel(level);
if (err) return sendJSON(res, 400, { error: err });
Object.assign(existing, cleanLevel(level), { updated: new Date().toISOString().slice(0, 10) });
saveLevels(levels);
return sendJSON(res, 200, { id: existing.id });
});
}
sendJSON(res, 404, { error: "route inconnue" });
}
/* ---------- Statique ---------- */
function handleStatic(req, res, url) {
let p = decodeURIComponent(url.pathname);
if (p === "/") p = "/index.html";
const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT) || file === path.join(ROOT, "server.js")) {
res.writeHead(403); return res.end("interdit");
}
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); return res.end("introuvable"); }
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
res.end(data);
});
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname.startsWith("/api/")) return handleAPI(req, res, url);
if (req.method !== "GET") { res.writeHead(405); return res.end(); }
handleStatic(req, res, url);
});
if (require.main === module) {
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}`));
}
module.exports = { validateLevel, MAP_W, MAP_H };