Spaces:
Sleeping
Sleeping
| 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 ──────────────────────────────────────────────────────────────────── | |
| async def health(): | |
| return {"status": "ok"} | |
| # ── Frontend ────────────────────────────────────────────────────────────────── | |
| 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 ─────────────────────────────────────────────────────────────── | |
| 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 ─────────────────────────────────────────────────────────────── | |
| 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 ───────────────────────────────────────────────────────────── | |
| 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) | |