Add .gitattributes: LF in the repository, CRLF for batch files

Normalize the files that had been committed with CRLF (line endings only,
no content changes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:41:24 +03:00
1 parent e1d7b29f77
commit 51a7e77e56
14 files changed
+1461 -1447

No files matched your search

+24 -24
View File
@@ -1,24 +1,24 @@
[alembic]
script_location = migrations
prepend_sys_path = .
[loggers]
keys = root
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[handler_console]
class = logging.StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
[alembic]
script_location = migrations
prepend_sys_path = .
[loggers]
keys = root
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[handler_console]
class = logging.StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+69 -69
View File
@@ -1,69 +1,69 @@
"""Content-addressed blob store.
A blob id is ``<sha256><ext>`` (e.g. ``3fa1….webp``); the file lives at
``blobs/3f/a1/<id>``. The extension is part of the id so nginx can serve
blobs with the right Content-Type straight from disk.
"""
import hashlib
import os
import re
import shutil
import tempfile
from pathlib import Path
from app.config import get_settings
BLOB_ID_RE = re.compile(r"^[0-9a-f]{64}(\.[a-z0-9]{1,8})?$")
_CHUNK = 1 << 20
def blob_path(blob_id: str) -> Path:
if not BLOB_ID_RE.match(blob_id):
raise ValueError(f"bad blob id {blob_id!r}")
return get_settings().blobs_dir / blob_id[:2] / blob_id[2:4] / blob_id
def _finalize(tmp: Path, digest: str, ext: str) -> tuple[str, int]:
blob_id = digest + ext.lower()
dest = blob_path(blob_id)
size = tmp.stat().st_size
if dest.exists():
tmp.unlink()
else:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user
os.replace(tmp, dest)
return blob_id, size
def _tmp_file() -> Path:
root = get_settings().blobs_dir / "tmp"
root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=root)
os.close(fd)
return Path(name)
def put_file(src: Path, ext: str, move: bool = False) -> tuple[str, int]:
"""Store a file, returning (blob id, size). ``move`` consumes ``src``."""
h = hashlib.sha256()
with src.open("rb") as f:
while chunk := f.read(_CHUNK):
h.update(chunk)
tmp = _tmp_file()
if move:
shutil.move(src, tmp)
else:
shutil.copyfile(src, tmp)
return _finalize(tmp, h.hexdigest(), ext)
def put_bytes(data: bytes, ext: str) -> tuple[str, int]:
tmp = _tmp_file()
tmp.write_bytes(data)
return _finalize(tmp, hashlib.sha256(data).hexdigest(), ext)
def read_bytes(blob_id: str) -> bytes:
return blob_path(blob_id).read_bytes()
"""Content-addressed blob store.
A blob id is ``<sha256><ext>`` (e.g. ``3fa1….webp``); the file lives at
``blobs/3f/a1/<id>``. The extension is part of the id so nginx can serve
blobs with the right Content-Type straight from disk.
"""
import hashlib
import os
import re
import shutil
import tempfile
from pathlib import Path
from app.config import get_settings
BLOB_ID_RE = re.compile(r"^[0-9a-f]{64}(\.[a-z0-9]{1,8})?$")
_CHUNK = 1 << 20
def blob_path(blob_id: str) -> Path:
if not BLOB_ID_RE.match(blob_id):
raise ValueError(f"bad blob id {blob_id!r}")
return get_settings().blobs_dir / blob_id[:2] / blob_id[2:4] / blob_id
def _finalize(tmp: Path, digest: str, ext: str) -> tuple[str, int]:
blob_id = digest + ext.lower()
dest = blob_path(blob_id)
size = tmp.stat().st_size
if dest.exists():
tmp.unlink()
else:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user
os.replace(tmp, dest)
return blob_id, size
def _tmp_file() -> Path:
root = get_settings().blobs_dir / "tmp"
root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=root)
os.close(fd)
return Path(name)
def put_file(src: Path, ext: str, move: bool = False) -> tuple[str, int]:
"""Store a file, returning (blob id, size). ``move`` consumes ``src``."""
h = hashlib.sha256()
with src.open("rb") as f:
while chunk := f.read(_CHUNK):
h.update(chunk)
tmp = _tmp_file()
if move:
shutil.move(src, tmp)
else:
shutil.copyfile(src, tmp)
return _finalize(tmp, h.hexdigest(), ext)
def put_bytes(data: bytes, ext: str) -> tuple[str, int]:
tmp = _tmp_file()
tmp.write_bytes(data)
return _finalize(tmp, hashlib.sha256(data).hexdigest(), ext)
def read_bytes(blob_id: str) -> bytes:
return blob_path(blob_id).read_bytes()
+86 -86
View File
@@ -1,86 +1,86 @@
"""Media conversion into web-friendly formats."""
import shutil
import subprocess
import uuid
from pathlib import Path
from PIL import Image, ImageSequence
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga", ".avif"}
AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".opus", ".flac", ".m4a", ".oga"}
VIDEO_EXTS = {".webm", ".ogv", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".m4v"}
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2", ".ttc"}
WEBP_MAX = 16383
def kind_of(path: Path) -> str | None:
ext = path.suffix.lower()
if ext in IMAGE_EXTS:
return "image"
if ext in AUDIO_EXTS:
return "audio"
if ext in VIDEO_EXTS:
return "video"
if ext in FONT_EXTS:
return "font"
return None
def target_ext(path: Path) -> str:
kind = kind_of(path)
return {"image": ".webp", "audio": ".ogg", "video": ".webm"}.get(kind, path.suffix.lower())
def convert(src: Path, out_dir: Path) -> tuple[Path, str]:
"""Convert one file into out_dir. Returns (output file, extension used for the blob)."""
kind = kind_of(src)
tmp = str(out_dir / uuid.uuid4().hex)
if kind == "image":
return _image(src, Path(tmp))
if kind == "audio":
return _ffmpeg(src, Path(tmp + ".ogg"),
["-vn", "-c:a", "libopus", "-b:a", "112k", "-vbr", "on"]), ".ogg"
if kind == "video":
if src.suffix.lower() == ".webm":
out = Path(tmp + ".webm")
shutil.copyfile(src, out)
return out, ".webm"
return _ffmpeg(src, Path(tmp + ".webm"),
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime",
"-cpu-used", "8", "-row-mt", "1", "-c:a", "libopus", "-b:a", "112k"]
), ".webm"
out = Path(tmp + src.suffix.lower())
shutil.copyfile(src, out)
return out, src.suffix.lower()
def _image(src: Path, tmp: Path) -> tuple[Path, str]:
with Image.open(src) as img:
animated = getattr(img, "is_animated", False)
if max(img.size) > WEBP_MAX:
out = tmp.with_suffix(".png")
img.save(out, "PNG", optimize=False)
return out, ".png"
out = tmp.with_suffix(".webp")
if animated:
frames = [f.convert("RGBA") for f in ImageSequence.Iterator(img)]
frames[0].save(out, "WEBP", save_all=True, append_images=frames[1:], quality=85,
duration=img.info.get("duration", 100), loop=0)
return out, ".webp"
has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
img = img.convert("RGBA" if has_alpha else "RGB")
img.save(out, "WEBP", quality=88, method=4, alpha_quality=95)
return out, ".webp"
def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
proc = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
*args, str(out)],
capture_output=True, text=True, timeout=3600,
)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg: {proc.stderr.strip()[-300:]}")
return out
"""Media conversion into web-friendly formats."""
import shutil
import subprocess
import uuid
from pathlib import Path
from PIL import Image, ImageSequence
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga", ".avif"}
AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".opus", ".flac", ".m4a", ".oga"}
VIDEO_EXTS = {".webm", ".ogv", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".m4v"}
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2", ".ttc"}
WEBP_MAX = 16383
def kind_of(path: Path) -> str | None:
ext = path.suffix.lower()
if ext in IMAGE_EXTS:
return "image"
if ext in AUDIO_EXTS:
return "audio"
if ext in VIDEO_EXTS:
return "video"
if ext in FONT_EXTS:
return "font"
return None
def target_ext(path: Path) -> str:
kind = kind_of(path)
return {"image": ".webp", "audio": ".ogg", "video": ".webm"}.get(kind, path.suffix.lower())
def convert(src: Path, out_dir: Path) -> tuple[Path, str]:
"""Convert one file into out_dir. Returns (output file, extension used for the blob)."""
kind = kind_of(src)
tmp = str(out_dir / uuid.uuid4().hex)
if kind == "image":
return _image(src, Path(tmp))
if kind == "audio":
return _ffmpeg(src, Path(tmp + ".ogg"),
["-vn", "-c:a", "libopus", "-b:a", "112k", "-vbr", "on"]), ".ogg"
if kind == "video":
if src.suffix.lower() == ".webm":
out = Path(tmp + ".webm")
shutil.copyfile(src, out)
return out, ".webm"
return _ffmpeg(src, Path(tmp + ".webm"),
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime",
"-cpu-used", "8", "-row-mt", "1", "-c:a", "libopus", "-b:a", "112k"]
), ".webm"
out = Path(tmp + src.suffix.lower())
shutil.copyfile(src, out)
return out, src.suffix.lower()
def _image(src: Path, tmp: Path) -> tuple[Path, str]:
with Image.open(src) as img:
animated = getattr(img, "is_animated", False)
if max(img.size) > WEBP_MAX:
out = tmp.with_suffix(".png")
img.save(out, "PNG", optimize=False)
return out, ".png"
out = tmp.with_suffix(".webp")
if animated:
frames = [f.convert("RGBA") for f in ImageSequence.Iterator(img)]
frames[0].save(out, "WEBP", save_all=True, append_images=frames[1:], quality=85,
duration=img.info.get("duration", 100), loop=0)
return out, ".webp"
has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
img = img.convert("RGBA" if has_alpha else "RGB")
img.save(out, "WEBP", quality=88, method=4, alpha_quality=95)
return out, ".webp"
def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
proc = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
*args, str(out)],
capture_output=True, text=True, timeout=3600,
)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg: {proc.stderr.strip()[-300:]}")
return out
+189 -189
View File
@@ -1,189 +1,189 @@
"""Translate the subset of Python used by visual novels into PGVN IR.
Expressions become JSON trees ``{"k": kind, ...}``; statements become
``{"op": ..., ...}`` nodes executed by the player's JS interpreter.
Anything outside the subset raises :class:`Unsupported` so the caller can
report it and degrade gracefully.
"""
import ast
from typing import Any
Expr = dict[str, Any]
Stmt = dict[str, Any]
BIN_OPS = {
ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//",
ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^",
ast.LShift: "<<", ast.RShift: ">>",
}
CMP_OPS = {
ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=",
ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not",
}
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"}
class Unsupported(Exception):
pass
def const(v: Any) -> Expr:
return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr:
try:
tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]:
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body)
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
def literal(source: str) -> Any:
"""Evaluate a constant expression at conversion time, or raise Unsupported."""
try:
return ast.literal_eval(source.strip())
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e:
raise Unsupported(f"не константа: {source!r}") from e
def expr(node: ast.AST) -> Expr:
match node:
case ast.Constant(value=v):
if isinstance(v, bytes):
v = v.decode("utf-8", "replace")
if isinstance(v, complex) or v is Ellipsis:
raise Unsupported("неподдерживаемая константа")
return const(v)
case ast.Name(id=name):
return {"k": "name", "id": name}
case ast.Attribute(value=ast.Name(id="store"), attr=attr):
return {"k": "name", "id": attr}
case ast.Attribute(value=value, attr=attr):
return {"k": "attr", "o": expr(value), "a": attr}
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)):
return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)}
case ast.Subscript(value=value, slice=index):
return {"k": "sub", "o": expr(value), "i": expr(index)}
case ast.BinOp(left=l, op=op, right=r):
if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}")
return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)}
case ast.BoolOp(op=op, values=values):
return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or",
"v": [expr(v) for v in values]}
case ast.UnaryOp(op=op, operand=operand):
return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)}
case ast.Compare(left=l, ops=ops, comparators=rs):
return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops],
"r": [expr(r) for r in rs]}
case ast.IfExp(test=t, body=b, orelse=e):
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
case ast.List(elts=elts) | ast.Set(elts=elts):
return {"k": "list", "v": [expr(e) for e in elts]}
case ast.Tuple(elts=elts):
return {"k": "tuple", "v": [expr(e) for e in elts]}
case ast.Dict(keys=keys, values=values):
if any(k is None for k in keys):
raise Unsupported("распаковка ** в словаре")
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
case ast.Call(func=func, args=args, keywords=kws):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}}
case ast.JoinedStr(values=values):
parts: list[Any] = []
for v in values:
if isinstance(v, ast.Constant):
parts.append(v.value)
elif isinstance(v, ast.FormattedValue):
parts.append(expr(v.value))
return {"k": "fmt", "v": parts}
case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
ifs=ifs, is_async=0)])
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
raise Unsupported("lambda")
raise Unsupported(f"выражение {type(node).__name__}")
def _opt(node: ast.AST | None) -> Expr | None:
return None if node is None else expr(node)
def target(node: ast.AST) -> Expr:
match node:
case ast.Name() | ast.Attribute() | ast.Subscript():
return expr(node)
case ast.Tuple(elts=elts) | ast.List(elts=elts):
return {"k": "tuple", "v": [target(e) for e in elts]}
raise Unsupported(f"присваивание в {type(node).__name__}")
def stmts(nodes: list[ast.stmt]) -> list[Stmt]:
out: list[Stmt] = []
for n in nodes:
out.extend(stmt(n))
return out
def stmt(node: ast.stmt) -> list[Stmt]:
match node:
case ast.Assign(targets=targets, value=value):
v = expr(value)
return [{"op": "set", "t": target(t), "v": v} for t in targets]
case ast.AnnAssign(target=t, value=value) if value is not None:
return [{"op": "set", "t": target(t), "v": expr(value)}]
case ast.AugAssign(target=t, op=op, value=value):
if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}")
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
case ast.Expr(value=ast.Constant()):
return [] # docstring / bare literal
case ast.Expr(value=value):
return [{"op": "eval", "e": expr(value)}]
case ast.If(test=test, body=body, orelse=orelse):
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
case ast.While(test=test, body=body, orelse=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal():
return []
case ast.Break():
return [{"op": "break"}]
case ast.Continue():
return [{"op": "continue"}]
case ast.Import(names=names) | ast.ImportFrom(names=names):
mod = getattr(node, "module", None) or names[0].name
if mod.split(".")[0] in KNOWN_MODULES:
return []
raise Unsupported(f"импорт модуля {mod}")
case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}")
"""Translate the subset of Python used by visual novels into PGVN IR.
Expressions become JSON trees ``{"k": kind, ...}``; statements become
``{"op": ..., ...}`` nodes executed by the player's JS interpreter.
Anything outside the subset raises :class:`Unsupported` so the caller can
report it and degrade gracefully.
"""
import ast
from typing import Any
Expr = dict[str, Any]
Stmt = dict[str, Any]
BIN_OPS = {
ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//",
ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^",
ast.LShift: "<<", ast.RShift: ">>",
}
CMP_OPS = {
ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=",
ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not",
}
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"}
class Unsupported(Exception):
pass
def const(v: Any) -> Expr:
return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr:
try:
tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]:
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body)
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
def literal(source: str) -> Any:
"""Evaluate a constant expression at conversion time, or raise Unsupported."""
try:
return ast.literal_eval(source.strip())
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e:
raise Unsupported(f"не константа: {source!r}") from e
def expr(node: ast.AST) -> Expr:
match node:
case ast.Constant(value=v):
if isinstance(v, bytes):
v = v.decode("utf-8", "replace")
if isinstance(v, complex) or v is Ellipsis:
raise Unsupported("неподдерживаемая константа")
return const(v)
case ast.Name(id=name):
return {"k": "name", "id": name}
case ast.Attribute(value=ast.Name(id="store"), attr=attr):
return {"k": "name", "id": attr}
case ast.Attribute(value=value, attr=attr):
return {"k": "attr", "o": expr(value), "a": attr}
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)):
return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)}
case ast.Subscript(value=value, slice=index):
return {"k": "sub", "o": expr(value), "i": expr(index)}
case ast.BinOp(left=l, op=op, right=r):
if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}")
return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)}
case ast.BoolOp(op=op, values=values):
return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or",
"v": [expr(v) for v in values]}
case ast.UnaryOp(op=op, operand=operand):
return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)}
case ast.Compare(left=l, ops=ops, comparators=rs):
return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops],
"r": [expr(r) for r in rs]}
case ast.IfExp(test=t, body=b, orelse=e):
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
case ast.List(elts=elts) | ast.Set(elts=elts):
return {"k": "list", "v": [expr(e) for e in elts]}
case ast.Tuple(elts=elts):
return {"k": "tuple", "v": [expr(e) for e in elts]}
case ast.Dict(keys=keys, values=values):
if any(k is None for k in keys):
raise Unsupported("распаковка ** в словаре")
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
case ast.Call(func=func, args=args, keywords=kws):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}}
case ast.JoinedStr(values=values):
parts: list[Any] = []
for v in values:
if isinstance(v, ast.Constant):
parts.append(v.value)
elif isinstance(v, ast.FormattedValue):
parts.append(expr(v.value))
return {"k": "fmt", "v": parts}
case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
ifs=ifs, is_async=0)])
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
raise Unsupported("lambda")
raise Unsupported(f"выражение {type(node).__name__}")
def _opt(node: ast.AST | None) -> Expr | None:
return None if node is None else expr(node)
def target(node: ast.AST) -> Expr:
match node:
case ast.Name() | ast.Attribute() | ast.Subscript():
return expr(node)
case ast.Tuple(elts=elts) | ast.List(elts=elts):
return {"k": "tuple", "v": [target(e) for e in elts]}
raise Unsupported(f"присваивание в {type(node).__name__}")
def stmts(nodes: list[ast.stmt]) -> list[Stmt]:
out: list[Stmt] = []
for n in nodes:
out.extend(stmt(n))
return out
def stmt(node: ast.stmt) -> list[Stmt]:
match node:
case ast.Assign(targets=targets, value=value):
v = expr(value)
return [{"op": "set", "t": target(t), "v": v} for t in targets]
case ast.AnnAssign(target=t, value=value) if value is not None:
return [{"op": "set", "t": target(t), "v": expr(value)}]
case ast.AugAssign(target=t, op=op, value=value):
if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}")
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
case ast.Expr(value=ast.Constant()):
return [] # docstring / bare literal
case ast.Expr(value=value):
return [{"op": "eval", "e": expr(value)}]
case ast.If(test=test, body=body, orelse=orelse):
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
case ast.While(test=test, body=body, orelse=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal():
return []
case ast.Break():
return [{"op": "break"}]
case ast.Continue():
return [{"op": "continue"}]
case ast.Import(names=names) | ast.ImportFrom(names=names):
mod = getattr(node, "module", None) or names[0].name
if mod.split(".")[0] in KNOWN_MODULES:
return []
raise Unsupported(f"импорт модуля {mod}")
case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}")
+109 -109
View File
@@ -1,109 +1,109 @@
"""Reader for Ren'Py .rpa archives (RPA-2.0, RPA-3.x and common variants).
The index is a zlib-compressed pickle, so it is loaded with a restricted
unpickler that only allows plain containers — archives are untrusted input.
"""
import io
import pickle
import zlib
from collections.abc import Callable
from pathlib import Path
class RpaError(Exception):
pass
class _SafeUnpickler(pickle.Unpickler):
_ALLOWED = {("_codecs", "encode"), ("builtins", "set"), ("builtins", "frozenset"),
("__builtin__", "set"), ("builtins", "bytearray"), ("__builtin__", "bytearray"),
("builtins", "bytes"), ("__builtin__", "bytes")}
def find_class(self, module, name):
if (module, name) in self._ALLOWED:
import builtins
import codecs
return codecs.encode if module == "_codecs" else getattr(builtins, name)
raise pickle.UnpicklingError(f"запрещённый тип в индексе RPA: {module}.{name}")
def _text(v) -> str:
return v.decode("utf-8", "replace") if isinstance(v, bytes) else str(v)
def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]:
with path.open("rb") as f:
header = f.readline(200).decode("latin-1").strip()
parts = header.split()
if not parts or not parts[0].startswith("RPA-"):
raise RpaError(f"{path.name}: неизвестный формат архива ({header[:20]!r})")
version = parts[0]
try:
offset = int(parts[1], 16)
key = 0
if version != "RPA-2.0":
for token in parts[2:]:
key ^= int(token, 16)
except (IndexError, ValueError) as e:
raise RpaError(f"{path.name}: повреждён заголовок") from e
f.seek(offset)
try:
raw = _SafeUnpickler(io.BytesIO(zlib.decompress(f.read())), encoding="bytes").load()
except (zlib.error, pickle.UnpicklingError, EOFError) as e:
raise RpaError(f"{path.name}: не удалось прочитать индекс: {e}") from e
index: dict[str, list[tuple[int, int, bytes]]] = {}
for name, entries in raw.items():
fixed = []
for entry in entries:
off, length = entry[0] ^ key, entry[1] ^ key
prefix = entry[2] if len(entry) > 2 else b""
if isinstance(prefix, str):
prefix = prefix.encode("latin-1")
fixed.append((off, length, prefix))
index[_text(name)] = fixed
return index
def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int:
"""Extract files of an archive into dest (all, or those `only` accepts)."""
index = read_index(path)
root = dest.resolve()
count = 0
with path.open("rb") as f:
for name, entries in index.items():
if only is not None and not only(name):
continue
target = (dest / name).resolve()
if not target.is_relative_to(root):
continue
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as out:
for off, length, prefix in entries:
out.write(prefix)
f.seek(off)
remaining = length - len(prefix)
while remaining > 0:
chunk = f.read(min(remaining, 1 << 20))
if not chunk:
break
out.write(chunk)
remaining -= len(chunk)
count += 1
return count
def write_rpa(path: Path, files: dict[str, bytes], key: int = 0x42424242) -> None:
"""Write an RPA-3.0 archive (used by tests)."""
with path.open("wb") as f:
f.write(b"RPA-3.0 " + b"0" * 16 + b" " + b"0" * 8 + b"\n")
index = {}
for name, data in files.items():
off = f.tell()
f.write(data)
index[name] = [(off ^ key, len(data) ^ key, b"")]
idx_off = f.tell()
f.write(zlib.compress(pickle.dumps(index, protocol=2)))
f.seek(0)
f.write(f"RPA-3.0 {idx_off:016x} {key:08x}\n".encode())
"""Reader for Ren'Py .rpa archives (RPA-2.0, RPA-3.x and common variants).
The index is a zlib-compressed pickle, so it is loaded with a restricted
unpickler that only allows plain containers — archives are untrusted input.
"""
import io
import pickle
import zlib
from collections.abc import Callable
from pathlib import Path
class RpaError(Exception):
pass
class _SafeUnpickler(pickle.Unpickler):
_ALLOWED = {("_codecs", "encode"), ("builtins", "set"), ("builtins", "frozenset"),
("__builtin__", "set"), ("builtins", "bytearray"), ("__builtin__", "bytearray"),
("builtins", "bytes"), ("__builtin__", "bytes")}
def find_class(self, module, name):
if (module, name) in self._ALLOWED:
import builtins
import codecs
return codecs.encode if module == "_codecs" else getattr(builtins, name)
raise pickle.UnpicklingError(f"запрещённый тип в индексе RPA: {module}.{name}")
def _text(v) -> str:
return v.decode("utf-8", "replace") if isinstance(v, bytes) else str(v)
def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]:
with path.open("rb") as f:
header = f.readline(200).decode("latin-1").strip()
parts = header.split()
if not parts or not parts[0].startswith("RPA-"):
raise RpaError(f"{path.name}: неизвестный формат архива ({header[:20]!r})")
version = parts[0]
try:
offset = int(parts[1], 16)
key = 0
if version != "RPA-2.0":
for token in parts[2:]:
key ^= int(token, 16)
except (IndexError, ValueError) as e:
raise RpaError(f"{path.name}: повреждён заголовок") from e
f.seek(offset)
try:
raw = _SafeUnpickler(io.BytesIO(zlib.decompress(f.read())), encoding="bytes").load()
except (zlib.error, pickle.UnpicklingError, EOFError) as e:
raise RpaError(f"{path.name}: не удалось прочитать индекс: {e}") from e
index: dict[str, list[tuple[int, int, bytes]]] = {}
for name, entries in raw.items():
fixed = []
for entry in entries:
off, length = entry[0] ^ key, entry[1] ^ key
prefix = entry[2] if len(entry) > 2 else b""
if isinstance(prefix, str):
prefix = prefix.encode("latin-1")
fixed.append((off, length, prefix))
index[_text(name)] = fixed
return index
def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int:
"""Extract files of an archive into dest (all, or those `only` accepts)."""
index = read_index(path)
root = dest.resolve()
count = 0
with path.open("rb") as f:
for name, entries in index.items():
if only is not None and not only(name):
continue
target = (dest / name).resolve()
if not target.is_relative_to(root):
continue
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as out:
for off, length, prefix in entries:
out.write(prefix)
f.seek(off)
remaining = length - len(prefix)
while remaining > 0:
chunk = f.read(min(remaining, 1 << 20))
if not chunk:
break
out.write(chunk)
remaining -= len(chunk)
count += 1
return count
def write_rpa(path: Path, files: dict[str, bytes], key: int = 0x42424242) -> None:
"""Write an RPA-3.0 archive (used by tests)."""
with path.open("wb") as f:
f.write(b"RPA-3.0 " + b"0" * 16 + b" " + b"0" * 8 + b"\n")
index = {}
for name, data in files.items():
off = f.tell()
f.write(data)
index[name] = [(off ^ key, len(data) ^ key, b"")]
idx_off = f.tell()
f.write(zlib.compress(pickle.dumps(index, protocol=2)))
f.seek(0)
f.write(f"RPA-3.0 {idx_off:016x} {key:08x}\n".encode())
+48 -48
View File
@@ -1,48 +1,48 @@
"""Import an already unpacked game from a folder on the server.
python -m app.import_dir /games/Some-Game [--title "Name"] [--owner admin]
The folder is only read; the converted copy goes to the blob store like an upload.
"""
import argparse
import asyncio
from pathlib import Path
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Game, GameVersion, Role, User
from app.worker import convert
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("path", type=Path)
ap.add_argument("--title")
ap.add_argument("--owner", help="username; defaults to the first admin")
args = ap.parse_args()
if not args.path.is_dir():
raise SystemExit(f"{args.path} is not a directory")
async with SessionLocal() as db:
stmt = select(User).join(Role).order_by(User.id)
stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin")
owner = await db.scalar(stmt.limit(1))
if owner is None:
raise SystemExit("owner not found")
game = Game(title=args.title or args.path.name, created_by_id=owner.id)
db.add(game)
await db.flush()
version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал",
upload_name=args.path.name, status="importing")
db.add(version)
await db.commit()
game_id, version_id = game.id, version.id
ok = await convert(version_id, args.path, args.path.name)
print(f"game {game_id}, version {version_id}: {'ready' if ok else 'failed'}")
if __name__ == "__main__":
asyncio.run(main())
"""Import an already unpacked game from a folder on the server.
python -m app.import_dir /games/Some-Game [--title "Name"] [--owner admin]
The folder is only read; the converted copy goes to the blob store like an upload.
"""
import argparse
import asyncio
from pathlib import Path
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Game, GameVersion, Role, User
from app.worker import convert
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("path", type=Path)
ap.add_argument("--title")
ap.add_argument("--owner", help="username; defaults to the first admin")
args = ap.parse_args()
if not args.path.is_dir():
raise SystemExit(f"{args.path} is not a directory")
async with SessionLocal() as db:
stmt = select(User).join(Role).order_by(User.id)
stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin")
owner = await db.scalar(stmt.limit(1))
if owner is None:
raise SystemExit("owner not found")
game = Game(title=args.title or args.path.name, created_by_id=owner.id)
db.add(game)
await db.flush()
version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал",
upload_name=args.path.name, status="importing")
db.add(version)
await db.commit()
game_id, version_id = game.id, version.id
ok = await convert(version_id, args.path, args.path.name)
print(f"game {game_id}, version {version_id}: {'ready' if ok else 'failed'}")
if __name__ == "__main__":
asyncio.run(main())
+41 -41
View File
@@ -1,41 +1,41 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap
from app.config import get_settings
from app.db import SessionLocal
from app.routers import auth, games, roles, users, versions
logging.basicConfig(level=logging.INFO)
@asynccontextmanager
async def lifespan(_: FastAPI):
if get_settings().secret_key == "change-me":
logging.getLogger(__name__).warning("SECRET_KEY is the default value — set it in .env")
async with SessionLocal() as db:
await bootstrap(db)
yield
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
openapi_url="/api/openapi.json")
for r in (auth, users, roles, games, versions):
app.include_router(r.router)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/blobs/{blob_id}")
async def blob(blob_id: str) -> FileResponse:
"""Fallback for running without nginx; in compose nginx serves /blobs itself."""
if not BLOB_ID_RE.match(blob_id) or not (path := blob_path(blob_id)).is_file():
raise HTTPException(404)
return FileResponse(path, headers={"Cache-Control": "public, max-age=31536000, immutable"})
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap
from app.config import get_settings
from app.db import SessionLocal
from app.routers import auth, games, roles, users, versions
logging.basicConfig(level=logging.INFO)
@asynccontextmanager
async def lifespan(_: FastAPI):
if get_settings().secret_key == "change-me":
logging.getLogger(__name__).warning("SECRET_KEY is the default value — set it in .env")
async with SessionLocal() as db:
await bootstrap(db)
yield
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
openapi_url="/api/openapi.json")
for r in (auth, users, roles, games, versions):
app.include_router(r.router)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/blobs/{blob_id}")
async def blob(blob_id: str) -> FileResponse:
"""Fallback for running without nginx; in compose nginx serves /blobs itself."""
if not BLOB_ID_RE.match(blob_id) or not (path := blob_path(blob_id)).is_file():
raise HTTPException(404)
return FileResponse(path, headers={"Cache-Control": "public, max-age=31536000, immutable"})
+99 -99
View File
@@ -1,99 +1,99 @@
from datetime import datetime
from typing import Any
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_username: Mapped[bool] = mapped_column(Boolean, default=False)
token_version: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
role: Mapped[Role] = relationship(lazy="joined")
@property
def permissions(self) -> list[str]:
return sorted(self.role.permissions) if self.is_active else []
class Game(Base):
__tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80))
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_by: Mapped[User | None] = relationship(lazy="joined")
class GameVersion(Base):
__tablename__ = "game_versions"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL"))
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner: Mapped[User | None] = relationship(lazy="joined")
class Save(Base):
__tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"))
slot: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(255), default="")
data: Mapped[dict[str, Any]] = mapped_column(JSONB)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
from datetime import datetime
from typing import Any
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_username: Mapped[bool] = mapped_column(Boolean, default=False)
token_version: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
role: Mapped[Role] = relationship(lazy="joined")
@property
def permissions(self) -> list[str]:
return sorted(self.role.permissions) if self.is_active else []
class Game(Base):
__tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80))
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_by: Mapped[User | None] = relationship(lazy="joined")
class GameVersion(Base):
__tablename__ = "game_versions"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL"))
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner: Mapped[User | None] = relationship(lazy="joined")
class Save(Base):
__tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"))
slot: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(255), default="")
data: Mapped[dict[str, Any]] = mapped_column(JSONB)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+205 -205
View File
@@ -1,205 +1,205 @@
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require
from app.models import GameVersion, Save, User
from app.routers.games import get_game
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"])
Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def can_see(user: User, v: GameVersion) -> bool:
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
def can_manage(user: User, v: GameVersion) -> bool:
return (
v.owner_id == user.id
or has_perm(user, "versions.manage_any")
or has_perm(user, "games.delete")
)
async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await db.get(GameVersion, version_id)
if not v or not can_see(user, v):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
return v
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
async def upload_version(
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
) -> GameVersion:
"""Stream a raw archive body to disk and queue it for conversion."""
game = await get_game(db, game_id)
if not filename.lower().endswith(ARCHIVE_EXTS):
raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
upload_name=filename, status="uploading")
db.add(version)
await db.commit()
path = upload_path(version.id)
path.parent.mkdir(parents=True, exist_ok=True)
size = 0
try:
with path.open("wb") as f:
async for chunk in request.stream():
f.write(chunk)
size += len(chunk)
except Exception as e:
remove_upload(version.id)
await db.delete(version)
await db.commit()
if isinstance(e, ClientDisconnect):
# usually a proxy in front cut the body off (e.g. its own 413)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None
raise
if size == 0:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
version.status = "queued"
version.progress = "В очереди на конвертацию"
version.size_bytes = size
await db.commit()
return version
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
return await get_version(db, user, version_id)
@router.get("/versions/{version_id}/manifest")
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
async def fork_version(version_id: int, body: VersionIn,
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
db: DB) -> GameVersion:
parent = await get_version(db, user, version_id)
if parent.status != "ready":
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
fork = GameVersion(
game_id=parent.game_id,
parent_id=parent.id,
owner_id=user.id,
name=body.name.strip(),
description=body.description,
status="ready",
manifest=parent.manifest,
report=parent.report,
size_bytes=parent.size_bytes,
)
db.add(fork)
await db.commit()
return fork
@router.patch("/versions/{version_id}", response_model=VersionOut)
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
v.name = body.name.strip()
v.description = body.description
await db.commit()
return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status != "failed" or not upload_path(v.id).exists():
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
v.status = "queued"
v.progress = "В очереди на конвертацию"
await db.commit()
return v
@router.delete("/versions/{version_id}", status_code=204)
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status in ("converting", "importing"):
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
await db.delete(v)
await db.commit()
remove_upload(version_id)
# --- saves -----------------------------------------------------------------
def _check_slot(slot: str) -> str:
if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
return slot
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
await get_version(db, user, version_id)
return list((await db.scalars(
select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
.order_by(Save.updated_at.desc())
)).all())
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
return await db.scalar(select(Save).where(
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot)))
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot)
if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
return save
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut)
async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save:
await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot)
if save is None:
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
data=body.data)
db.add(save)
else:
save.title = body.title[:255]
save.data = body.data
await db.commit()
await db.refresh(save)
return save
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
save = await _find_save(db, user, version_id, slot)
if save:
await db.delete(save)
await db.commit()
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require
from app.models import GameVersion, Save, User
from app.routers.games import get_game
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"])
Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def can_see(user: User, v: GameVersion) -> bool:
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
def can_manage(user: User, v: GameVersion) -> bool:
return (
v.owner_id == user.id
or has_perm(user, "versions.manage_any")
or has_perm(user, "games.delete")
)
async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await db.get(GameVersion, version_id)
if not v or not can_see(user, v):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
return v
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
async def upload_version(
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
) -> GameVersion:
"""Stream a raw archive body to disk and queue it for conversion."""
game = await get_game(db, game_id)
if not filename.lower().endswith(ARCHIVE_EXTS):
raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
upload_name=filename, status="uploading")
db.add(version)
await db.commit()
path = upload_path(version.id)
path.parent.mkdir(parents=True, exist_ok=True)
size = 0
try:
with path.open("wb") as f:
async for chunk in request.stream():
f.write(chunk)
size += len(chunk)
except Exception as e:
remove_upload(version.id)
await db.delete(version)
await db.commit()
if isinstance(e, ClientDisconnect):
# usually a proxy in front cut the body off (e.g. its own 413)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None
raise
if size == 0:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
version.status = "queued"
version.progress = "В очереди на конвертацию"
version.size_bytes = size
await db.commit()
return version
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
return await get_version(db, user, version_id)
@router.get("/versions/{version_id}/manifest")
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
async def fork_version(version_id: int, body: VersionIn,
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
db: DB) -> GameVersion:
parent = await get_version(db, user, version_id)
if parent.status != "ready":
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
fork = GameVersion(
game_id=parent.game_id,
parent_id=parent.id,
owner_id=user.id,
name=body.name.strip(),
description=body.description,
status="ready",
manifest=parent.manifest,
report=parent.report,
size_bytes=parent.size_bytes,
)
db.add(fork)
await db.commit()
return fork
@router.patch("/versions/{version_id}", response_model=VersionOut)
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
v.name = body.name.strip()
v.description = body.description
await db.commit()
return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status != "failed" or not upload_path(v.id).exists():
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
v.status = "queued"
v.progress = "В очереди на конвертацию"
await db.commit()
return v
@router.delete("/versions/{version_id}", status_code=204)
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status in ("converting", "importing"):
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
await db.delete(v)
await db.commit()
remove_upload(version_id)
# --- saves -----------------------------------------------------------------
def _check_slot(slot: str) -> str:
if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
return slot
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
await get_version(db, user, version_id)
return list((await db.scalars(
select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
.order_by(Save.updated_at.desc())
)).all())
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
return await db.scalar(select(Save).where(
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot)))
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot)
if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
return save
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut)
async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save:
await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot)
if save is None:
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
data=body.data)
db.add(save)
else:
save.title = body.title[:255]
save.data = body.data
await db.commit()
await db.refresh(save)
return save
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
save = await _find_save(db, user, version_id, slot)
if save:
await db.delete(save)
await db.commit()
+116 -116
View File
@@ -1,116 +1,116 @@
"""Conversion worker: polls the DB for queued versions and converts them."""
import asyncio
import logging
import shutil
from pathlib import Path
from sqlalchemy import select, update
from app.blobs import read_bytes
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.models import Game, GameVersion
from app.routers.games import make_cover
from app.uploads import remove_upload, upload_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("worker")
async def set_progress(version_id: int, text: str) -> None:
async with SessionLocal() as db:
await db.execute(update(GameVersion).where(GameVersion.id == version_id)
.values(progress=text[:255]))
await db.commit()
async def claim() -> GameVersion | None:
async with SessionLocal() as db:
version_id = await db.scalar(
select(GameVersion.id).where(GameVersion.status == "queued").order_by(GameVersion.id)
.limit(1).with_for_update(skip_locked=True)
)
if version_id is None:
return None
v = await db.get(GameVersion, version_id)
v.status = "converting"
v.progress = "Начало конвертации"
await db.commit()
return v
async def process(v: GameVersion) -> None:
ok = await convert(v.id, upload_path(v.id), v.upload_name or "")
if ok:
remove_upload(v.id) # sources are not kept once converted
async def convert(version_id: int, source: Path, filename: str) -> bool:
"""Run the converter for a version and store the outcome. Returns success."""
loop = asyncio.get_running_loop()
work = get_settings().work_dir / str(version_id)
def progress(text: str) -> None:
asyncio.run_coroutine_threadsafe(set_progress(version_id, text), loop)
log.info("converting version %s (%s)", version_id, filename)
try:
result = await asyncio.to_thread(convert_archive, source, filename, work, progress)
except Exception as e: # noqa: BLE001
log.exception("version %s failed", version_id)
async with SessionLocal() as db:
version = await db.get(GameVersion, version_id)
if version:
version.status = "failed"
version.progress = f"Ошибка: {e}"[:255]
version.report = {"error": str(e)}
await db.commit()
return False
finally:
shutil.rmtree(work, ignore_errors=True)
async with SessionLocal() as db:
version = await db.get(GameVersion, version_id)
if version is None: # deleted while converting
return True
version.status = "ready"
version.progress = ""
version.manifest = result.manifest
version.report = result.report
version.size_bytes = result.size
game = await db.get(Game, version.game_id)
if game and not game.cover_hash and result.cover_source:
try:
game.cover_hash = await asyncio.to_thread(make_cover,
read_bytes(result.cover_source))
except OSError:
pass
if game and result.title and game.title == source.name:
game.title = result.title
await db.commit()
log.info("version %s ready: %s", version_id, result.report.get("media"))
return True
async def main() -> None:
settings = get_settings()
for d in (settings.blobs_dir, settings.uploads_dir, settings.work_dir):
d.mkdir(parents=True, exist_ok=True)
async with SessionLocal() as db:
# jobs interrupted by a restart go back to the queue
await db.execute(update(GameVersion).where(GameVersion.status == "converting")
.values(status="queued"))
await db.commit()
log.info("worker started")
while True:
v = await claim()
if v is None:
await asyncio.sleep(settings.worker_poll_seconds)
continue
await process(v)
if __name__ == "__main__":
asyncio.run(main())
"""Conversion worker: polls the DB for queued versions and converts them."""
import asyncio
import logging
import shutil
from pathlib import Path
from sqlalchemy import select, update
from app.blobs import read_bytes
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.models import Game, GameVersion
from app.routers.games import make_cover
from app.uploads import remove_upload, upload_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("worker")
async def set_progress(version_id: int, text: str) -> None:
async with SessionLocal() as db:
await db.execute(update(GameVersion).where(GameVersion.id == version_id)
.values(progress=text[:255]))
await db.commit()
async def claim() -> GameVersion | None:
async with SessionLocal() as db:
version_id = await db.scalar(
select(GameVersion.id).where(GameVersion.status == "queued").order_by(GameVersion.id)
.limit(1).with_for_update(skip_locked=True)
)
if version_id is None:
return None
v = await db.get(GameVersion, version_id)
v.status = "converting"
v.progress = "Начало конвертации"
await db.commit()
return v
async def process(v: GameVersion) -> None:
ok = await convert(v.id, upload_path(v.id), v.upload_name or "")
if ok:
remove_upload(v.id) # sources are not kept once converted
async def convert(version_id: int, source: Path, filename: str) -> bool:
"""Run the converter for a version and store the outcome. Returns success."""
loop = asyncio.get_running_loop()
work = get_settings().work_dir / str(version_id)
def progress(text: str) -> None:
asyncio.run_coroutine_threadsafe(set_progress(version_id, text), loop)
log.info("converting version %s (%s)", version_id, filename)
try:
result = await asyncio.to_thread(convert_archive, source, filename, work, progress)
except Exception as e: # noqa: BLE001
log.exception("version %s failed", version_id)
async with SessionLocal() as db:
version = await db.get(GameVersion, version_id)
if version:
version.status = "failed"
version.progress = f"Ошибка: {e}"[:255]
version.report = {"error": str(e)}
await db.commit()
return False
finally:
shutil.rmtree(work, ignore_errors=True)
async with SessionLocal() as db:
version = await db.get(GameVersion, version_id)
if version is None: # deleted while converting
return True
version.status = "ready"
version.progress = ""
version.manifest = result.manifest
version.report = result.report
version.size_bytes = result.size
game = await db.get(Game, version.game_id)
if game and not game.cover_hash and result.cover_source:
try:
game.cover_hash = await asyncio.to_thread(make_cover,
read_bytes(result.cover_source))
except OSError:
pass
if game and result.title and game.title == source.name:
game.title = result.title
await db.commit()
log.info("version %s ready: %s", version_id, result.report.get("media"))
return True
async def main() -> None:
settings = get_settings()
for d in (settings.blobs_dir, settings.uploads_dir, settings.work_dir):
d.mkdir(parents=True, exist_ok=True)
async with SessionLocal() as db:
# jobs interrupted by a restart go back to the queue
await db.execute(update(GameVersion).where(GameVersion.status == "converting")
.values(status="queued"))
await db.commit()
log.info("worker started")
while True:
v = await claim()
if v is None:
await asyncio.sleep(settings.worker_poll_seconds)
continue
await process(v)
if __name__ == "__main__":
asyncio.run(main())