#!/usr/bin/env python3 """Local static server with extensionless URL support (mirrors Apache .htaccess). Usage: python3 serve.py python3 serve.py 8080 Then open http://127.0.0.1:8080/about (not file://) """ from __future__ import annotations import argparse import json import mimetypes import os from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse ROOT = Path(__file__).resolve().parent SECURITY_HEADERS = ( ("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "strict-origin-when-cross-origin"), ("X-Frame-Options", "SAMEORIGIN"), ("Permissions-Policy", "camera=(), microphone=(), geolocation=()"), ( "Content-Security-Policy", "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'", ), ) class CleanURLHandler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(ROOT), **kwargs) def log_message(self, format, *args): return # quieter local dev logs def _parts(self): parsed = urlparse(self.path) path = unquote(parsed.path) query = f"?{parsed.query}" if parsed.query else "" return path, query def _redirect(self, location: str) -> None: self.send_response(301) self.send_header("Location", location) self.end_headers() def _json(self, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def _serve_404(self) -> None: page = ROOT / "404.html" data = page.read_bytes() if page.is_file() else b"Not found" self.send_response(404) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() if self.command != "HEAD": self.wfile.write(data) def _maybe_redirect(self) -> bool: path, query = self._parts() if path in ("/services/odoo", "/services/odoo/", "/services/odoo.html"): self._redirect("/services/erp" + query) return True if path in ("/index.html", "/index"): self._redirect("/" + query) return True if path.endswith(".html") and path != "/404.html": clean = path[:-5] or "/" self._redirect(clean + query) return True # Trailing slash on pages (and /services/) → slashless URL if path != "/" and path.endswith("/"): self._redirect(path.rstrip("/") + query) return True return False def _handle_php(self) -> bool: path, _query = self._parts() clean = path.rstrip("/") or "/" if not clean.endswith(".php"): return False if clean.endswith("/api/helpers.php") or clean == "/helpers.php": self.send_error(403, "Forbidden") return True if self.command == "POST": self.send_error(501, "PHP is not available on the local static server") return True self._json(405, {"ok": False, "error": "Method not allowed."}) return True def do_GET(self): if self._maybe_redirect(): return if self._handle_php(): return translated = Path(self.translate_path(self.path)) if not self._is_servable(translated): self._serve_404() return return super().do_GET() def do_HEAD(self): if self._maybe_redirect(): return if self._handle_php(): return translated = Path(self.translate_path(self.path)) if not self._is_servable(translated): self._serve_404() return return super().do_HEAD() def do_POST(self): if self._handle_php(): return self.send_error(501, "Unsupported method") def _is_servable(self, translated: Path) -> bool: if translated.is_file(): return True # Directory with index.html (site root /) return translated.is_dir() and (translated / "index.html").is_file() def list_directory(self, path): self._serve_404() return None def translate_path(self, path: str) -> str: parsed = urlparse(path) clean = unquote(parsed.path) if clean != "/" and clean.endswith("/"): clean = clean.rstrip("/") candidate = clean.lstrip("/") if candidate and candidate != "404.html": html_path = ROOT / f"{candidate}.html" file_path = ROOT / candidate # Prefer .html page over a same-named folder (/services → services.html) if html_path.is_file() and (not file_path.exists() or file_path.is_dir()): path = f"/{candidate}.html" if parsed.query: path += f"?{parsed.query}" return super().translate_path(path) def end_headers(self): self.send_header("Cache-Control", "no-store") for name, value in SECURITY_HEADERS: self.send_header(name, value) super().end_headers() class ReusableHTTPServer(ThreadingHTTPServer): allow_reuse_address = True def main() -> None: parser = argparse.ArgumentParser(description="Serve Zettai Systems with clean URLs") parser.add_argument("port", nargs="?", type=int, default=8080) args = parser.parse_args() os.chdir(ROOT) mimetypes.add_type("text/css", ".css") mimetypes.add_type("application/javascript", ".js") try: server = ReusableHTTPServer(("127.0.0.1", args.port), CleanURLHandler) except OSError as exc: if exc.errno == 48: print(f"Port {args.port} is already in use.") print(f"Stop the other server: lsof -ti:{args.port} | xargs kill -9") print(f"Or use another port: python3 serve.py 8081") raise SystemExit(1) from exc raise print(f"Serving {ROOT}", flush=True) print(f"Open http://127.0.0.1:{args.port}/services", flush=True) print("Press Ctrl+C to stop", flush=True) try: server.serve_forever() except KeyboardInterrupt: print("\nStopped.") if __name__ == "__main__": main()