SPB Git

spb/uqo-imm1003

Public
6.4 KB · 168 lines javascript
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  const ae = String(req.headers["accept-encoding"] || "");79  const canGzip = COMPRESSIBLE.has(ext) && stat.size > 1024 && !req.headers.range;80  if (canGzip && /\bbr\b/.test(ae)) {81    headers["Content-Encoding"] = "br";82    headers["Vary"] = "Accept-Encoding";83    res.writeHead(200, headers);84    if (req.method === "HEAD") return res.end();85    return pipeline(fs.createReadStream(file), zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }), res, () => {});86  }87  if (canGzip && /\bgzip\b/.test(ae)) {88    headers["Content-Encoding"] = "gzip";89    headers["Vary"] = "Accept-Encoding";90    res.writeHead(200, headers);91    if (req.method === "HEAD") return res.end();92    return pipeline(fs.createReadStream(file), zlib.createGzip({ level: 6 }), res, () => {});93  }94  // plages (PDF)95  const range = req.headers.range;96  if (range) {97    const m = /bytes=(\d*)-(\d*)/.exec(range);98    if (m) {99      const start = m[1] ? Number(m[1]) : 0;100      const end = m[2] ? Math.min(Number(m[2]), stat.size - 1) : stat.size - 1;101      if (start <= end && start < stat.size) {102        res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${stat.size}`, "Content-Length": end - start + 1 });103        if (req.method === "HEAD") return res.end();104        return pipeline(fs.createReadStream(file, { start, end }), res, () => {});105      }106      res.writeHead(416, { "Content-Range": `bytes */${stat.size}` });107      return res.end();108    }109  }110  headers["Content-Length"] = stat.size;111  res.writeHead(200, headers);112  if (req.method === "HEAD") return res.end();113  pipeline(fs.createReadStream(file), res, () => {});114}115116function notFound(req, res) {117  const nf = path.join(ROOT, "404.html");118  if (fs.existsSync(nf)) {119    const body = fs.readFileSync(nf);120    res.writeHead(404, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });121    return res.end(body);122  }123  send(res, 404, "404 — page introuvable");124}125126const server = http.createServer((req, res) => {127  if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "Méthode non permise", { Allow: "GET, HEAD" });128  const url = req.url || "/";129  if (url.startsWith("/api/health")) {130    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" });131  }132  let file = safeJoin(ROOT, url);133  if (!file) return send(res, 400, "Requête invalide");134  try {135    let stat = fs.existsSync(file) ? fs.statSync(file) : null;136    if (stat && stat.isDirectory()) {137      if (!url.split("?")[0].endsWith("/")) {138        res.writeHead(301, { Location: url.split("?")[0] + "/" + (url.includes("?") ? "?" + url.split("?")[1] : "") });139        return res.end();140      }141      file = path.join(file, "index.html");142      stat = fs.existsSync(file) ? fs.statSync(file) : null;143    }144    if (!stat && !path.extname(file)) {145      // URL propre sans slash : /glossaire → /glossaire/index.html146      const alt = path.join(file, "index.html");147      if (fs.existsSync(alt)) {148        res.writeHead(301, { Location: url.split("?")[0] + "/" });149        return res.end();150      }151      const alt2 = file + ".html";152      if (fs.existsSync(alt2)) {153        file = alt2;154        stat = fs.statSync(file);155      }156    }157    if (!stat || !stat.isFile()) return notFound(req, res);158    serveFile(req, res, file, stat);159  } catch (e) {160    console.error(e);161    send(res, 500, "Erreur serveur");162  }163});164165server.listen(PORT, "0.0.0.0", () => {166  console.log(`[uqo-cours] port ${PORT} · racine ${ROOT}`);167});168
Files · 710b5f31c7bae6a8c0dcee12410b5c91b5a5967d
Menu

Theme