SPB Git forge

spb/uqo-imm1033

Public
4commits 1branches 0releases
29.5 MBsize
maindefault branch
4 min agolast push
JavaScript 68% CSS 32%
6.5 KB · 169 lines javascript
Raw Blame History
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