#!/usr/bin/env node // server.mjs — serveur statique zéro dépendance pour les sites de cours UQO. // Usage : node server.mjs import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; import { pipeline } from "node:stream"; const PORT = Number(process.argv[2] || process.env.PORT || 8160); const ROOT = path.resolve(process.argv[3] || process.env.DIST || path.join(path.dirname(new URL(import.meta.url).pathname), "..", "dist")); const STARTED = new Date().toISOString(); const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon", ".pdf": "application/pdf", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml; charset=utf-8", ".webmanifest": "application/manifest+json", ".mp4": "video/mp4", ".m4a": "audio/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".vtt": "text/vtt; charset=utf-8", ".srt": "application/x-subrip; charset=utf-8", ".avif": "image/avif", }; const COMPRESSIBLE = new Set([".html", ".css", ".js", ".mjs", ".json", ".svg", ".txt", ".xml", ".webmanifest", ".vtt", ".srt"]); function safeJoin(root, urlPath) { const decoded = decodeURIComponent(urlPath.split("?")[0]); const p = path.normalize(decoded).replace(/^(\.\.[/\\])+/, ""); const full = path.join(root, p); if (!full.startsWith(root)) return null; return full; } function send(res, status, body, headers = {}) { res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", ...headers }); res.end(body); } function serveFile(req, res, file, stat) { const ext = path.extname(file).toLowerCase(); const type = MIME[ext] || "application/octet-stream"; const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`; if (req.headers["if-none-match"] === etag) { res.writeHead(304, { ETag: etag }); return res.end(); } const isHtml = ext === ".html"; const cache = isHtml || ext === ".json" ? "no-cache" : /\/assets\/|\/files\/|\/videos\/.*\.(mp4|m4a|webp|vtt)$/.test(file) ? "public, max-age=86400, stale-while-revalidate=604800" : "public, max-age=3600"; const headers = { "Content-Type": type, ETag: etag, "Cache-Control": cache, "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "Accept-Ranges": "bytes", }; if (isHtml) headers["Content-Security-Policy"] = "frame-ancestors 'self'"; if (/\/files\//.test(file)) headers["Access-Control-Allow-Origin"] = "*"; // téléchargements réutilisables (ex. dépôt Moodle) const ae = String(req.headers["accept-encoding"] || ""); const canGzip = COMPRESSIBLE.has(ext) && stat.size > 1024 && !req.headers.range; if (canGzip && /\bbr\b/.test(ae)) { headers["Content-Encoding"] = "br"; headers["Vary"] = "Accept-Encoding"; res.writeHead(200, headers); if (req.method === "HEAD") return res.end(); return pipeline(fs.createReadStream(file), zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }), res, () => {}); } if (canGzip && /\bgzip\b/.test(ae)) { headers["Content-Encoding"] = "gzip"; headers["Vary"] = "Accept-Encoding"; res.writeHead(200, headers); if (req.method === "HEAD") return res.end(); return pipeline(fs.createReadStream(file), zlib.createGzip({ level: 6 }), res, () => {}); } // plages (PDF) const range = req.headers.range; if (range) { const m = /bytes=(\d*)-(\d*)/.exec(range); if (m) { const start = m[1] ? Number(m[1]) : 0; const end = m[2] ? Math.min(Number(m[2]), stat.size - 1) : stat.size - 1; if (start <= end && start < stat.size) { res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${stat.size}`, "Content-Length": end - start + 1 }); if (req.method === "HEAD") return res.end(); return pipeline(fs.createReadStream(file, { start, end }), res, () => {}); } res.writeHead(416, { "Content-Range": `bytes */${stat.size}` }); return res.end(); } } headers["Content-Length"] = stat.size; res.writeHead(200, headers); if (req.method === "HEAD") return res.end(); pipeline(fs.createReadStream(file), res, () => {}); } function notFound(req, res) { const nf = path.join(ROOT, "404.html"); if (fs.existsSync(nf)) { const body = fs.readFileSync(nf); res.writeHead(404, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" }); return res.end(body); } send(res, 404, "404 — page introuvable"); } const server = http.createServer((req, res) => { if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "Méthode non permise", { Allow: "GET, HEAD" }); const url = req.url || "/"; if (url.startsWith("/api/health")) { 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" }); } let file = safeJoin(ROOT, url); if (!file) return send(res, 400, "Requête invalide"); try { let stat = fs.existsSync(file) ? fs.statSync(file) : null; if (stat && stat.isDirectory()) { if (!url.split("?")[0].endsWith("/")) { res.writeHead(301, { Location: url.split("?")[0] + "/" + (url.includes("?") ? "?" + url.split("?")[1] : "") }); return res.end(); } file = path.join(file, "index.html"); stat = fs.existsSync(file) ? fs.statSync(file) : null; } if (!stat && !path.extname(file)) { // URL propre sans slash : /glossaire → /glossaire/index.html const alt = path.join(file, "index.html"); if (fs.existsSync(alt)) { res.writeHead(301, { Location: url.split("?")[0] + "/" }); return res.end(); } const alt2 = file + ".html"; if (fs.existsSync(alt2)) { file = alt2; stat = fs.statSync(file); } } if (!stat || !stat.isFile()) return notFound(req, res); serveFile(req, res, file, stat); } catch (e) { console.error(e); send(res, 500, "Erreur serveur"); } }); server.listen(PORT, "0.0.0.0", () => { console.log(`[uqo-cours] port ${PORT} · racine ${ROOT}`); });