diff --git a/.gitea/workflows/obfuscate-main.yml b/.gitea/workflows/obfuscate-main.yml new file mode 100644 index 0000000..86a491b --- /dev/null +++ b/.gitea/workflows/obfuscate-main.yml @@ -0,0 +1,51 @@ +name: Obfuscate Main Build + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + obfuscate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Skip loop commits + run: | + msg="$(git log -1 --pretty=%B)" + echo "Last commit message: $msg" + if echo "$msg" | grep -q "\[skip ci\]"; then + echo "Skip CI commit detected." + exit 0 + fi + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Run release obfuscation + run: python scripts/obfuscate_release.py --root . --hash-assets + + - name: Commit obfuscated build + run: | + git config user.name "gitea-actions" + git config user.email "actions@local" + git add -A + if git diff --cached --quiet; then + echo "No build changes to commit." + exit 0 + fi + git commit -m "chore(release): obfuscate and hash production assets [skip ci]" + git push diff --git a/.github/workflows/obfuscate-main.yml b/.github/workflows/obfuscate-main.yml new file mode 100644 index 0000000..0c1b10e --- /dev/null +++ b/.github/workflows/obfuscate-main.yml @@ -0,0 +1,44 @@ +name: Obfuscate Main Build + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + obfuscate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run release obfuscation + run: python scripts/obfuscate_release.py --root . --hash-assets + + - name: Commit obfuscated build + run: | + git add -A + if git diff --cached --quiet; then + echo "No build changes to commit." + exit 0 + fi + git commit -m "chore(release): obfuscate and hash production assets [skip ci]" + git push diff --git a/README.md b/README.md index 0366fd7..f9fbb03 100644 --- a/README.md +++ b/README.md @@ -166,25 +166,22 @@ Für den Produktivbetrieb `public/` als Webroot konfigurieren. ### Production-Build & Veröffentlichung -Der Quellcode bleibt auf `dev`, der veröffentlichte Stand liegt auf `main` (ohne Kommentare, obfuskiertes JS). +Der Quellcode bleibt auf `dev`, der veröffentlichte Stand liegt auf `main`. -**Voraussetzungen:** Node.js 18+ (inkl. npm), PHP 8+ CLI, Git +Bei jedem Push/Merge auf `main` läuft die GitHub Action `.github/workflows/obfuscate-main.yml` automatisch und führt aus: -```powershell -# Windows -.\scripts\run-build.ps1 -.\scripts\publish-to-main.ps1 -Push -``` +- Entfernen von Kommentaren (inkl. Block-Kommentaren) in PHP/JS/CSS +- Minify + Obfuscate für JavaScript +- Minify für CSS +- Kein Source-Map-Output +- Hashing von JS/CSS-Dateinamen + automatische Referenz-Anpassung + +Lokal ausführbar: ```bash -# Linux / macOS -chmod +x scripts/*.sh -./scripts/run-build.sh -./scripts/publish-to-main.sh --push +python scripts/obfuscate_release.py --root . --hash-assets ``` -Details: `scripts/build/README.md` - ## 🔗 Backend-Integration Das Backend-Repository enthält folgende wiederverwendbare Komponenten: diff --git a/scripts/__pycache__/obfuscate_release.cpython-314.pyc b/scripts/__pycache__/obfuscate_release.cpython-314.pyc new file mode 100644 index 0000000..475c68f Binary files /dev/null and b/scripts/__pycache__/obfuscate_release.cpython-314.pyc differ diff --git a/scripts/obfuscate_release.py b/scripts/obfuscate_release.py new file mode 100644 index 0000000..9f0fa6e --- /dev/null +++ b/scripts/obfuscate_release.py @@ -0,0 +1,337 @@ +#!/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())