forgebuilder / app.py
DxrkMonteva's picture
Upload app.py
ce1b5b7 verified
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)