spb/uqo-imm1033
Public
JavaScript 68%
CSS 32%
1#!/usr/bin/env node2// server.mjs — serveur statique zéro dépendance pour les sites de cours UQO.3// Usage : node server.mjs <port> <dossier-dist>4import http from "node:http";5import fs from "node:fs";6import path from "node:path";7import zlib from "node:zlib";8import { pipeline } from "node:stream";910const PORT = Number(process.argv[2] || process.env.PORT || 8160);11const ROOT = path.resolve(process.argv[3] || process.env.DIST || path.join(path.dirname(new URL(import.meta.url).pathname), "..", "dist"));12const STARTED = new Date().toISOString();1314const MIME = {15 ".html": "text/html; charset=utf-8",16 ".css": "text/css; charset=utf-8",17 ".js": "application/javascript; charset=utf-8",18 ".mjs": "application/javascript; charset=utf-8",19 ".json": "application/json; charset=utf-8",20 ".svg": "image/svg+xml",21 ".png": "image/png",22 ".jpg": "image/jpeg",23 ".jpeg": "image/jpeg",24 ".webp": "image/webp",25 ".ico": "image/x-icon",26 ".pdf": "application/pdf",27 ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",28 ".woff": "font/woff",29 ".woff2": "font/woff2",30 ".ttf": "font/ttf",31 ".txt": "text/plain; charset=utf-8",32 ".xml": "application/xml; charset=utf-8",33 ".webmanifest": "application/manifest+json",34 ".mp4": "video/mp4",35 ".m4a": "audio/mp4",36 ".m4v": "video/mp4",37 ".webm": "video/webm",38 ".vtt": "text/vtt; charset=utf-8",39 ".srt": "application/x-subrip; charset=utf-8",40 ".avif": "image/avif",41};42const COMPRESSIBLE = new Set([".html", ".css", ".js", ".mjs", ".json", ".svg", ".txt", ".xml", ".webmanifest", ".vtt", ".srt"]);4344function safeJoin(root, urlPath) {45 const decoded = decodeURIComponent(urlPath.split("?")[0]);46 const p = path.normalize(decoded).replace(/^(\.\.[/\\])+/, "");47 const full = path.join(root, p);48 if (!full.startsWith(root)) return null;49 return full;50}5152function send(res, status, body, headers = {}) {53 res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", ...headers });54 res.end(body);55}5657function serveFile(req, res, file, stat) {58 const ext = path.extname(file).toLowerCase();59 const type = MIME[ext] || "application/octet-stream";60 const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;61 if (req.headers["if-none-match"] === etag) {62 res.writeHead(304, { ETag: etag });63 return res.end();64 }65 const isHtml = ext === ".html";66 const cache = isHtml || ext === ".json"67 ? "no-cache"68 : /\/assets\/|\/files\/|\/videos\/.*\.(mp4|m4a|webp|vtt)$/.test(file) ? "public, max-age=86400, stale-while-revalidate=604800" : "public, max-age=3600";69 const headers = {70 "Content-Type": type,71 ETag: etag,72 "Cache-Control": cache,73 "X-Content-Type-Options": "nosniff",74 "Referrer-Policy": "strict-origin-when-cross-origin",75 "Accept-Ranges": "bytes",76 };77 if (isHtml) headers["Content-Security-Policy"] = "frame-ancestors 'self'";78 if (/\/files\//.test(file)) headers["Access-Control-Allow-Origin"] = "*"; // téléchargements réutilisables (ex. dépôt Moodle)79 const ae = String(req.headers["accept-encoding"] || "");80 const canGzip = COMPRESSIBLE.has(ext) && stat.size > 1024 && !req.headers.range;81 if (canGzip && /\bbr\b/.test(ae)) {82 headers["Content-Encoding"] = "br";83 headers["Vary"] = "Accept-Encoding";84 res.writeHead(200, headers);85 if (req.method === "HEAD") return res.end();86 return pipeline(fs.createReadStream(file), zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }), res, () => {});87 }88 if (canGzip && /\bgzip\b/.test(ae)) {89 headers["Content-Encoding"] = "gzip";90 headers["Vary"] = "Accept-Encoding";91 res.writeHead(200, headers);92 if (req.method === "HEAD") return res.end();93 return pipeline(fs.createReadStream(file), zlib.createGzip({ level: 6 }), res, () => {});94 }95 // plages (PDF)96 const range = req.headers.range;97 if (range) {98 const m = /bytes=(\d*)-(\d*)/.exec(range);99 if (m) {100 const start = m[1] ? Number(m[1]) : 0;101 const end = m[2] ? Math.min(Number(m[2]), stat.size - 1) : stat.size - 1;102 if (start <= end && start < stat.size) {103 res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${stat.size}`, "Content-Length": end - start + 1 });104 if (req.method === "HEAD") return res.end();105 return pipeline(fs.createReadStream(file, { start, end }), res, () => {});106 }107 res.writeHead(416, { "Content-Range": `bytes */${stat.size}` });108 return res.end();109 }110 }111 headers["Content-Length"] = stat.size;112 res.writeHead(200, headers);113 if (req.method === "HEAD") return res.end();114 pipeline(fs.createReadStream(file), res, () => {});115}116117function notFound(req, res) {118 const nf = path.join(ROOT, "404.html");119 if (fs.existsSync(nf)) {120 const body = fs.readFileSync(nf);121 res.writeHead(404, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });122 return res.end(body);123 }124 send(res, 404, "404 — page introuvable");125}126127const server = http.createServer((req, res) => {128 if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "Méthode non permise", { Allow: "GET, HEAD" });129 const url = req.url || "/";130 if (url.startsWith("/api/health")) {131 return send(res, 200, JSON.stringify({ ok: true, started: STARTED, root: ROOT, uptime: process.uptime() }), { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });132 }133 let file = safeJoin(ROOT, url);134 if (!file) return send(res, 400, "Requête invalide");135 try {136 let stat = fs.existsSync(file) ? fs.statSync(file) : null;137 if (stat && stat.isDirectory()) {138 if (!url.split("?")[0].endsWith("/")) {139 res.writeHead(301, { Location: url.split("?")[0] + "/" + (url.includes("?") ? "?" + url.split("?")[1] : "") });140 return res.end();141 }142 file = path.join(file, "index.html");143 stat = fs.existsSync(file) ? fs.statSync(file) : null;144 }145 if (!stat && !path.extname(file)) {146 // URL propre sans slash : /glossaire → /glossaire/index.html147 const alt = path.join(file, "index.html");148 if (fs.existsSync(alt)) {149 res.writeHead(301, { Location: url.split("?")[0] + "/" });150 return res.end();151 }152 const alt2 = file + ".html";153 if (fs.existsSync(alt2)) {154 file = alt2;155 stat = fs.statSync(file);156 }157 }158 if (!stat || !stat.isFile()) return notFound(req, res);159 serveFile(req, res, file, stat);160 } catch (e) {161 console.error(e);162 send(res, 500, "Erreur serveur");163 }164});165166server.listen(PORT, "0.0.0.0", () => {167 console.log(`[uqo-cours] port ${PORT} · racine ${ROOT}`);168});169