diff --git "a/panel.py" "b/panel.py" --- "a/panel.py" +++ "b/panel.py" @@ -1,1577 +1,2186 @@ -import os, asyncio, collections, shutil, urllib.request, json, time, re, threading +#!/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 from pathlib import Path -from fastapi import FastAPI, WebSocket, Form, UploadFile, File, HTTPException, WebSocketDisconnect -from fastapi.responses import HTMLResponse, Response, JSONResponse +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.middleware.cors import CORSMiddleware import uvicorn -# ─── CONFIG ────────────────────────────────────────────────────────────────── -app = FastAPI() -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, - allow_methods=["*"], allow_headers=["*"]) +# ═══════════════════════════════════════════════════════════════════════════════ +# 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": "" + } +} -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") +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 -mc_process = None -output_history = collections.deque(maxlen=500) -connected_clients: set = set() -server_start_time: float | None = None +# ═══════════════════════════════════════════════════════════════════════════════ +# 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}") -# ─── HTML FRONTEND ──────────────────────────────────────────────────────────── -HTML_CONTENT = r""" - - -Orbit Panel - - - - -
-
- -
- -
-
-
-
- - -
- + 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) - -""" - -# ─── 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)) + const r=await fetch('/api/fs/read?path='+encodeURIComponent(path.replace(/^\//,''))); + const text=await r.text(); + document.getElementById('editor-area').value=text; + }catch(e){document.getElementById('editor-area').value='// Failed to load'} +} +async function saveFile(path){ + const ta=document.getElementById('editor-area');if(!ta)return; + const fd=new FormData();fd.append('path',path);fd.append('content',ta.value); + const r=await fetch('/api/fs/write',{method:'POST',body:fd}); + if(r.ok){toast('Saved','success');closeModal()}else toast('Save failed','error'); +} +function previewImage(name){ + const path=(currentPath==='/'?'':currentPath)+'/'+name; + showModal({title:name,body:`
`,foot:``}); +} +function showRename(name){ + showModal({title:'Rename',body:``, + foot:``}); + setTimeout(()=>{const i=document.getElementById('rename-input');if(i){i.focus();i.select()}},100); +} +async function doRename(old){ + const inp=document.getElementById('rename-input');if(!inp||!inp.value.trim())return; + const fd=new FormData(); + fd.append('old_path',(currentPath==='/'?'':currentPath)+'/'+old); + fd.append('new_path',(currentPath==='/'?'':currentPath)+'/'+inp.value.trim()); + const r=await fetch('/api/fs/rename',{method:'POST',body:fd}); + if(r.ok){toast('Renamed','success');closeModal();fetchFiles(currentPath)}else toast('Failed','error'); +} +function showDeleteFile(name){ + const path=(currentPath==='/'?'':currentPath)+'/'+name; + showModal({title:'Delete',body:`
Delete "${escHtml(name)}"?
This cannot be undone.
`, + foot:``}); +} +async function doDeleteFile(path){ + const fd=new FormData();fd.append('path',path); + const r=await fetch('/api/fs/delete',{method:'POST',body:fd}); + if(r.ok){toast('Deleted','success');closeModal();fetchFiles(currentPath)}else toast('Failed','error'); +} +function startUpload(){ + showModal({title:'Upload Files',body:`
Drop files or click to browse
Uploading to: ${escHtml(currentPath)}
`, + foot:``}); +} +async function handleDrop(e){e.preventDefault();document.getElementById('drop-zone').classList.remove('drag');await doUpload(e.dataTransfer.files)} +async function doUpload(files){ + if(!files||!files.length)return;closeModal(); + for(const file of files){ + toast('Uploading '+file.name+'...','success'); + const fd=new FormData();fd.append('path',currentPath.replace(/^\//,''));fd.append('file',file); + const r=await fetch('/api/fs/upload',{method:'POST',body:fd}); + if(r.ok)toast('Uploaded '+file.name,'success');else toast('Failed: '+file.name,'error'); + } + fetchFiles(currentPath); +} +function startCreate(){ + showModal({title:'Create New',body:`
+ + +
`, + foot:``}); +} +async function doCreate(){ + const name=document.getElementById('create-name')?.value?.trim();if(!name)return; + const isDir=document.getElementById('cb-dir')?.style.borderColor?.includes('primary')||false; + const fd=new FormData(); + fd.append('path',(currentPath==='/'?'':currentPath)+'/'+name); + fd.append('is_dir',isDir?'1':'0'); + const r=await fetch('/api/fs/create',{method:'POST',body:fd}); + if(r.ok){toast('Created','success');closeModal();fetchFiles(currentPath)}else toast('Failed','error'); +} +function startMove(name){clipboardItem={name,from:currentPath};clipboardAction='move';updatePasteBtn();toast('Navigate to destination and paste','success')} +async function doPaste(){ + if(clipboardAction==='move'&&clipboardItem){ + const fd=new FormData(); + fd.append('old_path',(clipboardItem.from==='/'?'':clipboardItem.from)+'/'+clipboardItem.name); + fd.append('new_path',(currentPath==='/'?'':currentPath)+'/'+clipboardItem.name); + const r=await fetch('/api/fs/rename',{method:'POST',body:fd}); + if(r.ok)toast('Moved','success');else toast('Move failed','error'); + } + cancelClip();fetchFiles(currentPath); +} +function cancelClip(){clipboardItem=null;clipboardAction=null;updatePasteBtn()} +function updatePasteBtn(){ + const c=document.getElementById('paste-container'); + if(clipboardAction==='move'&&clipboardItem) + c.innerHTML=``; + else c.innerHTML=''; +} -# ─── 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 +// ═══════════════════════════════════════════════════════════════════════════ +// PLUGINS +// ═══════════════════════════════════════════════════════════════════════════ +function renderPlugins(){ + return` +
Browse
Installed
+ ${pluginSubTab==='browse'?renderPluginBrowse():renderPluginInstalled()}`; +} +function renderPluginBrowse(){ + return` +
`; +} +function renderPluginInstalled(){ + return`
`; +} +async function searchPlugins(){ + const q=document.getElementById('plugin-search')?.value?.trim();if(!q)return; + pluginSearch=q; + const el=document.getElementById('plugin-results');if(!el)return; + el.innerHTML='
'; + try{ + const r=await fetch(`https://api.modrinth.com/v2/search?query=${encodeURIComponent(q)}&facets=[["project_type:plugin"]]&limit=20`); + const d=await r.json(); + if(!d.hits||!d.hits.length){el.innerHTML='
No results
';return} + el.innerHTML='
'+d.hits.map(p=>`
+
+
${p.icon_url?``:p.title[0]}
+
${escHtml(p.title)}
${escHtml(p.description||'')}
+
+
${formatNum(p.downloads)}
+
+
`).join('')+'
'; + }catch(e){el.innerHTML='
Search failed
'} +} +function formatNum(n){if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(0)+'K';return n} -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) +async function showPluginVersions(slug,name){ + showModal({title:'Install '+name,body:'
Loading versions...
',foot:''}); + try{ + const r=await fetch(`https://api.modrinth.com/v2/project/${slug}/version?loaders=["paper","spigot","bukkit","purpur"]&limit=10`); + const versions=await r.json(); + if(!versions.length){document.querySelector('.modal-body').innerHTML='
No compatible versions
';return} + document.querySelector('.modal-body').innerHTML=versions.map(v=>{ + const file=v.files.find(f=>f.primary)||v.files[0]; + return`
+
${escHtml(v.version_number)}
${v.game_versions.slice(0,3).join(', ')}
+ +
`}).join(''); + }catch(e){document.querySelector('.modal-body').innerHTML='
Failed to load versions
'} +} +async function installPlugin(url,filename,pid,vid,name){ + closeModal();toast('Installing '+name+'...','success'); + const fd=new FormData();fd.append('url',url);fd.append('filename',filename);fd.append('project_id',pid);fd.append('version_id',vid);fd.append('name',name); + const r=await fetch('/api/plugins/install',{method:'POST',body:fd}); + if(r.ok)toast(name+' installed!','success');else toast('Install failed','error'); +} +async function loadInstalledPlugins(){ + const el=document.getElementById('installed-list');if(!el)return; + try{ + const r=await fetch('/api/plugins/installed');const data=await r.json(); + const entries=Object.entries(data); + if(!entries.length){el.innerHTML='
No plugins installed
';return} + el.innerHTML='
'+entries.map(([pid,p])=>`
+
${(p.name||'?')[0]}
+
${escHtml(p.name)}
${escHtml(p.filename)}
+
+
+
`).join('')+'
'; + }catch(e){el.innerHTML='
Failed to load
'} +} +async function uninstallPlugin(pid,name){ + if(!confirm('Uninstall '+name+'?'))return; + const fd=new FormData();fd.append('project_id',pid); + const r=await fetch('/api/plugins/uninstall',{method:'POST',body:fd}); + if(r.ok){toast('Uninstalled','success');loadInstalledPlugins()}else toast('Failed','error'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SERVER STATUS (external API) +// ═══════════════════════════════════════════════════════════════════════════ +function renderStatus(){ + return` +
Checking...
`; +} +async function fetchExternalStatus(){ + const el=document.getElementById('ext-status');if(!el)return; + const addr=panelConfig.serverAddress||''; + if(!addr){el.innerHTML='
Set server address in Settings to check external status
';return} + try{ + const host=addr.split(':')[0]||addr;const port=addr.split(':')[1]||'25565'; + const r=await fetch(`https://api.mcsrvstat.us/3/${host}:${port}`);const d=await r.json(); + el.innerHTML=`
+
+
+ ${d.online?'Online':'Offline'} + ${d.online?`${d.players?.online||0}/${d.players?.max||0} players`:''} +
+ ${d.online?` +
+
Host
${host}:${port}
+
Version
${escHtml(d.version||'—')}
+
MOTD
${d.motd?.clean?escHtml(d.motd.clean.join(' ')):'—'}
+
`:'
Server appears to be offline or unreachable.
'} +
`; + }catch(e){el.innerHTML='
Failed to check status
'} +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SCHEDULES +// ═══════════════════════════════════════════════════════════════════════════ +function renderSchedules(){ + return` +
`; +} +async function loadSchedules(){ + const el=document.getElementById('sched-list');if(!el)return; + try{ + const r=await fetch('/api/schedules/list');scheduleList=await r.json(); + if(!scheduleList.length){el.innerHTML='
No schedules configured
';return} + el.innerHTML=scheduleList.map((s,i)=>`
+
+
+
${escHtml(s.name)} ${s.enabled?'Active':'Paused'}
+
${escHtml(s.type)} — ${s.tasks?.length||0} task(s)
+
+
+
+ +
+
`).join(''); + }catch(e){el.innerHTML='
Failed to load
'} +} +async function toggleSchedule(idx){ + scheduleList[idx].enabled=!scheduleList[idx].enabled; + const fd=new FormData();fd.append('data',JSON.stringify(scheduleList)); + await fetch('/api/schedules/save',{method:'POST',body:fd}); + loadSchedules(); +} +async function deleteSchedule(idx){ + if(!confirm('Delete schedule "'+scheduleList[idx].name+'"?'))return; + scheduleList.splice(idx,1); + const fd=new FormData();fd.append('data',JSON.stringify(scheduleList)); + await fetch('/api/schedules/save',{method:'POST',body:fd}); + toast('Deleted','success');loadSchedules(); +} +function showNewSchedule(){ + showModal({title:'New Schedule',body:` +
+
+
+
+
`, + foot:``}); +} +async function createSchedule(){ + const name=document.getElementById('sched-name')?.value?.trim()||'Schedule'; + const type=document.getElementById('sched-type')?.value||'interval'; + const hours=parseInt(document.getElementById('sched-hours')?.value)||6; + const action=document.getElementById('sched-action')?.value||'restart'; + const cmd=document.getElementById('sched-cmd')?.value||''; + const sched={id:Date.now().toString(36),name,type,intervalHours:hours,intervalMinutes:0,enabled:true,tasks:[{action,payload:cmd}]}; + scheduleList.push(sched); + const fd=new FormData();fd.append('data',JSON.stringify(scheduleList)); + await fetch('/api/schedules/save',{method:'POST',body:fd}); + toast('Created','success');closeModal();loadSchedules(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BACKUPS +// ═══════════════════════════════════════════════════════════════════════════ +function renderBackups(){ + return` +
`; +} +async function loadBackups(){ + const el=document.getElementById('backup-list');if(!el)return; + try{ + const r=await fetch('/api/backups/list');backupList=await r.json(); + if(!backupList.length){el.innerHTML='
No backups yet
';return} + el.innerHTML='
'+backupList.map(b=>`
+
+
+
${escHtml(b.name)} ${b.locked?'':''} ${b.gdrive?'':''}
+
${fmtSize(b.size)} • ${b.date?new Date(b.date).toLocaleDateString():'—'}
+
+
+ + + + ${!b.locked?``:''} +
+
`).join('')+'
'; + }catch(e){el.innerHTML='
Failed to load
'} +} +function showCreateBackup(){ + showModal({title:'Create Backup',body:``, + foot:``}); +} +async function createBackup(){ + const name=document.getElementById('backup-name')?.value?.trim()||'Backup'; + const btn=document.getElementById('backup-btn');if(btn){btn.disabled=true;btn.innerHTML=' Creating...'} + const fd=new FormData();fd.append('name',name); + const r=await fetch('/api/backups/create',{method:'POST',body:fd}); + if(r.ok){toast('Backup created','success');closeModal();loadBackups()} + else{toast('Failed','error');if(btn){btn.disabled=false;btn.innerHTML=' Create'}} +} +function restoreBackup(id,name){ + showModal({title:'Restore Backup',body:`
Restore "${escHtml(name)}"?
This will stop the server and overwrite current files.
`, + foot:``}); +} +async function doRestore(id){ + closeModal();toast('Restoring...','success'); + const fd=new FormData();fd.append('backup_id',id); + const r=await fetch('/api/backups/restore',{method:'POST',body:fd}); + if(r.ok)toast('Restored! Server restarting...','success');else toast('Restore failed','error'); +} +async function toggleBackupLock(id){ + const fd=new FormData();fd.append('backup_id',id); + await fetch('/api/backups/lock',{method:'POST',body:fd});loadBackups(); +} +async function deleteBackup(id,name){ + if(!confirm('Delete backup "'+name+'"?'))return; + const fd=new FormData();fd.append('backup_id',id); + const r=await fetch('/api/backups/delete',{method:'POST',body:fd}); + if(r.ok){toast('Deleted','success');loadBackups()}else toast('Failed','error'); +} +function showGDriveSettings(){ + const bc=panelConfig.backups||{}; + showModal({title:'Google Drive Settings',body:` +
Optional: Connect Google Drive to automatically upload backups.
+
+
+
+
+
`, + foot:``}); +} +async function saveGDrive(){ + panelConfig.backups={ + gdrive_enabled:document.getElementById('gd-toggle')?.classList.contains('on')||false, + gdrive_client_id:document.getElementById('gd-cid')?.value||'', + gdrive_client_secret:document.getElementById('gd-cs')?.value||'', + gdrive_refresh_token:document.getElementById('gd-rt')?.value||'', + gdrive_folder_id:document.getElementById('gd-fid')?.value||'' + }; + const fd=new FormData();fd.append('data',JSON.stringify(panelConfig)); + await fetch('/api/settings/panel',{method:'POST',body:fd}); + toast('Saved','success');closeModal(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SETTINGS +// ═══════════════════════════════════════════════════════════════════════════ +const PROP_GROUPS=[ + {group:'Network',icon:'fa-network-wired',props:[ + {key:'server-port',type:'number',desc:'Server port number'}, + {key:'server-ip',type:'text',desc:'Server IP binding address'}, + {key:'online-mode',type:'bool',desc:'Authenticate with Mojang'}, + ]}, + {group:'Gameplay',icon:'fa-gamepad',props:[ + {key:'gamemode',type:'select',options:['survival','creative','adventure','spectator'],desc:'Default game mode'}, + {key:'difficulty',type:'select',options:['peaceful','easy','normal','hard'],desc:'Difficulty'}, + {key:'pvp',type:'bool',desc:'Enable PvP'}, + {key:'max-players',type:'number',desc:'Max players'}, + {key:'spawn-protection',type:'number',desc:'Spawn protection radius'}, + {key:'allow-flight',type:'bool',desc:'Allow flight'}, + {key:'hardcore',type:'bool',desc:'Hardcore mode'}, + ]}, + {group:'World',icon:'fa-globe',props:[ + {key:'level-name',type:'text',desc:'World folder name'}, + {key:'level-seed',type:'text',desc:'World seed'}, + {key:'generate-structures',type:'bool',desc:'Generate structures'}, + {key:'spawn-animals',type:'bool',desc:'Spawn animals'}, + {key:'spawn-monsters',type:'bool',desc:'Spawn monsters'}, + ]}, + {group:'General',icon:'fa-cog',props:[ + {key:'motd',type:'text',desc:'Server list message'}, + {key:'enable-command-block',type:'bool',desc:'Enable command blocks'}, + {key:'white-list',type:'bool',desc:'Enable whitelist'}, + {key:'view-distance',type:'number',desc:'View distance'}, + ]} +]; -@app.get("/api/settings/properties") -def get_properties(): - path = os.path.join(BASE_DIR, "server.properties") - return _parse_properties(path) +function renderSettings(){ + return` +
+
Server Properties
+
Panel
+
+ ${settingsSubTab==='server'?renderServerProps():renderPanelSettings()}`; +} +function renderServerProps(){ + return`
${renderPropsList()}
`; +} +function renderPropsList(){ + return PROP_GROUPS.map(g=>`
${g.group}
+ ${g.props.map(p=>{const val=serverProps[p.key]??'';return`
${p.key}
${p.desc}
+
${p.type==='bool'?`
`:p.type==='select'?``:``}
`}).join('')}
`).join(''); +} +async function fetchServerProps(){ + try{const r=await fetch('/api/settings/properties');if(r.ok)serverProps=await r.json()}catch(e){} + const body=document.getElementById('props-body');if(body)body.innerHTML=renderPropsList(); +} +function renderPanelSettings(){ + const themes=['dark','light','amoled'];const accents=['blue','purple','green','orange','red','teal']; + return` +
Appearance
+
Theme
+
${themes.map(t=>``).join('')}
+
Accent Color
+
${accents.map(a=>`
`).join('')}
+
+
Server Connection
+
Server Address
For external status checks
+
+
+
Danger Zone
+
Force Kill Server
Immediately terminate the process
+
+
`; +} +function setTheme(t){ + panelConfig.theme=t; + document.body.className=''; + if(t==='light')document.body.classList.add('theme-light'); + else if(t==='amoled')document.body.classList.add('theme-amoled'); + if(panelConfig.accent&&panelConfig.accent!=='blue')document.body.classList.add('accent-'+panelConfig.accent); + switchTab('settings'); +} +function setAccent(a){ + panelConfig.accent=a; + document.body.classList.remove('accent-purple','accent-green','accent-orange','accent-red','accent-teal'); + if(a!=='blue')document.body.classList.add('accent-'+a); + switchTab('settings'); +} +async function saveSettings(){ + if(settingsSubTab==='server'){ + const fd=new FormData();fd.append('data',JSON.stringify(serverProps)); + const r=await fetch('/api/settings/properties',{method:'POST',body:fd}); + if(r.ok)toast('server.properties saved','success');else toast('Save failed','error'); + }else{ + const fd=new FormData();fd.append('data',JSON.stringify(panelConfig)); + await fetch('/api/settings/panel',{method:'POST',body:fd}); + toast('Panel settings saved','success'); + } +} +async function loadPanelConfig(){ + try{ + const r=await fetch('/api/settings/panel');if(r.ok){const d=await r.json();Object.assign(panelConfig,d); + if(panelConfig.theme==='light')document.body.classList.add('theme-light'); + else if(panelConfig.theme==='amoled')document.body.classList.add('theme-amoled'); + if(panelConfig.accent&&panelConfig.accent!=='blue')document.body.classList.add('accent-'+panelConfig.accent); + }}catch(e){} +} -@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)) +// ═══════════════════════════════════════════════════════════════════════════ +// MODAL & TOAST +// ═══════════════════════════════════════════════════════════════════════════ +function showModal(cfg){ + const m=document.getElementById('modal-wrap'),box=document.getElementById('modal-box'); + m.classList.remove('editor-modal'); + if(cfg.cls==='editor-modal')m.classList.add('editor-modal'); + box.innerHTML=`${cfg.foot!==undefined&&cfg.foot!==''?``:(cfg.foot===''?'':``)}`; + m.classList.add('on'); +} +function closeModal(){document.getElementById('modal-wrap').classList.remove('on','editor-modal')} +function modalBgClick(e){if(e.target.classList.contains('modal-bg'))closeModal()} +function toast(msg,type='success'){ + const w=document.getElementById('toast-wrap'); + const icons={success:'fa-circle-check',error:'fa-circle-xmark',warn:'fa-triangle-exclamation'}; + const t=document.createElement('div');t.className='toast '+type; + t.innerHTML=`${escHtml(msg)}`; + w.appendChild(t); + setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),300)},3500); +} -@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": ""} +// ═══════════════════════════════════════════════════════════════════════════ +// SIDEBAR +// ═══════════════════════════════════════════════════════════════════════════ +function toggleSb(){document.getElementById('sidebar').classList.add('open');document.getElementById('overlay').classList.add('on')} +function closeSb(){document.getElementById('sidebar').classList.remove('open');document.getElementById('overlay').classList.remove('on')} +document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeModal();hideCtx()}}); -@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)) +// ═══════════════════════════════════════════════════════════════════════════ +// INIT +// ═══════════════════════════════════════════════════════════════════════════ +checkAuth(); + + +""" -# ─── ENTRY POINT ───────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ +# ENTRY POINT +# ═══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": uvicorn.run( app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), - log_level="warning" + log_level="info" )