206 lines
6.4 KiB
Python
206 lines
6.4 KiB
Python
#!/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()
|