DxrkMonteva commited on
Commit
5050db7
·
verified ·
1 Parent(s): af9c1c3

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +36 -0
  2. README.md +39 -5
  3. app.py +565 -0
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ # Только JDK 17 — он умеет запускать и 1.12.2 и 1.20.1
7
+ # JDK 17 с флагом --add-opens совместим с Gradle 4.x
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ openjdk-17-jdk-headless \
10
+ wget unzip curl \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
14
+
15
+ # Только один Gradle — 4.10.3 (работает для обеих версий через wrapper)
16
+ RUN wget -q https://services.gradle.org/distributions/gradle-4.10.3-bin.zip \
17
+ && unzip -q gradle-4.10.3-bin.zip -d /opt/gradle \
18
+ && rm gradle-4.10.3-bin.zip
19
+ ENV PATH="/opt/gradle/gradle-4.10.3/bin:${PATH}"
20
+
21
+ # Python зависимости
22
+ RUN pip install --no-cache-dir \
23
+ fastapi==0.104.1 \
24
+ uvicorn==0.24.0 \
25
+ python-multipart==0.0.6
26
+
27
+ WORKDIR /app
28
+ COPY app.py .
29
+
30
+ RUN useradd -m -u 1000 user && \
31
+ mkdir -p /tmp/fb && \
32
+ chown -R user:user /tmp/fb /app
33
+ USER user
34
+
35
+ EXPOSE 7860
36
+ CMD ["python3", "app.py"]
README.md CHANGED
@@ -1,11 +1,45 @@
1
  ---
2
- title: Forgebuilder
3
- emoji: 📚
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
8
  license: mit
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ForgeBuilder
3
+ emoji: 🔨
4
+ colorFrom: red
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
+ short_description: ZIP to JAR for Minecraft Forge mods
10
  ---
11
 
