#!/usr/bin/env python3 """Serve packaged Gaia with a distinct loopback origin for every installed app.""" import argparse import json import mimetypes import os import re import subprocess from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlsplit ROOT = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=8765) parser.add_argument("--legacy-apps", action="store_true") args = parser.parse_args() from project import build_info, validate_assets, release_info validate_assets() info = build_info() info["release"] = release_info() revision, commit_time = info["revision"], str(info["timestamp"]) (ROOT / "assets/build-info.json").write_text(json.dumps(info)) APPS = ROOT / "assets/webapps" ORIGINS = {} REGISTRY = [] pattern = re.compile(r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org") def translate(text): return pattern.sub(lambda m: f"http://{m[1]}.localhost:{args.port}", text) manifests = sorted(APPS.glob("*/manifest.webapp")) LEGACY_ROOTS = set() if args.legacy_apps: for entry in json.loads((ROOT / "tests/legacy-apps.json").read_text()): directory = ROOT / "downloads/legacy-apps/apps" / entry["id"] manifests.append(directory / "manifest.webapp") LEGACY_ROOTS.add(directory.resolve()) for manifest_path in manifests: name = manifest_path.parent.name.removesuffix(".gaiamobile.org") if not re.fullmatch("[a-z0-9_-]+", name): continue origin = f"http://{name}.localhost:{args.port}" manifest = json.loads(translate(manifest_path.read_text())) ORIGINS[urlsplit(origin).netloc] = manifest_path.parent.resolve() REGISTRY.append( { "id": name, "origin": origin, "manifestURL": origin + "/manifest.webapp", "manifest": manifest, } ) ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{args.port}"] (ROOT / "assets/registry.json").write_text( json.dumps(REGISTRY, ensure_ascii=False, indent=2) + "\n" ) (ROOT / "assets/settings-current-defaults.json").write_text( translate((ROOT / "assets/settings-defaults.json").read_text()) ) class Handler(BaseHTTPRequestHandler): def do_GET(self): root = ORIGINS.get(self.headers.get("Host", "").lower()) if root is None: self.send_error(421, "Unregistered application host") return path = unquote(urlsplit(self.path).path) if "\x00" in path or "\\" in path or ".." in Path(path).parts: self.send_error(400) return if path == "/_vulpes/platform-config.js": data = (('window.VulpesPlatform='+json.dumps('tundra' if os.environ.get('VULPES_TUNDRA')=='1' else 'desktop')+';window.VulpesNativeInput='+('true' if os.environ.get('VULPES_TUNDRA')=='1' or os.environ.get('VULPES_TEST_INPUT')=='1' else 'false')+';window.VulpesNativeTelephony=') + ('true' if os.environ.get('VULPES_TUNDRA_RADIO') == '1' or os.environ.get('VULPES_TEST_TELEPHONY') == '1' else 'false') + ';window.VulpesNativeWifi=' + ('true' if os.environ.get('VULPES_TUNDRA_WIFI') == '1' else 'false') + ';').encode() self.send_response(200) self.send_header("Content-Type", "text/javascript") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return if path == "/resources/gaia_commit.txt": data = (revision + "\n" + commit_time + "\n").encode() self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(data) return # Only this non-privileged compatibility layer is exposed, never host modules. if path in [ "/_vulpes/" + name for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"] ]: file = ROOT / "host" / path.rsplit("/", 1)[1] else: file = (root / path.lstrip("/")).resolve() if file.is_dir(): file = file / "index.html" if not file.is_relative_to(root): self.send_error(403) return overrides = { "system.gaiamobile.org": [ "js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js", "js/core.js", "js/rocketbar.js", ] } if path.lstrip("/") in overrides.get(root.name, []): file = ( ROOT / "gaia/apps" / root.name.removesuffix(".gaiamobile.org") / path.lstrip("/") ) overlay_root = (ROOT / "overrides" / root.name).resolve() overlay = (overlay_root / path.lstrip("/")).resolve() if ( path != "/manifest.webapp" and overlay.is_relative_to(overlay_root) and overlay.is_file() ): file = overlay if path.startswith("/shared/"): common = (ROOT / "overrides" / path.lstrip("/")).resolve() if common.is_relative_to((ROOT / "overrides/shared").resolve()) and common.is_file(): file = common if not file.is_file() and path.startswith("/shared/"): shared = (ROOT / "gaia" / path.lstrip("/")).resolve() if shared.is_relative_to((ROOT / "gaia/shared").resolve()): file = shared if not file.is_file(): self.send_error(404) return data = file.read_bytes() mime = mimetypes.guess_type(file.name)[0] or "application/octet-stream" if file.suffix in (".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"): text = translate(data.decode("utf-8")) if file.suffix in (".css", ".js", ".html"): text = ( text.replace("-moz-calc(", "calc(") .replace("-moz-box-sizing:", "box-sizing:") .replace("-moz-plaintext", "plaintext") .replace(":-moz-dir(", ":dir(") .replace("offset-inline-start:", "inset-inline-start:") .replace("offset-inline-end:", "inset-inline-end:") ) data = text.encode("utf-8") if root.name == "music.gaiamobile.org" and file.suffix == ".js": data = data.replace(b"audio.src=null;", b"audio.removeAttribute('src');") if file.suffix == ".html" and root in LEGACY_ROOTS: data = data.replace( b"
", b'', 1, ) elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")): data = data.replace( b"", b'', 1, ) if ( file.suffix == ".html" and root.name == "communications.gaiamobile.org" and "/dialer/" in path ): data = data.replace( b"", b'', 1 ) self.send_response(200) self.send_header( "Content-Type", mime + ("; charset=utf-8" if mime.startswith("text/") else "") ) self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") # Installed Gaia code may use its legacy module compiler, but must not # gain privileged host rights through inline or remote script injection. self.send_header( "Content-Security-Policy", "script-src 'self' 'unsafe-eval'; object-src 'none'; " "base-uri 'self'; worker-src 'self' blob:; " f"frame-ancestors http://*.localhost:{args.port}", ) origin = self.headers.get("Origin", "") if urlsplit(origin).netloc in ORIGINS and origin.startswith("http://"): self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Vary", "Origin") self.end_headers() self.wfile.write(data) def log_message(self, fmt, *values): if values and str(values[1]) not in ("200", "304"): super().log_message(fmt, *values) server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) print(f"Gaia: {len(REGISTRY)} applications; 127.0.0.1:{args.port}", flush=True) server.serve_forever()