Compare commits

...

2 Commits

5 changed files with 238 additions and 58 deletions

View File

@@ -1,9 +1,9 @@
name: Obfuscate Main Build name: Release Build (dev → main)
on: on:
push: push:
branches: branches:
- main - dev
workflow_dispatch: workflow_dispatch:
env: env:
@@ -11,18 +11,34 @@ env:
REPO_PATH: smueller/HexaHost-Frontend REPO_PATH: smueller/HexaHost-Frontend
jobs: jobs:
obfuscate: release-build:
# Kein erneuter Lauf nach dem Bot-Commit
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout (volle History)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# Gitea liefert intern oft eine IP; Zertifikat gilt für git.hexahost.dev
repository-url: https://git.hexahost.dev/smueller/HexaHost-Frontend repository-url: https://git.hexahost.dev/smueller/HexaHost-Frontend
ref: dev
- name: Merge dev in CI-Workspace (Basis main)
env:
GITEA_TOKEN: ${{ github.token }}
run: |
git config user.name "gitea-actions"
git config user.email "actions@local"
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}.git"
git fetch origin main dev
if git show-ref --verify --quiet refs/remotes/origin/main; then
git checkout -B main origin/main
git merge origin/dev -X theirs --no-edit -m "ci: merge dev for release build"
else
echo "main branch missing, initializing from dev"
git checkout -B main origin/dev
fi
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
@@ -37,7 +53,7 @@ jobs:
- name: Run release obfuscation - name: Run release obfuscation
run: python scripts/obfuscate_release.py --root . --hash-assets run: python scripts/obfuscate_release.py --root . --hash-assets
- name: Commit obfuscated build - name: Publish release to main
env: env:
GITEA_TOKEN: ${{ github.token }} GITEA_TOKEN: ${{ github.token }}
run: | run: |
@@ -46,8 +62,8 @@ jobs:
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}.git" git remote set-url origin "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}.git"
git add -A git add -A
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No build changes to commit." echo "No release changes to publish."
exit 0 exit 0
fi fi
git commit -m "chore(release): obfuscate and hash production assets [skip ci]" git commit -m "chore(release): obfuscate and hash production assets [skip ci]"
git push origin HEAD:main git push origin main

View File

@@ -1,44 +1,70 @@
name: Obfuscate Main Build # Hinweis: Gitea nutzt .gitea/workflows/obfuscate-main.yml (identischer Ablauf).
name: Release Build (dev → main)
on: on:
push: push:
branches: branches:
- main - dev
workflow_dispatch: workflow_dispatch:
permissions: env:
contents: write GITEA_HOST: git.hexahost.dev
REPO_PATH: smueller/HexaHost-Frontend
jobs: jobs:
obfuscate: release-build:
if: github.actor != 'github-actions[bot]' if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout (volle History)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
repository-url: https://git.hexahost.dev/smueller/HexaHost-Frontend
ref: dev
- name: Merge dev in CI-Workspace (Basis main)
env:
GITEA_TOKEN: ${{ github.token }}
run: |
git config user.name "gitea-actions"
git config user.email "actions@local"
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}.git"
git fetch origin main dev
if git show-ref --verify --quiet refs/remotes/origin/main; then
git checkout -B main origin/main
git merge origin/dev -X theirs --no-edit -m "ci: merge dev for release build"
else
echo "main branch missing, initializing from dev"
git checkout -B main origin/dev
fi
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: "3.12"
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
- name: Run release obfuscation - name: Run release obfuscation
run: python scripts/obfuscate_release.py --root . --hash-assets run: python scripts/obfuscate_release.py --root . --hash-assets
- name: Commit obfuscated build - name: Publish release to main
env:
GITEA_TOKEN: ${{ github.token }}
run: | run: |
git config user.name "gitea-actions"
git config user.email "actions@local"
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}.git"
git add -A git add -A
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No build changes to commit." echo "No release changes to publish."
exit 0 exit 0
fi fi
git commit -m "chore(release): obfuscate and hash production assets [skip ci]" git commit -m "chore(release): obfuscate and hash production assets [skip ci]"
git push git push origin main