12
+ # ForgeBuilder 🔨
13
+
14
+ **Собери Minecraft Forge мод прямо в браузере — без установки Java и Gradle.**
15
+
16
+ ## Как использовать
17
+
18
+ 1. Выбери версию Minecraft (1.12.2 или 1.20.1)
19
+ 2. Загрузи ZIP с исходниками мода (должен содержать `build.gradle` и `src/`)
20
+ 3. Нажми "Собрать JAR"
21
+ 4. Скачай готовый `.jar` файл
22
+
23
+ ## Требования к ZIP
24
+
25
+ Структура должна быть такой:
26
+ ```
27
+ ваш-мод.zip
28
+ ├── build.gradle ← обязательно
29
+ ├── src/
30
+ │ └── main/
31
+ │ ├── java/ ← ваш Java код
32
+ │ └── resources/ ← текстуры, lang файлы
33
+ └── gradlew ← необязательно
34
+ ```
35
+
36
+ ## Ограничения
37
+
38
+ - Максимальный размер ZIP: 50 MB
39
+ - JAR хранится 1 час после сборки
40
+ - Одновременно обрабатывается не более 3 сборок
41
+
42
+ ## Безопасность
43
+
44
+ Все загружаемые файлы проверяются на наличие вредоносного кода.
45
+ Исходные файлы удаляются сразу после сборки.
app.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ForgeBuilder — Hugging Face Spaces
3
+ Полный бэкенд + встроенный фронтенд в одном файле.
4
+ Запускается как: python app.py
5
+ """
6
+ import asyncio, os, shutil, subprocess, time, uuid, zipfile
7
+ from pathlib import Path
8
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Request
9
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
10
+ import uvicorn
11
+
12
+ app = FastAPI()
13
+
14
+ # ── Dirs ──────────────────────────────────────────────────────────────────────
15
+ BASE = Path("/tmp/fb")
16
+ JOBS_DIR = BASE / "jobs"
17
+ JARS_DIR = BASE / "jars"
18
+ CACHE_DIR = BASE / "gradle-cache"
19
+ for d in [JOBS_DIR, JARS_DIR, CACHE_DIR]:
20
+ d.mkdir(parents=True, exist_ok=True)
21
+
22
+ jobs: dict[str, dict] = {}
23
+
24
+ VERSIONS = {
25
+ "1.12.2": {"jdk": "/usr/lib/jvm/java-8-openjdk-amd64", "forge": "1.12.2-14.23.5.2860", "mappings": "stable_39"},
26
+ "1.20.1": {"jdk": "/usr/lib/jvm/java-17-openjdk-amd64", "forge": "1.20.1-47.2.0", "mappings": "official"},
27
+ }
28
+
29
+ MAX_ZIP = 50 * 1024 * 1024
30
+ BAD_EXT = {".exe",".sh",".bat",".ps1",".cmd",".php",".rb"}
31
+
32
+ # ── HTML (весь фронтенд встроен сюда) ────────────────────────────────────────
33
+ HTML = r"""<!DOCTYPE html>
34
+ <html lang="ru">
35
+ <head>
36
+ <meta charset="UTF-8">
37
+ <meta name="viewport" content="width=device-width,initial-scale=1">
38
+ <title>ForgeBuilder — ZIP → JAR</title>
39
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Unbounded:wght@700;900&display=swap" rel="stylesheet">
40
+ <style>
41
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
42
+ :root{
43
+ --bg:#09090d;--surf:#111118;--surf2:#18181f;--brd:#252530;--brd2:#35353f;
44
+ --acc:#e8572a;--acc2:#ff7a4d;--grn:#27ae60;--red:#e74c3c;--ylw:#f39c12;
45
+ --txt:#e4e4f0;--mut:#5e5e78;
46
+ --mono:'JetBrains Mono',monospace;--disp:'Unbounded',sans-serif;
47
+ }
48
+ body{background:var(--bg);color:var(--txt);font-family:var(--mono);font-size:14px;line-height:1.6;min-height:100vh}
49
+ .wrap{max-width:820px;margin:0 auto;padding:0 18px}
50
+
51
+ /* header */
52
+ header{padding:44px 0 36px;border-bottom:1px solid var(--brd);margin-bottom:44px}
53
+ .logo{font-family:var(--disp);font-size:clamp(26px,6vw,46px);font-weight:900;letter-spacing:-.03em;line-height:1;margin-bottom:10px}
54
+ .logo span{color:var(--acc)}
55
+ .tag{color:var(--mut);font-size:12px;letter-spacing:.1em;text-transform:uppercase}
56
+
57
+ /* cards */
58
+ .card{background:var(--surf);border:1px solid var(--brd);border-radius:12px;padding:28px;margin-bottom:18px}
59
+ .ctitle{font-family:var(--disp);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--mut);margin-bottom:18px;display:flex;align-items:center;gap:8px}
60
+ .ctitle::before{content:'';width:3px;height:13px;background:var(--acc);border-radius:2px;display:inline-block}
61
+
62
+ /* version */
63
+ .vgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
64
+ .vbtn{background:var(--surf2);border:1px solid var(--brd);border-radius:8px;padding:15px 18px;cursor:pointer;color:var(--txt);font-family:var(--mono);text-align:left;transition:all .15s}
65
+ .vbtn:hover{border-color:var(--acc)}
66
+ .vbtn.on{border-color:var(--acc);background:#1a1016;box-shadow:0 0 0 1px var(--acc)}
67
+ .vnum{font-family:var(--disp);font-size:19px;font-weight:700;color:var(--acc2);display:block;margin-bottom:3px}
68
+ .vlbl{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.07em}
69
+ .vbtn.on .vlbl{color:var(--acc)}
70
+
71
+ /* drop */
72
+ .dz{border:2px dashed var(--brd2);border-radius:10px;padding:44px 20px;text-align:center;cursor:pointer;transition:all .2s;background:var(--surf2);position:relative}
73
+ .dz:hover,.dz.over{border-color:var(--acc);background:#14100d}
74
+ .dz input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
75
+ .dico{font-size:36px;margin-bottom:10px;display:block}
76
+ .dtitle{font-family:var(--disp);font-size:15px;font-weight:700;margin-bottom:5px}
77
+ .dsub{color:var(--mut);font-size:12px}
78
+ .finfo{display:none;align-items:center;gap:10px;background:var(--surf2);border:1px solid var(--grn);border-radius:8px;padding:12px 16px;margin-top:10px}
79
+ .finfo.show{display:flex}
80
+ .fick{color:var(--grn)}
81
+ .fname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
82
+ .fsz{color:var(--mut);font-size:12px}
83
+
84
+ /* warn */
85
+ .warn{background:#161200;border:1px solid var(--ylw);border-radius:6px;padding:10px 14px;font-size:12px;color:var(--ylw);margin-top:10px;display:flex;gap:8px}
86
+
87
+ /* info grid */
88
+ .igrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:16px}
89
+ .iitem{background:var(--surf2);border:1px solid var(--brd);border-radius:8px;padding:14px}
90
+ .ilbl{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--mut);margin-bottom:5px}
91
+ .ival{font-size:13px;font-weight:600;color:var(--acc2)}
92
+
93
+ /* build btn */
94
+ .bbtn{width:100%;padding:17px;background:var(--acc);color:#fff;border:none;border-radius:10px;font-family:var(--disp);font-size:14px;font-weight:700;letter-spacing:.05em;cursor:pointer;transition:all .15s;text-transform:uppercase}
95
+ .bbtn:hover:not(:disabled){background:var(--acc2);transform:translateY(-1px)}
96
+ .bbtn:disabled{opacity:.38;cursor:not-allowed;transform:none}
97
+
98
+ /* progress */
99
+ #psec{display:none}
100
+ #psec.show{display:block}
101
+ .plbl{display:flex;justify-content:space-between;font-size:12px;color:var(--mut);margin-bottom:7px}
102
+ .ptrack{background:var(--surf2);border-radius:4px;height:5px;overflow:hidden}
103
+ .pbar{height:100%;background:linear-gradient(90deg,var(--acc),var(--acc2));border-radius:4px;width:0%;transition:width .5s ease}
104
+ .logbox{background:#050509;border:1px solid var(--brd);border-radius:8px;padding:16px;height:260px;overflow-y:auto;font-size:12px;line-height:1.9;margin-top:14px}
105
+ .logbox::-webkit-scrollbar{width:3px}
106
+ .logbox::-webkit-scrollbar-thumb{background:var(--brd2);border-radius:2px}
107
+ .ll{display:flex;gap:10px}
108
+ .lt{color:var(--mut);min-width:56px}
109
+ .lm{flex:1}
110
+ .lm.ok{color:var(--grn)}.lm.er{color:var(--red)}.lm.wn{color:var(--ylw)}.lm.in{color:#89b4fa}
111
+
112
+ /* result */
113
+ #rsec{display:none}
114
+ #rsec.show{display:block}
115
+ .rsucc{background:#08130d;border:1px solid var(--grn);border-radius:10px;padding:28px;text-align:center}
116
+ .rerr{background:#130808;border:1px solid var(--red);border-radius:10px;padding:28px;text-align:center}
117
+ .rico{font-size:44px;display:block;margin-bottom:10px}
118
+ .rtitle{font-family:var(--disp);font-size:20px;font-weight:900;margin-bottom:7px}
119
+ .rsucc .rtitle{color:var(--grn)}.rerr .rtitle{color:var(--red)}
120
+ .rsub{color:var(--mut);font-size:13px;margin-bottom:22px}
121
+ .dbtn{display:inline-flex;align-items:center;gap:9px;padding:13px 26px;background:var(--grn);color:#050509;border:none;border-radius:8px;font-family:var(--disp);font-size:13px;font-weight:700;cursor:pointer;text-decoration:none;transition:opacity .15s}
122
+ .dbtn:hover{opacity:.82}
123
+ .rbtn{display:inline-flex;align-items:center;gap:7px;padding:11px 22px;background:transparent;color:var(--txt);border:1px solid var(--brd2);border-radius:8px;font-family:var(--mono);font-size:13px;cursor:pointer;margin-top:14px;transition:border-color .15s}
124
+ .rbtn:hover{border-color:var(--acc)}
125
+
126
+ /* spinner */
127
+ .sp{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:5px}
128
+ @keyframes spin{to{transform:rotate(360deg)}}
129
+
130
+ footer{border-top:1px solid var(--brd);margin-top:56px;padding:28px 0;color:var(--mut);font-size:12px;display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
131
+ @media(max-width:480px){.vgrid{grid-template-columns:1fr}.card{padding:18px}}
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div class="wrap">
136
+ <header>
137
+ <div class="logo">Forge<span>Builder</span></div>
138
+ <div class="tag">ZIP с исходниками → готовый JAR // Forge 1.12.2 &amp; 1.20.1</div>
139
+ </header>
140
+
141
+ <div class="card">
142
+ <div class="ctitle">Шаг 1 — Версия Minecraft</div>
143
+ <div class="vgrid">
144
+ <button class="vbtn on" data-ver="1.12.2" onclick="selVer(this)">
145
+ <span class="vnum">1.12.2</span>
146
+ <span class="vlbl">Forge 14.23.5.2860 · JDK 8</span>
147
+ </button>
148
+ <button class="vbtn" data-ver="1.20.1" onclick="selVer(this)">
149
+ <span class="vnum">1.20.1</span>
150
+ <span class="vlbl">Forge 47.2.0 · JDK 17</span>
151
+ </button>
152
+ </div>
153
+ </div>
154
+
155
+ <div class="card">
156
+ <div class="ctitle">Шаг 2 — Загрузи ZIP с исходниками</div>
157
+ <div class="dz" id="dz">
158
+ <input type="file" id="fi" accept=".zip" onchange="onFile(this.files[0])">
159
+ <span class="dico">📦</span>
160
+ <div class="dtitle">Перетащи ZIP или нажми здесь</div>
161
+ <div class="dsub">Максимум 50 MB · Должен содержать build.gradle + src/</div>
162
+ </div>
163
+ <div class="finfo" id="finfo">
164
+ <span class="fick">✓</span>
165
+ <span class="fname" id="fname">—</span>
166
+ <span class="fsz" id="fsz">—</span>
167
+ </div>
168
+ <div class="warn">⚠ Загружай только свой код. Вредоносные файлы автоматически блокируются.</div>
169
+ </div>
170
+
171
+ <div class="card">
172
+ <div class="ctitle">Шаг 3 — Сборка</div>
173
+ <div class="igrid">
174
+ <div class="iitem"><div class="ilbl">Версия</div><div class="ival" id="iver">1.12.2 Forge</div></div>
175
+ <div class="iitem"><div class="ilbl">Время сборки</div><div class="ival">3 — 8 минут</div></div>
176
+ <div class="iitem"><div class="ilbl">Платформа</div><div class="ival">HF Spaces</div></div>
177
+ <div class="iitem"><div class="ilbl">JAR хранится</div><div class="ival">1 час</div></div>
178
+ </div>
179
+ <button class="bbtn" id="bbtn" onclick="startBuild()" disabled>Собрать JAR</button>
180
+ </div>
181
+
182
+ <div class="card" id="psec">
183
+ <div class="ctitle">Сборка...</div>
184
+ <div class="plbl"><span id="pstep">Запуск...</span><span id="ppct">0%</span></div>
185
+ <div class="ptrack"><div class="pbar" id="pbar"></div></div>
186
+ <div class="logbox" id="logbox"></div>
187
+ </div>
188
+
189
+ <div id="rsec"></div>
190
+
191
+ <footer>
192
+ <span>ForgeBuilder v1.0 — бесплатно для всех моддеров</span>
193
+ <span>Hugging Face Spaces · 16 GB RAM</span>
194
+ </footer>
195
+ </div>
196
+
197
+ <script>
198
+ let ver='1.12.2', file=null, jobId=null, poll=null;
199
+
200
+ function selVer(b){
201
+ document.querySelectorAll('.vbtn').forEach(x=>x.classList.remove('on'));
202
+ b.classList.add('on'); ver=b.dataset.ver;
203
+ document.getElementById('iver').textContent=ver+' Forge';
204
+ }
205
+
206
+ function onFile(f){
207
+ if(!f)return;
208
+ if(!f.name.endsWith('.zip')){alert('Нужен ZIP!');return}
209
+ if(f.size>50*1024*1024){alert('Максимум 50 MB');return}
210
+ file=f;
211
+ document.getElementById('fname').textContent=f.name;
212
+ document.getElementById('fsz').textContent=fmtSz(f.size);
213
+ document.getElementById('finfo').classList.add('show');
214
+ document.getElementById('bbtn').disabled=false;
215
+ }
216
+
217
+ function fmtSz(b){
218
+ if(b<1024)return b+' B';
219
+ if(b<1048576)return (b/1024).toFixed(1)+' KB';
220
+ return (b/1048576).toFixed(1)+' MB';
221
+ }
222
+
223
+ const dz=document.getElementById('dz');
224
+ dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('over')});
225
+ dz.addEventListener('dragleave',()=>dz.classList.remove('over'));
226
+ dz.addEventListener('drop',e=>{e.preventDefault();dz.classList.remove('over');onFile(e.dataTransfer.files[0])});
227
+
228
+ async function startBuild(){
229
+ if(!file)return;
230
+ document.getElementById('psec').classList.add('show');
231
+ document.getElementById('rsec').classList.remove('show');
232
+ document.getElementById('rsec').innerHTML='';
233
+ document.getElementById('bbtn').disabled=true;
234
+ document.getElementById('bbtn').innerHTML='<span class="sp"></span>Собирается...';
235
+ document.getElementById('logbox').innerHTML='';
236
+ setP(0,'Загрузка файла...');
237
+
238
+ const form=new FormData();
239
+ form.append('file',file);
240
+ form.append('version',ver);
241
+ addLog('Отправка ZIP на сервер...','in');
242
+
243
+ try{
244
+ const r=await fetch('/build',{method:'POST',body:form});
245
+ if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||'Ошибка '+r.status)}
246
+ const d=await r.json();
247
+ jobId=d.job_id;
248
+ addLog('Job: '+jobId,'in');
249
+ setP(12,'Запуск Gradle...');
250
+ pollJob();
251
+ }catch(e){showErr(e.message)}
252
+ }
253
+
254
+ function pollJob(){
255
+ poll=setInterval(async()=>{
256
+ try{
257
+ const r=await fetch('/status/'+jobId);
258
+ const d=await r.json();
259
+ if(d.logs)d.logs.forEach(l=>{
260
+ const c=l.includes('ERROR')||l.includes('FAILED')?'er':
261
+ l.includes('WARN')?'wn':
262
+ l.includes('SUCCESS')?'ok':'';
263
+ addLog(l,c);
264
+ if(l.includes('Compiling'))setP(72,'Компиляция Java...');
265
+ else if(l.includes('jar'))setP(88,'Создание JAR...');
266
+ else if(l.includes('Downloading')||l.includes('Resolving'))setP(40,'Загрузка зависимостей...');
267
+ });
268
+ if(d.progress)setP(d.progress,d.step||'');
269
+ if(d.status==='done'){clearInterval(poll);showOk(d.jar_url,d.jar_name,d.build_time)}
270
+ else if(d.status==='error'){clearInterval(poll);showErr(d.error||'BUILD FAILED')}
271
+ }catch(e){addLog('Потеря связи: '+e.message,'er')}
272
+ },2200);
273
+ }
274
+
275
+ function setP(pct,lbl){
276
+ document.getElementById('pbar').style.width=pct+'%';
277
+ document.getElementById('ppct').textContent=pct+'%';
278
+ if(lbl)document.getElementById('pstep').textContent=lbl;
279
+ }
280
+
281
+ function addLog(msg,cls){
282
+ const b=document.getElementById('logbox');
283
+ const now=new Date();
284
+ const t=now.getHours().toString().padStart(2,'0')+':'+now.getMinutes().toString().padStart(2,'0')+':'+now.getSeconds().toString().padStart(2,'0');
285
+ const d=document.createElement('div');d.className='ll';
286
+ d.innerHTML=`<span class="lt">${t}</span><span class="lm ${cls||''}">${esc(msg)}</span>`;
287
+ b.appendChild(d);b.scrollTop=b.scrollHeight;
288
+ }
289
+
290
+ function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
291
+
292
+ function showOk(url,name,t){
293
+ reset();
294
+ const s=document.getElementById('rsec');
295
+ s.innerHTML=`<div class="card"><div class="rsucc">
296
+ <span class="rico">✅</span>
297
+ <div class="rtitle">BUILD SUCCESSFUL</div>
298
+ <div class="rsub">Время сборки: ${t||'—'} · Файл будет удалён через 1 час</div>
299
+ <a class="dbtn" href="${url}" download="${name}">⬇ Скачать ${name}</a>
300
+ </div></div>`;
301
+ s.classList.add('show');s.scrollIntoView({behavior:'smooth',block:'center'});
302
+ }
303
+
304
+ function showErr(msg){
305
+ reset();
306
+ const s=document.getElementById('rsec');
307
+ s.innerHTML=`<div class="card"><div class="rerr">
308
+ <span class="rico">❌</span>
309
+ <div class="rtitle">BUILD FAILED</div>
310
+ <div class="rsub">${esc(msg)}</div>
311
+ <button class="rbtn" onclick="resetUI()">↺ Попробовать снова</button>
312
+ </div></div>`;
313
+ s.classList.add('show');s.scrollIntoView({behavior:'smooth',block:'center'});
314
+ }
315
+
316
+ function reset(){
317
+ document.getElementById('bbtn').disabled=false;
318
+ document.getElementById('bbtn').textContent='Собрать JAR';
319
+ }
320
+
321
+ function resetUI(){
322
+ document.getElementById('rsec').classList.remove('show');
323
+ document.getElementById('rsec').innerHTML='';
324
+ document.getElementById('psec').classList.remove('show');
325
+ reset();
326
+ }
327
+ </script>
328
+ </body>
329
+ </html>"""
330
+
331
+ # ── Health check (HF Spaces) ────────────────────────────────────────────────
332
+ @app.get("/health")
333
+ async def health():
334
+ return {"status": "ok"}
335
+
336
+ # ── Serve HTML ──────────────────────────────────────────────────────────────
337
+ @app.get("/", response_class=HTMLResponse)
338
+ async def index():
339
+ return HTML
340
+
341
+ # ── Security: validate ZIP ────────────────────────────────────────────────────
342
+ def validate_zip(path: Path):
343
+ try:
344
+ with zipfile.ZipFile(path) as zf:
345
+ names = zf.namelist()
346
+ if not any("build.gradle" in n for n in names):
347
+ return False, "ZIP не содержит build.gradle"
348
+ if not any("src/" in n or n.startswith("src/") for n in names):
349
+ return False, "ZIP не содержит папку src/"
350
+ for n in names:
351
+ if ".." in n or n.startswith("/"):
352
+ return False, f"Подозрительный путь: {n}"
353
+ if Path(n).suffix.lower() in BAD_EXT:
354
+ return False, f"Запрещённый файл: {n}"
355
+ if sum(i.file_size for i in zf.infolist()) > 200_000_000:
356
+ return False, "Распакованный размер > 200 MB"
357
+ return True, ""
358
+ except zipfile.BadZipFile:
359
+ return False, "Повреждённый ZIP"
360
+
361
+ # ── POST /build ───────────────────────────────────────────────────────────────
362
+ @app.post("/build")
363
+ async def build(file: UploadFile = File(...), version: str = Form(...)):
364
+ if version not in VERSIONS:
365
+ raise HTTPException(400, f"Неподдерживаемая версия: {version}")
366
+ data = await file.read()
367
+ if len(data) > MAX_ZIP:
368
+ raise HTTPException(400, "Файл > 50 MB")
369
+ if not file.filename.endswith(".zip"):
370
+ raise HTTPException(400, "Нужен ZIP файл")
371
+
372
+ jid = str(uuid.uuid4())[:8]
373
+ job_dir = JOBS_DIR / jid
374
+ zip_p = job_dir / "src.zip"
375
+ job_dir.mkdir(parents=True)
376
+ zip_p.write_bytes(data)
377
+
378
+ ok, err = validate_zip(zip_p)
379
+ if not ok:
380
+ shutil.rmtree(job_dir, ignore_errors=True)
381
+ raise HTTPException(400, err)
382
+
383
+ jobs[jid] = {"status":"queued","progress":5,"step":"В очереди...","logs":[],"error":None,"jar_url":None,"jar_name":None,"build_time":None}
384
+ asyncio.create_task(run_build(jid, version, zip_p))
385
+ return {"job_id": jid}
386
+
387
+ # ── GET /status/{jid} ─────────────────────────────────────────────────────────
388
+ @app.get("/status/{jid}")
389
+ async def status(jid: str):
390
+ if jid not in jobs:
391
+ raise HTTPException(404, "Job не найден")
392
+ j = jobs[jid]
393
+ logs = j["logs"][:]
394
+ j["logs"] = []
395
+ return {**j, "logs": logs}
396
+
397
+ # ── GET /download/{jid}/{name} ────────────────────────────────────────────────
398
+ @app.get("/download/{jid}/{name}")
399
+ async def download(jid: str, name: str):
400
+ p = JARS_DIR / jid / name
401
+ if not p.exists():
402
+ raise HTTPException(404, "Файл не найден или истёк срок")
403
+ return FileResponse(p, filename=name, media_type="application/java-archive")
404
+
405
+ # ── Build worker ──────────────────────────────────────────────────────────────
406
+ async def run_build(jid: str, version: str, zip_p: Path):
407
+ j = jobs[jid]
408
+ cfg = VERSIONS[version]
409
+ jdir = zip_p.parent
410
+ t0 = time.time()
411
+
412
+ def log(m): j["logs"].append(m)
413
+ def st(s,p,step=""): j["status"]=s; j["progress"]=p; j["step"]=step
414
+
415
+ try:
416
+ # Extract
417
+ st("running", 15, "Распаковка ZIP...")
418
+ log("[INFO] Распаковка ZIP...")
419
+ src = jdir / "extracted"
420
+ src.mkdir()
421
+ with zipfile.ZipFile(zip_p) as zf:
422
+ zf.extractall(src)
423
+
424
+ # Find project root (where build.gradle lives)
425
+ roots = list(src.rglob("build.gradle"))
426
+ if not roots:
427
+ raise RuntimeError("build.gradle не найден")
428
+ root = roots[0].parent
429
+ log(f"[INFO] Корень проекта: {root.name}")
430
+
431
+ # Verify src/main/java
432
+ if not (root / "src" / "main" / "java").exists():
433
+ raise RuntimeError("src/main/java не найдена")
434
+
435
+ # Copy to writable build dir
436
+ st("running", 22, "Подготовка окружения...")
437
+ build_dir = jdir / "build_workspace"
438
+ shutil.copytree(root, build_dir)
439
+
440
+ # Set JAVA_HOME for this version
441
+ env = os.environ.copy()
442
+ env["JAVA_HOME"] = "/usr/lib/jvm/java-17-openjdk-amd64"
443
+ env["PATH"] = "/usr/lib/jvm/java-17-openjdk-amd64/bin:" + env.get("PATH","")
444
+ env["GRADLE_USER_HOME"] = str(CACHE_DIR)
445
+ env["GRADLE_OPTS"] = "-Xmx1g -Xms128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED"
446
+
447
+ # Always use system gradle for reliability
448
+ # gradle4 = Gradle 4.10.3 for 1.12.2, gradle8 = Gradle 8.1.1 for 1.20.1
449
+ gradle = ["gradle"]
450
+ log("[INFO] Используем Gradle 4.10.3 + JDK 17"))
451
+
452
+ # Inject Forge plugin if missing
453
+ bg = (build_dir / "build.gradle").read_text(errors="replace")
454
+ if "minecraftforge" not in bg and "ForgeGradle" not in bg:
455
+ log("[WARN] Forge плагин не найден в build.gradle — добавляем автоматически")
456
+ (build_dir / "build.gradle").write_text(
457
+ _inject_forge(version, cfg) + "\n\n" + bg
458
+ )
459
+
460
+ # Run setupDecompWorkspace only if cache is cold
461
+ deobf_cache = CACHE_DIR / f"caches/minecraft/{version}"
462
+ if not deobf_cache.exists():
463
+ st("running", 30, "Загрузка Forge MDK (первый раз ~10 мин)...")
464
+ log("[INFO] Первая сборка: скачиваем Forge и Minecraft деобфускацию...")
465
+ await _run_gradle(gradle + ["setupDecompWorkspace", "--no-daemon"], build_dir, env, j)
466
+
467
+ # Build
468
+ st("running", 55, "Компиляция Java...")
469
+ log("[INFO] Запуск: gradle build")
470
+ await _run_gradle(gradle + ["build", "--no-daemon", "--stacktrace"], build_dir, env, j)
471
+
472
+ # Collect JARs
473
+ st("running", 92, "Сбор JAR файлов...")
474
+ out = JARS_DIR / jid
475
+ out.mkdir(parents=True)
476
+ jars = [f for f in (build_dir / "build" / "libs").glob("*.jar")
477
+ if not any(s in f.name for s in ["-sources","-dev","-javadoc"])]
478
+ if not jars:
479
+ raise RuntimeError("JAR не создан — проверь build.gradle")
480
+
481
+ jar = jars[0]
482
+ shutil.copy(jar, out / jar.name)
483
+ secs = int(time.time() - t0)
484
+ log(f"[SUCCESS] BUILD SUCCESSFUL — {jar.name} ({jar.stat().st_size//1024} KB)")
485
+ log(f"[INFO] Время: {secs} сек")
486
+
487
+ j["status"] = "done"
488
+ j["progress"] = 100
489
+ j["step"] = "Готово!"
490
+ j["jar_url"] = f"/download/{jid}/{jar.name}"
491
+ j["jar_name"] = jar.name
492
+ j["build_time"] = f"{secs} сек"
493
+
494
+ asyncio.create_task(_cleanup(jid))
495
+
496
+ except Exception as e:
497
+ log(f"[ERROR] {e}")
498
+ j["status"] = "error"
499
+ j["error"] = str(e)
500
+ finally:
501
+ shutil.rmtree(jdir, ignore_errors=True)
502
+
503
+
504
+ async def _run_gradle(cmd, cwd, env, j):
505
+ """Stream gradle output into job logs."""
506
+ proc = await asyncio.create_subprocess_exec(
507
+ *cmd, cwd=str(cwd), env=env,
508
+ stdout=asyncio.subprocess.PIPE,
509
+ stderr=asyncio.subprocess.STDOUT,
510
+ )
511
+ while True:
512
+ line = await proc.stdout.readline()
513
+ if not line:
514
+ break
515
+ txt = line.decode("utf-8", errors="replace").rstrip()
516
+ if txt and any(kw in txt for kw in [
517
+ "BUILD","Task","error","ERROR","warn","WARN",
518
+ "Compiling","Download","Resolving","Exception","FAILED","jar"
519
+ ]):
520
+ j["logs"].append(txt)
521
+ await proc.wait()
522
+ if proc.returncode != 0:
523
+ raise RuntimeError("Gradle вернул ошибку. Проверь логи выше.")
524
+
525
+
526
+ def _inject_forge(version: str, cfg: dict) -> str:
527
+ if version == "1.12.2":
528
+ return f"""
529
+ buildscript {{
530
+ repositories {{ maven {{ url = 'https://maven.minecraftforge.net' }} mavenCentral() }}
531
+ dependencies {{ classpath 'net.minecraftforge.gradle:ForgeGradle:2.3-SNAPSHOT' }}
532
+ }}
533
+ apply plugin: 'net.minecraftforge.gradle.forge'
534
+ apply plugin: 'java'
535
+ minecraft {{
536
+ version = "{cfg['forge']}"
537
+ runDir = "run"
538
+ mappings = "{cfg['mappings']}"
539
+ }}
540
+ """
541
+ else: # 1.20.1
542
+ return f"""
543
+ plugins {{
544
+ id 'net.minecraftforge.gradle' version '6.0.+'
545
+ id 'java'
546
+ }}
547
+ minecraft {{
548
+ mappings channel: 'official', version: '1.20.1'
549
+ runs {{ client {{ workingDirectory project.file('run') }} }}
550
+ }}
551
+ dependencies {{
552
+ minecraft 'net.minecraftforge:forge:{cfg['forge']}'
553
+ }}
554
+ """
555
+
556
+
557
+ async def _cleanup(jid: str, delay: int = 3600):
558
+ await asyncio.sleep(delay)
559
+ shutil.rmtree(JARS_DIR / jid, ignore_errors=True)
560
+ jobs.pop(jid, None)
561
+
562
+
563
+ # ── Entry point ───────────────────────────────────────────────────────────────
564
+ if __name__ == "__main__":
565
+ uvicorn.run(app, host="0.0.0.0", port=7860, timeout_keep_alive=120, workers=1)