diff --git "a/panel.py" "b/panel.py" --- "a/panel.py" +++ "b/panel.py" @@ -1,2123 +1,1013 @@ -#!/usr/bin/env python3 -""" -OSP Panel — Minecraft Server Management Panel -Single-file deployment for HuggingFace Docker Spaces. - -Required HF Secrets: - HF_USERNAME — Panel login username - HF_PASSWORD — Panel login password - SERVER_ZIP_URL — Google Drive share link to server zip (optional) - -Usage: - pip install fastapi uvicorn python-multipart - python app.py -""" - -import os, sys, asyncio, collections, shutil, urllib.request, json, time, re, secrets, hashlib -import tarfile, zipfile, threading +import os, asyncio, collections, shutil, urllib.request, json, time, re, threading from pathlib import Path -from datetime import datetime -from typing import Optional - -from fastapi import ( - FastAPI, WebSocket, WebSocketDisconnect, Form, UploadFile, File, - HTTPException, Request, Response, Depends, Cookie -) -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse +from fastapi import FastAPI, WebSocket, Form, UploadFile, File, HTTPException, WebSocketDisconnect +from fastapi.responses import HTMLResponse, Response, JSONResponse from fastapi.middleware.cors import CORSMiddleware import uvicorn -# ═══════════════════════════════════════════════════════════════════════════════ -# CONFIG -# ═══════════════════════════════════════════════════════════════════════════════ -app = FastAPI(title="OSP Panel", docs_url=None, redoc_url=None) -app.add_middleware( - CORSMiddleware, allow_origins=["*"], allow_credentials=True, - allow_methods=["*"], allow_headers=["*"] -) - -BASE_DIR = os.environ.get("SERVER_DIR", "/app") -PLUGINS_DIR = os.path.join(BASE_DIR, "plugins") -BACKUPS_DIR = os.path.join(BASE_DIR, "backups") -PANEL_CFG = os.path.join(BASE_DIR, "panel.json") -EULA_PATH = os.path.join(BASE_DIR, "eula.txt") -STORAGE_LIMIT = 20 * 1024 * 1024 * 1024 # 20 GB software limit - -HF_USERNAME = os.environ.get("HF_USERNAME", "admin") -HF_PASSWORD = os.environ.get("HF_PASSWORD", "admin") -SERVER_ZIP_URL = os.environ.get("SERVER_ZIP_URL", "") - -mc_process: Optional[asyncio.subprocess.Process] = None -output_history = collections.deque(maxlen=1000) -connected_clients: set = set() -server_start_time: Optional[float] = None -active_sessions: dict = {} # token -> expiry - -schedule_tasks: dict = {} # schedule_id -> asyncio.Task - -# ═══════════════════════════════════════════════════════════════════════════════ -# PANEL.JSON PERSISTENCE -# ═══════════════════════════════════════════════════════════════════════════════ -DEFAULT_PANEL = { - "theme": "dark", - "accent": "blue", - "fontSize": "default", - "reducedMotion": False, - "compactMode": False, - "serverAddress": "", - "serverPort": "25565", - "schedules": [], - "backups": { - "gdrive_enabled": False, - "gdrive_client_id": "", - "gdrive_client_secret": "", - "gdrive_refresh_token": "", - "gdrive_folder_id": "" - } -} +# ─── CONFIG ────────────────────────────────────────────────────────────────── +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, + allow_methods=["*"], allow_headers=["*"]) -def load_panel() -> dict: - if os.path.isfile(PANEL_CFG): - try: - with open(PANEL_CFG) as f: - data = json.load(f) - merged = {**DEFAULT_PANEL, **data} - if "backups" not in merged or not isinstance(merged["backups"], dict): - merged["backups"] = DEFAULT_PANEL["backups"] - return merged - except: - pass - return dict(DEFAULT_PANEL) - -def save_panel(cfg: dict): - with open(PANEL_CFG, "w") as f: - json.dump(cfg, f, indent=2) - -# ═══════════════════════════════════════════════════════════════════════════════ -# AUTH -# ═══════════════════════════════════════════════════════════════════════════════ -def create_session(remember: bool = False) -> tuple: - token = secrets.token_hex(32) - expiry = time.time() + (30 * 86400 if remember else 86400) - active_sessions[token] = expiry - return token, expiry - -def verify_session(token: str) -> bool: - if not token or token not in active_sessions: - return False - if time.time() > active_sessions[token]: - del active_sessions[token] - return False - return True - -async def require_auth(request: Request): - token = request.cookies.get("osp_session") - if not verify_session(token): - raise HTTPException(401, "Unauthorized") - -# ═══════════════════════════════════════════════════════════════════════════════ -# PATH SAFETY -# ═══════════════════════════════════════════════════════════════════════════════ -def safe_path(p: str) -> str: - clean = os.path.normpath((p or "").strip("/")).replace("..", "") - full = os.path.abspath(os.path.join(BASE_DIR, clean)) - if not full.startswith(os.path.abspath(BASE_DIR)): - raise HTTPException(403, "Access denied") - return full +BASE_DIR = os.environ.get("SERVER_DIR", os.path.abspath("/app")) +PLUGINS_DIR = os.path.join(BASE_DIR, "plugins") +PANEL_CFG = os.path.join(BASE_DIR, ".panel_config.json") -# ═══════════════════════════════════════════════════════════════════════════════ -# STORAGE USAGE -# ═══════════════════════════════════════════════════════════════════════════════ -def get_dir_size(path: str) -> int: - total = 0 - for dirpath, dirnames, filenames in os.walk(path): - for f in filenames: - fp = os.path.join(dirpath, f) - try: - total += os.path.getsize(fp) - except: - pass - return total - -# ═══════════════════════════════════════════════════════════════════════════════ -# GOOGLE DRIVE HELPERS -# ═══════════════════════════════════════════════════════════════════════════════ -def gdrive_download(share_url: str, dest_path: str): - """Download file from Google Drive share link.""" - file_id = None - patterns = [ - r'/file/d/([a-zA-Z0-9_-]+)', - r'id=([a-zA-Z0-9_-]+)', - r'/d/([a-zA-Z0-9_-]+)', - ] - for pat in patterns: - m = re.search(pat, share_url) - if m: - file_id = m.group(1) - break - if not file_id: - raise Exception(f"Cannot extract file ID from: {share_url}") +mc_process = None +output_history = collections.deque(maxlen=500) +connected_clients: set = set() +server_start_time: float | None = None - url = f"https://drive.google.com/uc?export=download&id={file_id}&confirm=t" - req = urllib.request.Request(url, headers={"User-Agent": "OSPPanel/1.0"}) - with urllib.request.urlopen(req, timeout=600) as resp: - with open(dest_path, "wb") as f: - shutil.copyfileobj(resp, f) +# ─── HTML FRONTEND ──────────────────────────────────────────────────────────── +HTML_CONTENT = r""" + + +Orbit Panel + + + + +
+
+ +
+ +
+
+
+
+ + +
+ -def gdrive_upload(file_path: str, cfg: dict) -> bool: - """Upload file to Google Drive using refresh token.""" - try: - # Get access token - token_url = "https://oauth2.googleapis.com/token" - token_data = urllib.parse.urlencode({ - "client_id": cfg.get("gdrive_client_id", ""), - "client_secret": cfg.get("gdrive_client_secret", ""), - "refresh_token": cfg.get("gdrive_refresh_token", ""), - "grant_type": "refresh_token" - }).encode() - req = urllib.request.Request(token_url, data=token_data, method="POST") - with urllib.request.urlopen(req, timeout=30) as resp: - token_resp = json.loads(resp.read()) - access_token = token_resp["access_token"] - - filename = os.path.basename(file_path) - filesize = os.path.getsize(file_path) - folder_id = cfg.get("gdrive_folder_id", "") - - metadata = {"name": filename} - if folder_id: - metadata["parents"] = [folder_id] - - # Simple upload for files < 5MB, resumable for larger - if filesize < 5 * 1024 * 1024: - import email.mime.multipart - boundary = "----OSPBoundary" - meta_json = json.dumps(metadata) - with open(file_path, "rb") as f: - file_data = f.read() - body = ( - f"--{boundary}\r\n" - f"Content-Type: application/json; charset=UTF-8\r\n\r\n" - f"{meta_json}\r\n" - f"--{boundary}\r\n" - f"Content-Type: application/octet-stream\r\n\r\n" - ).encode() + file_data + f"\r\n--{boundary}--".encode() - req = urllib.request.Request( - "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", - data=body, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": f"multipart/related; boundary={boundary}" - }, - method="POST" - ) - with urllib.request.urlopen(req, timeout=120) as resp: - resp.read() - else: - # Resumable upload - meta_json = json.dumps(metadata).encode() - req = urllib.request.Request( - "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", - data=meta_json, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json; charset=UTF-8", - "X-Upload-Content-Length": str(filesize) - }, - method="POST" - ) - with urllib.request.urlopen(req, timeout=30) as resp: - upload_url = resp.headers.get("Location") - with open(file_path, "rb") as f: - data = f.read() - req2 = urllib.request.Request( - upload_url, data=data, - headers={"Content-Length": str(filesize)}, - method="PUT" - ) - with urllib.request.urlopen(req2, timeout=600) as resp: - resp.read() - return True - except Exception as e: - output_history.append(f"[Panel] GDrive upload failed: {e}") - return False + - -""" +""" + +# ─── PATH HELPER ───────────────────────────────────────────────────────────── +def safe_path(p: str) -> str: + clean = (p or "").strip("/").replace("..", "") + full = os.path.abspath(os.path.join(BASE_DIR, clean)) + if not full.startswith(os.path.abspath(BASE_DIR)): + raise HTTPException(403, "Access denied") + return full + +# ─── MC PROCESS MANAGEMENT ─────────────────────────────────────────────────── +async def stream_output(pipe): + while True: + line = await pipe.readline() + if not line: + break + txt = line.decode("utf-8", errors="replace").rstrip() + output_history.append(txt) + dead = set() + for c in connected_clients: + try: + await c.send_text(txt) + except: + dead.add(c) + connected_clients.difference_update(dead) + +async def boot_mc(): + global mc_process, server_start_time + # Prefer purpur.jar → paper.jar → server.jar + jar = None + for candidate in ("purpur.jar", "paper.jar", "server.jar"): + p = os.path.join(BASE_DIR, candidate) + if os.path.exists(p): + jar = p + break + if not jar: + output_history.append("\x1b[33m[System] No server jar found in /app. Upload one via Files or install via Software tab.\x1b[0m") + return + server_start_time = time.time() + mc_process = await asyncio.create_subprocess_exec( + "java", "-Xmx4G", "-Xms1G", "-Dfile.encoding=UTF-8", + "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled", + "-jar", jar, "--nogui", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=BASE_DIR + ) + asyncio.create_task(stream_output(mc_process.stdout)) + await mc_process.wait() + server_start_time = None + output_history.append("[System] Server process exited.") + +@app.on_event("startup") +async def on_start(): + os.makedirs(PLUGINS_DIR, exist_ok=True) + asyncio.create_task(boot_mc()) + +# ─── ROUTES ─────────────────────────────────────────────────────────────────── +@app.get("/") +def index(): + return HTMLResponse(HTML_CONTENT) + +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket): + await ws.accept() + connected_clients.add(ws) + # Replay history + for line in output_history: + try: + await ws.send_text(line) + except: + break + try: + while True: + cmd = await ws.receive_text() + if mc_process and mc_process.stdin and not mc_process.stdin.is_closing(): + mc_process.stdin.write((cmd + "\n").encode()) + await mc_process.stdin.drain() + except (WebSocketDisconnect, Exception): + connected_clients.discard(ws) + +@app.get("/api/console/history") +def console_history(): + return list(output_history) + +# ─── SERVER STATUS ──────────────────────────────────────────────────────────── +@app.get("/api/server/status") +def server_status(): + running = mc_process is not None and mc_process.returncode is None + + # Uptime + uptime_str = "—" + if server_start_time and running: + secs = int(time.time() - server_start_time) + h, r = divmod(secs, 3600) + m, s = divmod(r, 60) + uptime_str = f"{h}h {m}m {s}s" if h else f"{m}m {s}s" + + # Memory (from /proc/meminfo if available) + ram_total, ram_used, ram_pct = 0, 0, 0 + try: + with open("/proc/meminfo") as f: + mem = {} + for line in f: + k, v = line.split(":", 1) + mem[k.strip()] = int(v.strip().split()[0]) + ram_total = mem.get("MemTotal", 0) + ram_free = mem.get("MemAvailable", mem.get("MemFree", 0)) + ram_used = ram_total - ram_free + ram_pct = round(ram_used / ram_total * 100) if ram_total else 0 + except: + pass + + # CPU (simple /proc/stat delta approximation) + cpu_pct = 0 + try: + with open("/proc/stat") as f: + vals = list(map(int, f.readline().split()[1:])) + idle, total = vals[3], sum(vals) + cpu_pct = max(0, round(100 - idle / total * 100)) if total else 0 + except: + pass + + # Disk + disk_total, disk_used, disk_pct = 0, 0, 0 + try: + st = os.statvfs(BASE_DIR) + disk_total = st.f_blocks * st.f_frsize + disk_free = st.f_bfree * st.f_frsize + disk_used = disk_total - disk_free + disk_pct = round(disk_used / disk_total * 100) if disk_total else 0 + except: + pass + + def mb(b): return f"{b//1048576} MB" if b else "—" + def gb(b): return f"{b/1073741824:.1f} GB" if b else "—" + + # Active jar + active_jar = None + for c in ("purpur.jar", "paper.jar", "server.jar"): + if os.path.exists(os.path.join(BASE_DIR, c)): + active_jar = c; break + + return { + "running": running, + "uptime": uptime_str, + "cpu_pct": cpu_pct, + "cpu_sub": f"{cpu_pct}% utilization", + "ram_pct": ram_pct, + "ram_sub": f"{mb(ram_used)} / {mb(ram_total)}", + "disk_pct": disk_pct, + "disk_sub": f"{gb(disk_used)} / {gb(disk_total)}", + "tps": "—", + "players": "—", + "mc_version": "1.20.4", + "active_jar": active_jar, + "address": "" + } + +@app.post("/api/server/{action}") +async def server_control(action: str): + global mc_process + if action == "stop": + if mc_process and mc_process.returncode is None: + try: + mc_process.stdin.write(b"stop\n") + await mc_process.stdin.drain() + except: + mc_process.terminate() + elif action == "start": + if mc_process is None or mc_process.returncode is not None: + asyncio.create_task(boot_mc()) + elif action == "restart": + if mc_process and mc_process.returncode is None: + try: + mc_process.stdin.write(b"stop\n") + await mc_process.stdin.drain() + await asyncio.sleep(3) + except: + pass + asyncio.create_task(boot_mc()) + return {"ok": True} + +# ─── FILE SYSTEM API ────────────────────────────────────────────────────────── +@app.get("/api/fs/list") +def fs_list(path: str = ""): + target = safe_path(path) + if not os.path.isdir(target): + raise HTTPException(404, "Not a directory") + items = [] + for name in os.listdir(target): + fp = os.path.join(target, name) + st = os.stat(fp) + items.append({ + "name": name, + "is_dir": os.path.isdir(fp), + "size": st.st_size if not os.path.isdir(fp) else -1, + "mtime": int(st.st_mtime) + }) + return sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower())) + +@app.get("/api/fs/read") +def fs_read(path: str): + target = safe_path(path) + if not os.path.isfile(target): + raise HTTPException(404, "File not found") + try: + with open(target, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + if path.endswith(".json"): + try: + return json.loads(content) + except: + pass + return Response(content, media_type="text/plain; charset=utf-8") + except: + raise HTTPException(500, "Cannot read file") + +@app.get("/api/fs/download") +def fs_download(path: str): + target = safe_path(path) + if not os.path.isfile(target): + raise HTTPException(404, "File not found") + from fastapi.responses import FileResponse + return FileResponse(target, filename=os.path.basename(target)) + +@app.post("/api/fs/write") +async def fs_write(path: str = Form(...), content: str = Form(...)): + target = safe_path(path) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as f: + f.write(content) + return {"ok": True} + +@app.post("/api/fs/upload") +async def fs_upload(path: str = Form(""), file: UploadFile = File(...)): + target_dir = safe_path(path) + os.makedirs(target_dir, exist_ok=True) + dest = os.path.join(target_dir, file.filename) + with open(dest, "wb") as f: + shutil.copyfileobj(file.file, f) + return {"ok": True} + +@app.post("/api/fs/delete") +def fs_delete(path: str = Form(...)): + target = safe_path(path) + if not os.path.exists(target): + raise HTTPException(404, "Not found") + if os.path.isdir(target): + shutil.rmtree(target) + else: + os.remove(target) + return {"ok": True} + +@app.post("/api/fs/rename") +def fs_rename(old_path: str = Form(...), new_path: str = Form(...)): + src = safe_path(old_path) + dst = safe_path(new_path) + if not os.path.exists(src): + raise HTTPException(404, "Source not found") + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.move(src, dst) + return {"ok": True} + +@app.post("/api/fs/create") +def fs_create(path: str = Form(...), is_dir: str = Form("0")): + target = safe_path(path) + if is_dir == "1": + os.makedirs(target, exist_ok=True) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + if not os.path.exists(target): + open(target, "w").close() + return {"ok": True} + +# ─── PLUGIN INSTALLER ───────────────────────────────────────────────────────── +@app.post("/api/plugins/install") +def plugins_install( + url: str = Form(...), + filename: str = Form(...), + project_id: str = Form(...), + version_id: str = Form(...), + name: str = Form(...) +): + dest = os.path.join(PLUGINS_DIR, filename) + try: + req = urllib.request.Request(url, headers={"User-Agent": "OrbitPanel/2.0"}) + with urllib.request.urlopen(req, timeout=120) as resp, open(dest, "wb") as f: + shutil.copyfileobj(resp, f) + except Exception as e: + raise HTTPException(500, f"Download failed: {e}") + + record_path = os.path.join(PLUGINS_DIR, "plugins.json") + data = {} + if os.path.exists(record_path): + try: + with open(record_path) as f: + data = json.load(f) + except: + pass + data[project_id] = { + "name": name, + "filename": filename, + "version_id": version_id, + "installed_at": time.time() + } + with open(record_path, "w") as f: + json.dump(data, f, indent=2) + return {"ok": True} + +# ─── SOFTWARE INSTALLER ─────────────────────────────────────────────────────── +@app.post("/api/software/install") +async def software_install(type: str = Form(...), version: str = Form(...)): + """Download and install a server jar from official sources.""" + dest = os.path.join(BASE_DIR, "server.jar") + # Rename existing jar as backup + for candidate in ("purpur.jar", "paper.jar", "server.jar"): + p = os.path.join(BASE_DIR, candidate) + if os.path.exists(p): + shutil.copy2(p, p + ".bak") + + try: + dl_url = None + + if type == "paper": + # Get latest build for version + builds_url = f"https://api.papermc.io/v2/projects/paper/versions/{version}/builds" + with urllib.request.urlopen(builds_url, timeout=15) as r: + builds_data = json.loads(r.read()) + latest_build = builds_data["builds"][-1]["build"] + jar_name = f"paper-{version}-{latest_build}.jar" + dl_url = f"https://api.papermc.io/v2/projects/paper/versions/{version}/builds/{latest_build}/downloads/{jar_name}" + + elif type == "purpur": + dl_url = f"https://api.purpurmc.org/v2/purpur/{version}/latest/download" + + elif type == "vanilla": + with urllib.request.urlopen("https://launchermeta.mojang.com/mc/game/version_manifest.json", timeout=15) as r: + manifest = json.loads(r.read()) + ver_info = next((v for v in manifest["versions"] if v["id"] == version), None) + if not ver_info: + raise HTTPException(404, f"Version {version} not found in manifest") + with urllib.request.urlopen(ver_info["url"], timeout=15) as r: + ver_data = json.loads(r.read()) + dl_url = ver_data["downloads"]["server"]["url"] + + elif type == "fabric": + # Get latest loader + installer + with urllib.request.urlopen("https://meta.fabricmc.net/v2/versions/loader", timeout=10) as r: + loaders = json.loads(r.read()) + with urllib.request.urlopen("https://meta.fabricmc.net/v2/versions/installer", timeout=10) as r: + installers = json.loads(r.read()) + loader_ver = loaders[0]["version"] + installer_ver = installers[0]["version"] + dl_url = f"https://meta.fabricmc.net/v2/versions/loader/{version}/{loader_ver}/{installer_ver}/server/jar" + + else: + raise HTTPException(400, f"Unsupported type: {type}") + + def do_download(): + req = urllib.request.Request(dl_url, headers={"User-Agent": "OrbitPanel/2.0"}) + with urllib.request.urlopen(req, timeout=300) as resp, open(dest, "wb") as f: + shutil.copyfileobj(resp, f) + + # Run blocking download in thread + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, do_download) + output_history.append(f"[System] Installed {type} {version} → server.jar") + return {"ok": True} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, str(e)) + +# ─── SETTINGS API ───────────────────────────────────────────────────────────── +def _parse_properties(path: str) -> dict: + props = {} + if not os.path.isfile(path): + return props + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + props[k.strip()] = v.strip() + return props + +def _write_properties(path: str, props: dict): + lines = [f"# Managed by Orbit Panel\n"] + for k, v in sorted(props.items()): + lines.append(f"{k}={v}\n") + with open(path, "w", encoding="utf-8") as f: + f.writelines(lines) + +@app.get("/api/settings/properties") +def get_properties(): + path = os.path.join(BASE_DIR, "server.properties") + return _parse_properties(path) + +@app.post("/api/settings/properties") +async def save_properties(data: str = Form(...)): + path = os.path.join(BASE_DIR, "server.properties") + try: + props = json.loads(data) + _write_properties(path, props) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) + +@app.get("/api/settings/panel") +def get_panel_config(): + if os.path.isfile(PANEL_CFG): + try: + with open(PANEL_CFG) as f: + return json.load(f) + except: + pass + return {"accentColor": "#00ff88", "bgColor": "#0a0a0a", "textSize": "14", "serverAddress": ""} + +@app.post("/api/settings/panel") +async def save_panel_config(data: str = Form(...)): + try: + cfg = json.loads(data) + with open(PANEL_CFG, "w") as f: + json.dump(cfg, f, indent=2) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) -# ═══════════════════════════════════════════════════════════════════════════════ -# ENTRY POINT -# ═══════════════════════════════════════════════════════════════════════════════ +# ─── ENTRY POINT ───────────────────────────────────────────────────────────── if __name__ == "__main__": uvicorn.run( app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), - log_level="info" + log_level="warning" )