View File

@@ -166,9 +166,21 @@ Für den Produktivbetrieb `public/` als Webroot konfigurieren.
### Production-Build & Veröffentlichung ### Production-Build & Veröffentlichung
Der Quellcode bleibt auf `dev`, der veröffentlichte Stand liegt auf `main`. | Branch | Zweck |
|--------|--------|
| **`dev`** | Entwicklung (lesbarer Code, Kommentare) |
| **`main`** | Release/Produktion (obfuskiert, gehashte Assets) |
Bei jedem Push/Merge auf `main` läuft die GitHub Action `.github/workflows/obfuscate-main.yml` automatisch und führt aus: **Workflow:** Nur auf `dev` entwickeln und pushen — **nicht** `dev` manuell nach `main` mergen.
Bei jedem Push auf `dev` startet `.gitea/workflows/obfuscate-main.yml`:
1. Checkout in temporärem Runner-Workspace
2. `dev` in CI mit `main` mergen (`-X theirs`, dev-Inhalte bei Konflikten)
3. Obfuscation-Build (`scripts/obfuscate_release.py --hash-assets`)
4. Ergebnis nach `main` pushen (Bot-Commit mit `[skip ci]`)
Der Build führt aus:
- Entfernen von Kommentaren (inkl. Block-Kommentaren) in PHP/JS/CSS - Entfernen von Kommentaren (inkl. Block-Kommentaren) in PHP/JS/CSS
- Minify + Obfuscate für JavaScript - Minify + Obfuscate für JavaScript
@@ -176,7 +188,7 @@ Bei jedem Push/Merge auf `main` läuft die GitHub Action `.github/workflows/obfu
- Kein Source-Map-Output - Kein Source-Map-Output
- Hashing von JS/CSS-Dateinamen + automatische Referenz-Anpassung - Hashing von JS/CSS-Dateinamen + automatische Referenz-Anpassung
Lokal ausführbar: Lokal testen (nur in Kopie, nicht committen):
```bash ```bash
python scripts/obfuscate_release.py --root . --hash-assets python scripts/obfuscate_release.py --root . --hash-assets

View File

@@ -3,15 +3,17 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from collections import defaultdict
from pathlib import Path from pathlib import Path
TEXT_EXTENSIONS = {".php", ".html", ".htm", ".xml", ".txt", ".js", ".css"} TEXT_EXTENSIONS = {".php", ".html", ".htm", ".xml", ".txt", ".js", ".css"}
HASH_SUFFIX_RE = re.compile(r"\.[a-f0-9]{12}$", re.I)
def strip_comments_keep_strings(text: str) -> str: def strip_comments_keep_strings(text: str) -> str:
@@ -174,42 +176,124 @@ def minify_js_fallback(text: str) -> str:
return text.strip() return text.strip()
def run_cmd(command: list[str], cwd: Path, input_text: str | None = None) -> str: def canonical_asset_base(stem: str) -> str:
name = stem
while HASH_SUFFIX_RE.search(name):
name = HASH_SUFFIX_RE.sub("", name)
return name
def is_skipped_asset(path: Path) -> bool:
lowered = path.as_posix().lower()
if ".min." in path.name or ".obf." in path.name or ".deob." in path.name:
return True
if "deobfuscated" in lowered:
return True
return False
def is_valid_source_content(content: str) -> bool:
if "[javascript-obfuscator-cli]" in content:
return False
return len(content.strip()) >= 20
def collect_asset_groups(asset_root: Path) -> dict[tuple[Path, str, str], list[Path]]:
groups: dict[tuple[Path, str, str], list[Path]] = defaultdict(list)
for ext in (".js", ".css"):
for file_path in sorted(asset_root.rglob(f"*{ext}")):
if is_skipped_asset(file_path):
continue
base = canonical_asset_base(file_path.stem)
key = (file_path.parent, base, ext)
groups[key].append(file_path)
return groups
def pick_source_file(paths: list[Path], base: str, ext: str) -> Path | None:
if not paths:
return None
parent = paths[0].parent
plain = parent / f"{base}{ext}"
ordered: list[Path] = []
if plain in paths:
ordered.append(plain)
for candidate in sorted(paths, key=lambda p: len(p.name)):
if candidate not in ordered:
ordered.append(candidate)
for candidate in ordered:
try:
content = candidate.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if is_valid_source_content(content):
return candidate
return None
def cleanup_invalid_siblings(paths: list[Path], source: Path) -> None:
for path in paths:
if path == source or not path.exists():
continue
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
content = ""
if not is_valid_source_content(content):
path.unlink()
def run_cmd(command: list[str], cwd: Path) -> None:
proc = subprocess.run( proc = subprocess.run(
command, command,
cwd=str(cwd), cwd=str(cwd),
input=input_text,
text=True, text=True,
capture_output=True, capture_output=True,
check=False, check=False,
) )
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "command failed") raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "command failed")
return proc.stdout
def process_js(path: Path, cwd: Path) -> None: def process_js(path: Path, cwd: Path) -> None:
original = path.read_text(encoding="utf-8") original = path.read_text(encoding="utf-8")
if not is_valid_source_content(original):
raise ValueError(
f"invalid or corrupted JS source: {path} "
f"(restore e.g. 'git checkout dev -- {path.as_posix()}')"
)
if shutil.which("npx"): if shutil.which("npx"):
tmpdir = Path(tempfile.mkdtemp())
try: try:
minified = run_cmd( src = tmpdir / "input.js"
out = tmpdir / "output.js"
src.write_text(original, encoding="utf-8")
run_cmd(
[ [
"npx", "npx",
"--yes", "--yes",
"terser", "terser",
str(src),
"-o",
str(src),
"--compress", "--compress",
"--mangle", "--mangle",
"--comments", "--comments",
"false", "false",
], ],
cwd, cwd,
input_text=original,
) )
obfuscated = run_cmd( run_cmd(
[ [
"npx", "npx",
"--yes", "--yes",
"javascript-obfuscator", "javascript-obfuscator",
str(src),
"--output",
str(out),
"--compact", "--compact",
"true", "true",
"--control-flow-flattening", "--control-flow-flattening",
@@ -224,38 +308,51 @@ def process_js(path: Path, cwd: Path) -> None:
"browser-no-eval", "browser-no-eval",
"--source-map", "--source-map",
"false", "false",
"--output",
"stdout",
], ],
cwd, cwd,
input_text=minified,
) )
path.write_text(obfuscated.strip() + "\n", encoding="utf-8") if not out.exists():
raise RuntimeError("obfuscator produced no output file")
result = out.read_text(encoding="utf-8")
if not is_valid_source_content(result):
raise RuntimeError("obfuscator output looks invalid")
path.write_text(result.strip() + "\n", encoding="utf-8")
return return
except Exception: except Exception:
pass pass
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
path.write_text(minify_js_fallback(original) + "\n", encoding="utf-8") path.write_text(minify_js_fallback(original) + "\n", encoding="utf-8")
def process_css(path: Path, cwd: Path) -> None: def process_css(path: Path, cwd: Path) -> None:
original = path.read_text(encoding="utf-8") original = path.read_text(encoding="utf-8")
if shutil.which("npx"): if shutil.which("npx"):
tmpdir = Path(tempfile.mkdtemp())
try: try:
minified = run_cmd( src = tmpdir / "input.css"
out = tmpdir / "output.css"
src.write_text(original, encoding="utf-8")
run_cmd(
[ [
"npx", "npx",
"--yes", "--yes",
"clean-css-cli", "clean-css-cli",
str(src),
"-o",
str(out),
"--skip-rebase", "--skip-rebase",
"-O2", "-O2",
], ],
cwd, cwd,
input_text=original,
) )
path.write_text(minified.strip() + "\n", encoding="utf-8") path.write_text(out.read_text(encoding="utf-8").strip() + "\n", encoding="utf-8")
return return
except Exception: except Exception:
pass pass
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
path.write_text(minify_css_fallback(original) + "\n", encoding="utf-8") path.write_text(minify_css_fallback(original) + "\n", encoding="utf-8")
@@ -266,8 +363,7 @@ def process_php(path: Path) -> None:
def hash_file(path: Path) -> str: def hash_file(path: Path) -> str:
digest = hashlib.sha256(path.read_bytes()).hexdigest()[:12] return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
return digest
def replace_references(root: Path, mapping: dict[str, str]) -> None: def replace_references(root: Path, mapping: dict[str, str]) -> None:
@@ -279,7 +375,7 @@ def replace_references(root: Path, mapping: dict[str, str]) -> None:
except UnicodeDecodeError: except UnicodeDecodeError:
continue continue
updated = content updated = content
for src, dst in mapping.items(): for src, dst in sorted(mapping.items(), key=lambda item: len(item[0]), reverse=True):
updated = updated.replace(src, dst) updated = updated.replace(src, dst)
updated = updated.replace("/" + src, "/" + dst) updated = updated.replace("/" + src, "/" + dst)
if updated != content: if updated != content:
@@ -289,17 +385,32 @@ def replace_references(root: Path, mapping: dict[str, str]) -> None:
def build_hash_mapping(public_root: Path) -> dict[str, str]: def build_hash_mapping(public_root: Path) -> dict[str, str]:
mapping: dict[str, str] = {} mapping: dict[str, str] = {}
asset_root = public_root / "assets" asset_root = public_root / "assets"
for ext in (".js", ".css"): if not asset_root.exists():
for file_path in sorted(asset_root.rglob(f"*{ext}")): return mapping
if ".min." in file_path.name or ".obf." in file_path.name:
groups = collect_asset_groups(asset_root)
for (parent, base, ext), paths in groups.items():
source = pick_source_file(paths, base, ext)
if source is None:
continue continue
digest = hash_file(file_path) digest = hash_file(source)
new_name = f"{file_path.stem}.{digest}{file_path.suffix}" target = parent / f"{base}.{digest}{ext}"
new_path = file_path.with_name(new_name) rel_new = target.relative_to(public_root).as_posix()
file_path.rename(new_path)
rel_old = file_path.relative_to(public_root).as_posix() for old in paths:
rel_new = new_path.relative_to(public_root).as_posix() rel_old = old.relative_to(public_root).as_posix()
if rel_old != rel_new:
mapping[rel_old] = rel_new mapping[rel_old] = rel_new
if source != target:
if target.exists():
target.unlink()
source.replace(target)
for old in paths:
if old != target and old.exists():
old.unlink()
return mapping return mapping
@@ -316,11 +427,26 @@ def main() -> int:
print("public directory not found", file=sys.stderr) print("public directory not found", file=sys.stderr)
return 1 return 1
for js in sorted(public_root.rglob("*.js")): asset_root = public_root / "assets"
process_js(js, repo_root) if asset_root.exists():
for css in sorted(public_root.rglob("*.css")): groups = collect_asset_groups(asset_root)
process_css(css, repo_root) for (parent, base, ext), paths in sorted(groups.items()):
for php in sorted((repo_root / "public").rglob("*.php")): source = pick_source_file(paths, base, ext)
if source is None:
rel = (parent / f"{base}{ext}").relative_to(public_root)
print(
f"ERROR: No valid source for {rel}. "
f"Restore from dev, e.g.: git checkout dev -- {rel}",
file=sys.stderr,
)
return 1
cleanup_invalid_siblings(paths, source)
if ext == ".js":
process_js(source, repo_root)
else:
process_css(source, repo_root)
for php in sorted(public_root.rglob("*.php")):
process_php(php) process_php(php)
for php in sorted((repo_root / "backend").rglob("*.php")): for php in sorted((repo_root / "backend").rglob("*.php")):
process_php(php) process_php(php)