Files
pgames-manager/backend/app/converter/archive.py
T
mrleo1nid c8d83b3fe7 feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
2026-10-25 04:42:48 +03:00

99 lines
3.8 KiB
Python

"""Archive extraction and locating the game directory inside it."""
import os
import shutil
import subprocess
import zipfile
from pathlib import Path
class ArchiveError(Exception):
pass
def _safe_target(root: Path, name: str) -> Path | None:
target = (root / name).resolve()
return target if target.is_relative_to(root.resolve()) else None
def extract(archive: Path, filename: str, dest: Path, *, only_scripts: bool = False) -> None:
dest.mkdir(parents=True, exist_ok=True)
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
_extract_zip(archive, dest, only_scripts=only_scripts)
return
seven = shutil.which("7zz") or shutil.which("7z")
if not seven:
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
filters = [f"-ir!*{suffix}" for suffix in SCRIPT_EXTS | {".tar"}] if only_scripts else []
proc = subprocess.run([seven, "x", "-y", "-snl-", *filters, f"-o{dest}", str(archive)],
capture_output=True, text=True)
if proc.returncode not in (0, 1): # 1 = warnings
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
_drop_escaping_links(dest)
# .tar.gz etc. extract to a single inner .tar first
inner = list(dest.iterdir())
if len(inner) == 1 and inner[0].suffix == ".tar":
tar = inner[0]
extract(tar, tar.name, dest / "_tar", only_scripts=only_scripts)
tar.unlink()
SCRIPT_EXTS = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
def _extract_zip(archive: Path, dest: Path, *, only_scripts: bool = False) -> None:
with zipfile.ZipFile(archive) as zf:
for info in zf.infolist():
name = info.filename
if only_scripts and Path(name).suffix.lower() not in SCRIPT_EXTS:
continue
if info.flag_bits & 0x800 == 0:
# legacy zips store names in cp437; many were really written as cp866/utf-8
try:
name = name.encode("cp437").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
target = _safe_target(dest, name)
if target is None:
continue
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, target.open("wb") as out:
shutil.copyfileobj(src, out, 1 << 20)
def _drop_escaping_links(root: Path) -> None:
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
p = Path(dirpath, name)
if p.is_symlink():
p.unlink()
def find_game_dir(root: Path) -> Path:
"""Find the Ren'Py `game` directory (the one holding scripts or archives)."""
best: tuple[int, Path] | None = None
for dirpath, _, filenames in os.walk(root):
exts = {Path(f).suffix.lower() for f in filenames}
if not exts & {".rpy", ".rpyc", ".rpa", ".rpymc"}:
continue
p = Path(dirpath)
depth = len(p.relative_to(root).parts)
score = (0 if p.name.lower() == "game" else 100) + depth
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.parts[-3:]]:
continue
if best is None or score < best[0]:
best = (score, p)
if best is None:
raise ArchiveError("В архиве не найдена игра Ren'Py (нет .rpy/.rpyc/.rpa)")
game = best[1]
# scripts in a subfolder of game/ — walk up to game/ itself
for parent in [game, *game.parents]:
if parent.name.lower() == "game":
return parent
if parent == root:
break
return game