#!/usr/bin/env python3 from __future__ import annotations import argparse import hashlib import os import re import shutil import subprocess import sys from pathlib import Path TEXT_EXTENSIONS = {".php", ".html", ".htm", ".xml", ".txt", ".js", ".css"} def strip_comments_keep_strings(text: str) -> str: out = [] i = 0 n = len(text) in_single = False in_double = False in_template = False escape = False in_line_comment = False in_block_comment = False while i < n: ch = text[i] nxt = text[i + 1] if i + 1 < n else "" if in_line_comment: if ch == "\n": in_line_comment = False out.append(ch) i += 1 continue if in_block_comment: if ch == "*" and nxt == "/": in_block_comment = False i += 2 else: if ch == "\n": out.append("\n") i += 1 continue if in_single or in_double or in_template: out.append(ch) if escape: escape = False elif ch == "\\": escape = True elif in_single and ch == "'": in_single = False elif in_double and ch == '"': in_double = False elif in_template and ch == "`": in_template = False i += 1 continue if ch == "/" and nxt == "/": in_line_comment = True i += 2 continue if ch == "/" and nxt == "*": in_block_comment = True i += 2 continue if ch == "'": in_single = True out.append(ch) i += 1 continue if ch == '"': in_double = True out.append(ch) i += 1 continue if ch == "`": in_template = True out.append(ch) i += 1 continue out.append(ch) i += 1 return "".join(out) def strip_php_comments(text: str) -> str: out = [] i = 0 n = len(text) in_single = False in_double = False in_line_comment = False in_block_comment = False escape = False while i < n: ch = text[i] nxt = text[i + 1] if i + 1 < n else "" if in_line_comment: if ch == "\n": in_line_comment = False out.append("\n") i += 1 continue if in_block_comment: if ch == "*" and nxt == "/": in_block_comment = False i += 2 else: if ch == "\n": out.append("\n") i += 1 continue if in_single or in_double: out.append(ch) if escape: escape = False elif ch == "\\": escape = True elif in_single and ch == "'": in_single = False elif in_double and ch == '"': in_double = False i += 1 continue if ch == "/" and nxt == "/": in_line_comment = True i += 2 continue if ch == "#": in_line_comment = True i += 1 continue if ch == "/" and nxt == "*": in_block_comment = True i += 2 continue if ch == "'": in_single = True elif ch == '"': in_double = True out.append(ch) i += 1 return "".join(out) def minify_css_fallback(text: str) -> str: text = strip_comments_keep_strings(text) text = re.sub(r"\s+", " ", text) text = re.sub(r"\s*([{}:;,>+~])\s*", r"\1", text) return text.strip() def minify_js_fallback(text: str) -> str: text = strip_comments_keep_strings(text) text = re.sub(r"\s+", " ", text) text = re.sub(r"\s*([{}:;,()=+\-*/<>!&|?])\s*", r"\1", text) return text.strip() def run_cmd(command: list[str], cwd: Path, input_text: str | None = None) -> str: proc = subprocess.run( command, cwd=str(cwd), input=input_text, text=True, capture_output=True, check=False, ) if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "command failed") return proc.stdout def process_js(path: Path, cwd: Path) -> None: original = path.read_text(encoding="utf-8") if shutil.which("npx"): try: minified = run_cmd( [ "npx", "--yes", "terser", "--compress", "--mangle", "--comments", "false", ], cwd, input_text=original, ) obfuscated = run_cmd( [ "npx", "--yes", "javascript-obfuscator", "--compact", "true", "--control-flow-flattening", "true", "--dead-code-injection", "true", "--string-array", "true", "--string-array-encoding", "base64", "--target", "browser-no-eval", "--source-map", "false", "--output", "stdout", ], cwd, input_text=minified, ) path.write_text(obfuscated.strip() + "\n", encoding="utf-8") return except Exception: pass path.write_text(minify_js_fallback(original) + "\n", encoding="utf-8") def process_css(path: Path, cwd: Path) -> None: original = path.read_text(encoding="utf-8") if shutil.which("npx"): try: minified = run_cmd( [ "npx", "--yes", "clean-css-cli", "--skip-rebase", "-O2", ], cwd, input_text=original, ) path.write_text(minified.strip() + "\n", encoding="utf-8") return except Exception: pass path.write_text(minify_css_fallback(original) + "\n", encoding="utf-8") def process_php(path: Path) -> None: original = path.read_text(encoding="utf-8") stripped = strip_php_comments(original) path.write_text(stripped, encoding="utf-8") def hash_file(path: Path) -> str: digest = hashlib.sha256(path.read_bytes()).hexdigest()[:12] return digest def replace_references(root: Path, mapping: dict[str, str]) -> None: for candidate in root.rglob("*"): if not candidate.is_file() or candidate.suffix.lower() not in TEXT_EXTENSIONS: continue try: content = candidate.read_text(encoding="utf-8") except UnicodeDecodeError: continue updated = content for src, dst in mapping.items(): updated = updated.replace(src, dst) updated = updated.replace("/" + src, "/" + dst) if updated != content: candidate.write_text(updated, encoding="utf-8") def build_hash_mapping(public_root: Path) -> dict[str, str]: mapping: dict[str, str] = {} asset_root = public_root / "assets" for ext in (".js", ".css"): for file_path in sorted(asset_root.rglob(f"*{ext}")): if ".min." in file_path.name or ".obf." in file_path.name: continue digest = hash_file(file_path) new_name = f"{file_path.stem}.{digest}{file_path.suffix}" new_path = file_path.with_name(new_name) file_path.rename(new_path) rel_old = file_path.relative_to(public_root).as_posix() rel_new = new_path.relative_to(public_root).as_posix() mapping[rel_old] = rel_new return mapping def main() -> int: parser = argparse.ArgumentParser(description="Release obfuscation build.") parser.add_argument("--root", default=".", help="Repository root") parser.add_argument("--hash-assets", action="store_true", help="Hash JS/CSS file names") args = parser.parse_args() repo_root = Path(args.root).resolve() public_root = repo_root / "public" if not public_root.exists(): print("public directory not found", file=sys.stderr) return 1 for js in sorted(public_root.rglob("*.js")): process_js(js, repo_root) for css in sorted(public_root.rglob("*.css")): process_css(css, repo_root) for php in sorted((repo_root / "public").rglob("*.php")): process_php(php) for php in sorted((repo_root / "backend").rglob("*.php")): process_php(php) if args.hash_assets: mapping = build_hash_mapping(public_root) replace_references(repo_root, mapping) print("Release obfuscation complete.") return 0 if __name__ == "__main__": raise SystemExit(main())