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`
+
+ ${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`
+ `;
+}
+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?`
+
+
+
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`
`}).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
+
+
${themes.map(t=>``).join('')}
+
+
${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.title||''}
${cfg.body||''}
${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();
+
+