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.
This commit is contained in:
mrleo1nid committed 2026-10-25 04:42:48 +03:00
1 parent 06ff19ac4a
commit c8d83b3fe7
65 files changed
+3825 -68

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
"""Disposable local UI fixture. Run seed/cleanup only on the pgames-test stand."""
import argparse
import asyncio
import json
import tempfile
from pathlib import Path
from uuid import uuid4
from sqlalchemy import delete
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.maintenance import storage_access
from app.models import Game, GameVersion, Role, User
from app.security import hash_password
from tests.make_sample import build
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("seed", "check", "cleanup"))
args = parser.parse_args()
record = get_settings().data_dir / "mod-ui-fixture.json"
if args.action == "check":
import http.cookiejar
import urllib.error
import urllib.request
data = json.loads(record.read_text())
host = "http://web"
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
payload = json.dumps({"username": data["username"], "password": data["password"]}).encode()
opener.open(urllib.request.Request(host + "/api/auth/login", data=payload, headers={"Content-Type": "application/json"}))
manifest = json.load(opener.open(host + f'/api/versions/{data["version_id"]}/manifest'))
key = manifest["files"]["game.json"]["hash"]
file_url = host + f'/api/versions/{data["version_id"]}/files/{key}'
response = opener.open(file_url)
assert response.status == 200 and "private" in response.headers["Cache-Control"]
assert json.load(response)["format"] == "pgvn/1"
assert opener.open(urllib.request.Request(file_url, method="HEAD")).status == 200
audio = next(f["hash"] for p, f in manifest["files"].items() if p.endswith(".ogg"))
response = opener.open(urllib.request.Request(host + f'/api/versions/{data["version_id"]}/files/{audio}', headers={"Range": "bytes=0-9"}))
assert response.status == 206 and len(response.read()) == 10
for url, status in [(file_url, 401), (host + f"/_protected_blobs/{key[:2]}/{key[2:4]}/{key}", 404)]:
try:
urllib.request.urlopen(url)
except urllib.error.HTTPError as error:
assert error.code == status
else:
raise AssertionError("Unprotected media route")
print("nginx: authenticated JSON/HEAD/Range OK; anonymous and direct internal access denied")
return
async with storage_access():
async with SessionLocal() as db:
if args.action == "cleanup":
if not record.exists():
return
data = json.loads(record.read_text())
user = await db.get(User, data["user_id"])
if not user or user.username != data["username"] or not user.username.startswith("mod-ui-"):
raise RuntimeError("Fixture identity mismatch; cleanup aborted")
await db.execute(delete(Game).where(Game.created_by_id == user.id))
await db.delete(user)
await db.commit()
await db.execute(delete(Role).where(Role.id == data["role_id"]))
await db.commit()
record.unlink()
print("Disposable UI user and games removed")
return
if record.exists():
print(record.read_text())
return
suffix = uuid4().hex[:10]
password = uuid4().hex
role = Role(name="mod-ui-" + suffix, permissions=["games.play", "versions.fork", "games.upload", "games.edit", "versions.publish"])
db.add(role)
await db.flush()
user = User(username="mod-ui-" + suffix, password_hash=hash_password(password), role_id=role.id, must_change_password=False)
db.add(user)
await db.flush()
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
result = await asyncio.to_thread(convert_archive, build(root / "sample.zip"), "sample.zip", root / "work", lambda _: None)
game = Game(title="Проверка редактора модов", created_by_id=user.id)
db.add(game)
await db.flush()
original = GameVersion(game_id=game.id, owner_id=user.id, name="Оригинал для проверки", status="ready", manifest=result.manifest)
db.add(original)
await db.flush()
fork = GameVersion(game_id=game.id, parent_id=original.id, owner_id=user.id, name="Мой тестовый мод", kind="mod", visibility="draft", status="ready", manifest=result.manifest, base_revision_id=original.revision_id)
fork._revision_parents = [original.revision_id]
db.add(fork)
await db.commit()
data = dict(username=user.username, password=password, user_id=user.id, role_id=role.id, version_id=fork.id, game_id=game.id)
record.write_text(json.dumps(data))
print(json.dumps(data))
if __name__ == "__main__":
asyncio.run(main())