#!/usr/bin/env python3 """ serve.py -- serve the offline Red Hat topics mirror. ./serve.py # http://127.0.0.1:8322/ ./serve.py --port 9000 ./serve.py --host 0.0.0.0 # reachable from other machines Python standard library only. The mirror keeps the site's own directory layout, so the pages' root-relative URLs (/modules/..., /rhdc/...) resolve untouched. This server adds the three things a plain `python3 -m http.server` gets wrong here: * query strings are ignored when resolving a file. Red Hat's aggregated CSS/JS carries ~530 characters of cache-busting query, and images carry ?itok= tokens; the path alone identifies the file. * /en/topics/devops/ is served from en/topics/devops/index.html. * correct Content-Type for .js / .mjs / .woff2 / .webp / .svg. Browsers refuse an ES module served as anything but a JavaScript MIME type, and these pages are built entirely out of ES-module web components. It must be HTTP, not file://. Browsers refuse ES modules and import maps from a file: origin, and the whole Red Hat Design System layer would die. """ import argparse import mimetypes import os import socket import sys import urllib.parse from http.server import HTTPServer, SimpleHTTPRequestHandler from socketserver import ThreadingMixIn ROOT = os.path.dirname(os.path.abspath(__file__)) SITE = os.path.join(ROOT, "site") TYPES = { ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".json": "application/json", ".map": "application/json", ".svg": "image/svg+xml", ".webp": "image/webp", ".avif": "image/avif", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".eot": "application/vnd.ms-fontobject", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".html": "text/html; charset=utf-8", } NOT_MIRRORED = """
{path} was not downloaded.
This mirror covers https://www.redhat.com/en/topics/ only. Links that leave the topic tree — products, documentation, the blog, other languages — point at redhat.com and need internet access.
""" class Handler(SimpleHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "rhtopics-offline" def translate_path(self, path): """Map a request path to a file under site/, ignoring the query. Each segment is unquoted separately, matching how fetch.py named the files, so an encoded %2F cannot climb out of site/. """ path = urllib.parse.urlsplit(path).path parts = [] for seg in path.split("/"): seg = urllib.parse.unquote(seg, errors="replace") seg = seg.replace("/", "_").replace("\\", "_") if seg and seg not in (".", ".."): parts.append(seg) local = os.path.join(SITE, *parts) if os.path.isdir(local): return os.path.join(local, "index.html") if not os.path.exists(local): # /en/topics/devops -> en/topics/devops/index.html candidate = os.path.join(local, "index.html") if os.path.exists(candidate): return candidate return local def guess_type(self, path): ext = os.path.splitext(path)[1].lower() if ext in TYPES: return TYPES[ext] guessed = mimetypes.guess_type(path)[0] return guessed or "application/octet-stream" def send_head(self): path = self.translate_path(self.path) if not os.path.isfile(path): return self.send_not_mirrored() return super().send_head() def send_not_mirrored(self): body = NOT_MIRRORED.format( path=urllib.parse.urlsplit(self.path).path).encode("utf-8") self.send_response(404) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() if self.command == "HEAD": return None self.wfile.write(body) return None def end_headers(self): # The mirror is rebuilt in place; never let a stale copy win. self.send_header("Cache-Control", "no-cache") super().end_headers() def log_message(self, fmt, *args): if self.server.quiet: # type: ignore status = args[1] if len(args) > 1 else "" if str(status).startswith("2") or str(status).startswith("3"): return sys.stderr.write("%s %s\n" % (self.log_date_time_string(), fmt % args)) class Server(ThreadingMixIn, HTTPServer): daemon_threads = True allow_reuse_address = True quiet = True def main(): ap = argparse.ArgumentParser(description="Serve the offline Red Hat topics mirror.") ap.add_argument("--host", default="127.0.0.1", help="bind address (default 127.0.0.1; use 0.0.0.0 to " "share with other machines)") ap.add_argument("--port", type=int, default=8322, help="port (default 8322)") ap.add_argument("--verbose", action="store_true", help="log every request, not just errors") args = ap.parse_args() index = os.path.join(SITE, "en", "topics", "index.html") if not os.path.isfile(index): sys.exit("No mirror found at %s\n" "Run ./fetch.py on a host with internet access first." % os.path.join(SITE, "en", "topics")) try: httpd = Server((args.host, args.port), Handler) except OSError as exc: sys.exit("Cannot bind %s:%d -- %s\n" "Pick another port with --port." % (args.host, args.port, exc)) httpd.quiet = not args.verbose pages = sum(1 for _root, _dirs, files in os.walk(os.path.join(SITE, "en")) for f in files if f == "index.html") shown = "127.0.0.1" if args.host in ("0.0.0.0", "") else args.host print("Red Hat topics, offline -- %d pages" % pages) print(" http://%s:%d/en/topics/" % (shown, args.port)) if args.host == "0.0.0.0": try: print(" http://%s:%d/en/topics/ (this host)" % (socket.gethostbyname(socket.gethostname()), args.port)) except OSError: pass print("Ctrl-C to stop.") try: httpd.serve_forever() except KeyboardInterrupt: print("\nstopped") return 0 if __name__ == "__main__": sys.exit(main())