import asyncio, os, shutil, time, uuid, zipfile from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, HTMLResponse import uvicorn app = FastAPI() BASE = Path("/tmp/fb") JOBS_DIR = BASE / "jobs" JARS_DIR = BASE / "jars" CACHE_DIR = BASE / "gradle-cache" for d in [JOBS_DIR, JARS_DIR, CACHE_DIR]: d.mkdir(parents=True, exist_ok=True) jobs: dict = {} MAX_ZIP = 50 * 1024 * 1024 BAD_EXT = {".exe", ".sh", ".bat", ".ps1", ".cmd", ".php"} JAVA_HOME = "/opt/java/openjdk" HTML = open("/app/index.html").read() if Path("/app/index.html").exists() else "" # ── Health ──────────────────────────────────────────────────────────────────── @app.get("/health") async def health(): return {"status": "ok"} # ── Frontend ────────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def index(): return HTML # ── Validate ZIP ────────────────────────────────────────────────────────────── def validate_zip(path: Path): try: with zipfile.ZipFile(path) as zf: names = zf.namelist() if not any("build.gradle" in n for n in names): return False, "ZIP не содержит build.gradle" if not any("src/" in n for n in names): return False, "ZIP не содержит папку src/" for n in names: if ".." in n or n.startswith("/"): return False, "Подозрительный путь: " + n if Path(n).suffix.lower() in BAD_EXT: return False, "Запрещённый файл: " + n total = sum(i.file_size for i in zf.infolist()) if total > 200_000_000: return False, "Распакованный размер > 200 MB" return True, "" except zipfile.BadZipFile: return False, "Повреждённый ZIP" # ── POST /build ─────────────────────────────────────────────────────────────── @app.post("/build") async def build(file: UploadFile = File(...), version: str = Form(...)): if version not in ("1.12.2", "1.20.1"): raise HTTPException(400, "Неподдерживаемая версия") data = await file.read() if len(data) > MAX_ZIP: raise HTTPException(400, "Файл > 50 MB") if not file.filename.endswith(".zip"): raise HTTPException(400, "Нужен ZIP файл") jid = str(uuid.uuid4())[:8] job_dir = JOBS_DIR / jid zip_p = job_dir / "src.zip" job_dir.mkdir(parents=True) zip_p.write_bytes(data) ok, err = validate_zip(zip_p) if not ok: shutil.rmtree(job_dir, ignore_errors=True) raise HTTPException(400, err) jobs[jid] = { "status": "queued", "progress": 5, "step": "В очереди...", "logs": [], "error": None, "jar_url": None, "jar_name": None, "build_time": None } asyncio.create_task(run_build(jid, version, zip_p)) return {"job_id": jid} # ── GET /status ─────────────────────────────────────────────────────────────── @app.get("/status/{jid}") async def status(jid: str): if jid not in jobs: raise HTTPException(404, "Job не найден") j = jobs[jid] logs = j["logs"][:] j["logs"] = [] return {**j, "logs": logs} # ── GET /download ───────────────────────────────────────────────────────────── @app.get("/download/{jid}/{name}") async def download(jid: str, name: str): p = JARS_DIR / jid / name if not p.exists(): raise HTTPException(404, "Файл не найден") return FileResponse(p, filename=name, media_type="application/java-archive") # ── Build worker ────────────────────────────────────────────────────────────── async def run_build(jid: str, version: str, zip_p: Path): j = jobs[jid] jdir = zip_p.parent t0 = time.time() def log(m): j["logs"].append(m) def st(s, p, step=""): j["status"] = s j["progress"] = p j["step"] = step try: # Extract st("running", 15, "Распаковка ZIP...") log("[INFO] Распаковка ZIP...") src = jdir / "extracted" src.mkdir() with zipfile.ZipFile(zip_p) as zf: zf.extractall(src) # Find build.gradle roots = list(src.rglob("build.gradle")) if not roots: raise RuntimeError("build.gradle не найден") root = roots[0].parent log("[INFO] Корень проекта: " + root.name) if not (root / "src" / "main" / "java").exists(): raise RuntimeError("src/main/java не найдена") # Copy to writable dir st("running", 22, "Подготовка...") build_dir = jdir / "workspace" shutil.copytree(root, build_dir) # Environment env = os.environ.copy() env["JAVA_HOME"] = JAVA_HOME env["PATH"] = JAVA_HOME + "/bin:" + env.get("PATH", "") env["GRADLE_USER_HOME"] = str(CACHE_DIR) env["GRADLE_OPTS"] = "-Xmx1g -Xms128m" gradle = ["gradle"] # Inject Forge if missing bg = (build_dir / "build.gradle").read_text(errors="replace") if "minecraftforge" not in bg and "ForgeGradle" not in bg: log("[WARN] Forge не найден в build.gradle — добавляем автоматически") (build_dir / "build.gradle").write_text( forge_snippet(version) + "\n\n" + bg ) # Build — Forge 1.20.1 downloads everything automatically on first build st("running", 30, "Скачивание Forge + компиляция (первый раз ~10 мин)...") log("[INFO] Запуск: gradle build") await run_gradle(gradle + ["build", "--no-daemon", "--stacktrace"], build_dir, env, j) # Collect JARs st("running", 92, "Сбор JAR...") out = JARS_DIR / jid out.mkdir(parents=True) jars = [ f for f in (build_dir / "build" / "libs").glob("*.jar") if not any(s in f.name for s in ["-sources", "-dev", "-javadoc"]) ] if not jars: raise RuntimeError("JAR не создан — проверь build.gradle") jar = jars[0] shutil.copy(jar, out / jar.name) secs = int(time.time() - t0) log("[SUCCESS] BUILD SUCCESSFUL — " + jar.name) log("[INFO] Время: " + str(secs) + " сек") j["status"] = "done" j["progress"] = 100 j["step"] = "Готово!" j["jar_url"] = "/download/" + jid + "/" + jar.name j["jar_name"] = jar.name j["build_time"] = str(secs) + " сек" asyncio.create_task(cleanup(jid)) except Exception as e: log("[ERROR] " + str(e)) j["status"] = "error" j["error"] = str(e) finally: shutil.rmtree(jdir, ignore_errors=True) async def run_gradle(cmd, cwd, env, j): proc = await asyncio.create_subprocess_exec( *cmd, cwd=str(cwd), env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) while True: line = await proc.stdout.readline() if not line: break txt = line.decode("utf-8", errors="replace").rstrip() if txt: j["logs"].append(txt) await proc.wait() if proc.returncode != 0: raise RuntimeError("Gradle вернул ошибку. Проверь логи выше.") def forge_snippet(version: str) -> str: if version == "1.12.2": return """buildscript { repositories { maven { url = 'https://maven.minecraftforge.net' } mavenCentral() } dependencies { classpath 'net.minecraftforge.gradle:ForgeGradle:2.3-SNAPSHOT' } } apply plugin: 'net.minecraftforge.gradle.forge' apply plugin: 'java' minecraft { version = "1.12.2-14.23.5.2860" runDir = "run" mappings = "stable_39" }""" return """plugins { id 'net.minecraftforge.gradle' version '6.0.+' id 'java' } minecraft { mappings channel: 'official', version: '1.20.1' runs { client { workingDirectory project.file('run') } } } dependencies { minecraft 'net.minecraftforge:forge:1.20.1-47.2.0' }""" async def cleanup(jid: str, delay: int = 3600): await asyncio.sleep(delay) shutil.rmtree(JARS_DIR / jid, ignore_errors=True) jobs.pop(jid, None) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=7860, workers=1)