| |
| """ |
| HFOS - HuggingFace Operating System for Minecraft (v2.1) |
| Fixed iframe proxy routing, silenced backend logs, robust subprocess, and Repair tools. |
| """ |
|
|
| import os |
| import re |
| import sys |
| import json |
| import time |
| import hmac |
| import hashlib |
| import base64 |
| import secrets |
| import shutil |
| import threading |
| import subprocess |
| from collections import deque |
|
|
| try: |
| import psutil |
| import httpx |
| from flask import Flask, request, jsonify, Response |
| from flask_cors import CORS |
| except ImportError: |
| subprocess.check_call([sys.executable, "-m", "pip", "install", "flask", "flask-cors", "psutil", "httpx"]) |
| import psutil |
| import httpx |
| from flask import Flask, request, jsonify, Response |
| from flask_cors import CORS |
|
|
| |
| |
| |
| BASE_DIR = os.path.abspath(os.environ.get("SERVER_DIR", "/data")) |
| PLUGINS_DIR = os.path.join(BASE_DIR, "plugins") |
| JARS_DIR = os.path.join(BASE_DIR, "jars") |
| CONFIG_PATH = os.path.join(BASE_DIR, "hfos_config.json") |
| SECRET_PATH = os.path.join(BASE_DIR, ".hfos_secret") |
|
|
| for d in (BASE_DIR, PLUGINS_DIR, JARS_DIR): |
| os.makedirs(d, exist_ok=True) |
|
|
| DEFAULT_JVM = ( |
| "-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 " |
| "-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch " |
| "-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M " |
| "-XX:G1ReservePercent=20 -XX:G1MixedGCCountTarget=4 -XX:InitiatingHeapOccupancyPercent=15 " |
| "-XX:G1MixedGCLiveThresholdPercent=90 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem " |
| "-XX:MaxTenuringThreshold=1 -Dusing.aikars.flags=https://mcflags.emc.gs " |
| "-Dterminal.jline=false -Dterminal.ansi=true -Dfile.encoding=UTF-8" |
| ) |
|
|
| cfg_lock = threading.Lock() |
|
|
| def load_config() -> dict: |
| c = { |
| "provider": "purpur", |
| "version": "latest", |
| "jar": "", |
| "memory": "6G", |
| "auto_start": True, |
| "jvm": DEFAULT_JVM, |
| } |
| with cfg_lock: |
| if os.path.exists(CONFIG_PATH): |
| try: |
| c.update(json.load(open(CONFIG_PATH, "r", encoding="utf-8"))) |
| except Exception: pass |
| return c |
|
|
| def save_config(c: dict): |
| with cfg_lock: |
| try: |
| json.dump(c, open(CONFIG_PATH, "w", encoding="utf-8"), indent=2) |
| except Exception: pass |
|
|
| |
| |
| |
| def get_secret() -> str: |
| if not os.path.exists(SECRET_PATH): |
| open(SECRET_PATH, "w").write(secrets.token_hex(32)) |
| return open(SECRET_PATH, "r").read().strip() |
|
|
| def get_admin_pass() -> str: |
| return os.environ.get("PASS", "").strip() or "admin" |
|
|
| def gen_token() -> str: |
| payload = f"{secrets.token_hex(8)}:{int(time.time()) + 86400}" |
| sig = hmac.new(get_secret().encode(), payload.encode(), hashlib.sha256).digest() |
| return f"{base64.urlsafe_b64encode(payload.encode()).decode().rstrip('=')}.{base64.urlsafe_b64encode(sig).decode().rstrip('=')}" |
|
|
| def verify_token(t: str) -> bool: |
| if not t or "." not in t: return False |
| try: |
| p_b64, s_b64 = t.split(".") |
| p_b64 += "=" * (-len(p_b64) % 4); s_b64 += "=" * (-len(s_b64) % 4) |
| payload = base64.urlsafe_b64decode(p_b64).decode() |
| if time.time() > int(payload.split(":")[1]): return False |
| expected = hmac.new(get_secret().encode(), payload.encode(), hashlib.sha256).digest() |
| return hmac.compare_digest(base64.urlsafe_b64decode(s_b64), expected) |
| except Exception: return False |
|
|
| def check_auth(): |
| auth = request.headers.get("Authorization", "").replace("Bearer ", "") |
| if not verify_token(auth): |
| return jsonify({"error": "Unauthorized"}), 401 |
|
|
| def safe_path(p: str) -> str: |
| rp = os.path.abspath(os.path.join(BASE_DIR, (p or "").lstrip("/"))) |
| if not rp.startswith(BASE_DIR): raise PermissionError("Path traversal blocked") |
| return rp |
|
|
| |
| |
| |
| class ServerEngine: |
| def __init__(self): |
| self.proc = None |
| self.lock = threading.Lock() |
| self.lines = deque(maxlen=2000) |
| self.after_count = 0 |
| self.start_time = 0 |
| self.players = set() |
| self.active_version = "Unknown" |
|
|
| def log(self, msg: str): |
| with self.lock: |
| self.lines.append(msg) |
| self.after_count += 1 |
| |
|
|
| def download_jar(self, provider: str, version: str) -> str: |
| self.log(f"[HFOS] Fetching {provider} version {version}...") |
| headers = {"User-Agent": "HFOS/2.1"} |
| |
| if version == "latest": |
| if provider == "purpur": |
| r = httpx.get("https://api.purpurmc.org/v2/purpur", headers=headers, timeout=15) |
| version = r.json()["versions"][-1] |
| elif provider == "paper": |
| r = httpx.get("https://api.papermc.io/v2/projects/paper", headers=headers, timeout=15) |
| version = r.json()["versions"][-1] |
|
|
| jar_name = f"{provider}-{version}.jar" |
| jar_path = os.path.join(JARS_DIR, jar_name) |
|
|
| if os.path.exists(jar_path): |
| self.log(f"[HFOS] Jar {jar_name} already exists. Skipping download.") |
| return jar_name |
|
|
| self.log(f"[HFOS] Downloading {jar_name} (This may take a moment)...") |
| if provider == "purpur": |
| url = f"https://api.purpurmc.org/v2/purpur/{version}/latest/download" |
| else: |
| r = httpx.get(f"https://api.papermc.io/v2/projects/paper/versions/{version}", headers=headers).json() |
| build = r["builds"][-1] |
| r2 = httpx.get(f"https://api.papermc.io/v2/projects/paper/versions/{version}/builds/{build}", headers=headers).json() |
| dl_name = r2["downloads"]["application"]["name"] |
| url = f"https://api.papermc.io/v2/projects/paper/versions/{version}/builds/{build}/downloads/{dl_name}" |
|
|
| with httpx.stream("GET", url, headers=headers, follow_redirects=True) as r: |
| r.raise_for_status() |
| with open(jar_path, "wb") as f: |
| for chunk in r.iter_bytes(8192): f.write(chunk) |
| |
| self.log(f"[HFOS] Successfully downloaded {jar_name}.") |
| return jar_name |
|
|
| def preflight_check(self): |
| c = load_config() |
| if not c.get("jar") or not os.path.exists(os.path.join(JARS_DIR, c.get("jar", ""))): |
| self.log("[HFOS] No valid Jar found. Auto-provisioning started...") |
| try: |
| jar = self.download_jar(c["provider"], c["version"]) |
| c["jar"] = jar |
| save_config(c) |
| except Exception as e: |
| self.log(f"[HFOS Error] Auto-provisioning failed: {e}") |
| return False |
|
|
| with open(os.path.join(BASE_DIR, "eula.txt"), "w") as f: f.write("eula=true\n") |
| |
| sp = os.path.join(BASE_DIR, "server.properties") |
| if not os.path.exists(sp): |
| with open(sp, "w") as f: |
| f.write("server-port=25565\nonline-mode=false\nmotd=Hosted on HFOS\nmax-players=50\n") |
| return True |
|
|
| def _reader(self): |
| |
| for line in iter(self.proc.stdout.readline, ""): |
| if not line: break |
| clean_line = line.rstrip() |
| self.log(clean_line) |
| |
| low = clean_line.lower() |
| v_match = re.search(r"starting minecraft server version\s+([\d.]+)", low) |
| if v_match: self.active_version = v_match.group(1) |
| |
| p_match = re.search(r"\s(\S+)\s+(joined|left) the game", low) |
| if p_match: |
| if p_match.group(2) == "joined": self.players.add(p_match.group(1)) |
| else: self.players.discard(p_match.group(1)) |
|
|
| def start(self): |
| if self.proc and self.proc.poll() is None: return "Running" |
| if not self.preflight_check(): return "Failed Preflight" |
|
|
| c = load_config() |
| jar_path = os.path.join(JARS_DIR, c["jar"]) |
| |
| |
| cmd = ["java", f"-Xmx{c['memory']}", f"-Xms{c['memory']}"] + c["jvm"].split() + ["-jar", jar_path, "--nogui"] |
| self.log(f"[HFOS] Launching Engine: {' '.join(cmd)}") |
| self.players.clear() |
| self.start_time = time.time() |
| |
| |
| self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASE_DIR, text=True, bufsize=1) |
| threading.Thread(target=self._reader, daemon=True).start() |
| return "Started" |
|
|
| def stop(self): |
| if not self.proc or self.proc.poll() is not None: return |
| self.log("[HFOS] Sending gracefully stop...") |
| try: |
| self.proc.stdin.write("stop\n"); self.proc.stdin.flush() |
| self.proc.wait(15) |
| except Exception: |
| self.proc.kill() |
| self.players.clear() |
|
|
| def send(self, cmd: str): |
| if self.proc and self.proc.poll() is None: |
| self.proc.stdin.write(cmd.strip() + "\n"); self.proc.stdin.flush() |
| self.log(f"> {cmd}") |
|
|
| engine = ServerEngine() |
|
|
| def autostart_worker(): |
| time.sleep(2) |
| if load_config().get("auto_start", True): |
| engine.start() |
|
|
| threading.Thread(target=autostart_worker, daemon=True).start() |
|
|
| |
| |
| |
| app = Flask(__name__) |
| CORS(app) |
| app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024 |
|
|
| @app.route("/") |
| def index(): |
| return Response(UI_HTML, mimetype="text/html") |
|
|
| @app.post("/api/auth") |
| def auth(): |
| provided_pass = request.form.get("pass", "") |
| if hmac.compare_digest(provided_pass, get_admin_pass()): |
| return jsonify({"token": gen_token()}) |
| return jsonify({"error": "Invalid password"}), 401 |
|
|
| @app.before_request |
| def check_requests(): |
| if request.path.startswith("/api/") and request.path != "/api/auth": |
| return check_auth() |
|
|
| @app.get("/api/status") |
| def status(): |
| running = engine.proc is not None and engine.proc.poll() is None |
| mem = psutil.virtual_memory() |
| c = load_config() |
| return jsonify({ |
| "running": running, |
| "uptime": int(time.time() - engine.start_time) if running else 0, |
| "players": list(engine.players), |
| "version": engine.active_version if running else c.get("version"), |
| "cpu": psutil.cpu_percent(), |
| "ram_used": mem.used, |
| "ram_total": mem.total, |
| "jar": c.get("jar") |
| }) |
|
|
| @app.post("/api/power") |
| def power(): |
| act = request.form.get("action") |
| if act == "start": engine.start() |
| elif act == "stop": threading.Thread(target=engine.stop).start() |
| elif act == "kill": |
| if engine.proc: engine.proc.kill() |
| return jsonify({"ok": True}) |
|
|
| @app.get("/api/console") |
| def console(): |
| after = int(request.args.get("after", 0)) |
| with engine.lock: |
| lines = list(engine.lines)[max(0, after - engine.after_count + len(engine.lines)):] |
| idx = engine.after_count |
| return jsonify({"lines": lines, "after": idx}) |
|
|
| @app.post("/api/console") |
| def console_send(): |
| engine.send(request.form.get("cmd", "")) |
| return jsonify({"ok": True}) |
|
|
| |
| @app.post("/api/system/repair") |
| def system_repair(): |
| if engine.proc and engine.proc.poll() is None: |
| return jsonify({"error": "Server must be stopped before repairing."}), 400 |
| |
| deleted_items = [] |
| |
| for target in ["cache", "libraries", "versions"]: |
| p = os.path.join(BASE_DIR, target) |
| if os.path.exists(p): |
| shutil.rmtree(p) |
| deleted_items.append(target) |
| |
| engine.log(f"[HFOS System] Repaired environment by clearing: {', '.join(deleted_items)}") |
| return jsonify({"ok": True, "msg": f"Cleared: {', '.join(deleted_items)}"}) |
|
|
| |
| @app.get("/api/fs/list") |
| def fs_list(): |
| p = safe_path(request.args.get("path", "")) |
| if not os.path.isdir(p): return jsonify([]) |
| items = [] |
| for f in os.listdir(p): |
| fp = os.path.join(p, f) |
| is_dir = os.path.isdir(fp) |
| items.append({ |
| "name": f, "is_dir": is_dir, |
| "size": 0 if is_dir else os.path.getsize(fp), |
| "date": os.path.getmtime(fp) |
| }) |
| return jsonify(sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower()))) |
|
|
| @app.post("/api/fs/read") |
| def fs_read(): |
| p = safe_path(request.form.get("path", "")) |
| try: return jsonify({"content": open(p, "r", encoding="utf-8").read()}) |
| except Exception as e: return jsonify({"error": str(e)}), 400 |
|
|
| @app.post("/api/fs/write") |
| def fs_write(): |
| p = safe_path(request.form.get("path", "")) |
| with open(p, "w", encoding="utf-8") as f: f.write(request.form.get("content", "")) |
| return jsonify({"ok": True}) |
|
|
| @app.post("/api/fs/delete") |
| def fs_delete(): |
| p = safe_path(request.form.get("path", "")) |
| if os.path.isdir(p): shutil.rmtree(p) |
| else: os.remove(p) |
| return jsonify({"ok": True}) |
|
|
| @app.post("/api/fs/upload") |
| def fs_upload(): |
| p = safe_path(request.form.get("path", "")) |
| file = request.files.get("file") |
| if file: file.save(os.path.join(p, file.filename)) |
| return jsonify({"ok": True}) |
|
|
| |
| @app.get("/api/config") |
| def get_config(): |
| return jsonify(load_config()) |
|
|
| @app.post("/api/config") |
| def set_config(): |
| c = load_config() |
| for k in c.keys(): |
| if k in request.form: c[k] = request.form[k] |
| save_config(c) |
| return jsonify({"ok": True}) |
|
|
| @app.post("/api/plugins/install_url") |
| def plugins_install_url(): |
| url = request.form.get("url", "").strip() |
| if not url: return jsonify({"error": "Empty URL provided."}), 400 |
| |
| try: |
| if url.endswith(".jar"): |
| fname = url.split("/")[-1] |
| dl_url = url |
| elif "modrinth.com/" in url: |
| match = re.search(r'modrinth\.com/(?:plugin|project)/([^/?#]+)', url) |
| if not match: return jsonify({"error": "Invalid Modrinth URL format."}), 400 |
| slug = match.group(1) |
| versions = httpx.get(f"https://api.modrinth.com/v2/project/{slug}/version", headers={"User-Agent":"HFOS/2.1"}).json() |
| if not versions: return jsonify({"error": "No files found for this Modrinth project."}), 404 |
| |
| file_info = versions[0]["files"][0] |
| dl_url = file_info["url"] |
| fname = file_info["filename"] |
| else: |
| return jsonify({"error": "Unsupported URL format."}), 400 |
|
|
| engine.log(f"[HFOS] Fetching plugin from: {dl_url}") |
| with httpx.stream("GET", dl_url, follow_redirects=True, headers={"User-Agent":"HFOS/2.1"}) as r: |
| r.raise_for_status() |
| with open(os.path.join(PLUGINS_DIR, fname), "wb") as f: |
| for chunk in r.iter_bytes(8192): f.write(chunk) |
| |
| engine.log(f"[HFOS] Installed plugin: {fname}") |
| return jsonify({"ok": True, "file": fname}) |
| except Exception as e: |
| return jsonify({"error": str(e)}), 500 |
|
|
| |
| |
| |
| UI_HTML = """<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>HFOS - Mobile Native Controller</title> |
| <style> |
| :root{--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;--bg4:#475569;--fg:#f8fafc;--mut:#94a3b8;--acc:#38bdf8;--acc-hov:#0ea5e9;--err:#ef4444;--succ:#22c55e;--warn:#f59e0b;--font:'Inter',system-ui,sans-serif;--mono:'JetBrains Mono',monospace;--rad:8px} |
| *{box-sizing:border-box;margin:0;padding:0;scrollbar-width:thin;scrollbar-color:var(--bg4) var(--bg2)} |
| body{background:var(--bg);color:var(--fg);font-family:var(--font);height:100vh;display:flex;flex-direction:column;overflow:hidden;-webkit-font-smoothing:antialiased} |
| button,input,select,textarea{font:inherit;color:inherit;outline:none} |
| .btn{background:var(--bg3);border:1px solid var(--bg4);padding:10px 16px;border-radius:var(--rad);cursor:pointer;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:14px;white-space:nowrap} |
| .btn:hover{background:var(--bg4)}.btn.primary{background:var(--acc);color:#0f172a;border-color:var(--acc)}.btn.primary:hover{background:var(--acc-hov)}.btn.danger{background:transparent;color:var(--err);border-color:var(--err)}.btn.danger:hover{background:var(--err);color:#fff}.btn.warn{background:transparent;color:var(--warn);border-color:var(--warn)}.btn.warn:hover{background:var(--warn);color:#fff} |
| .input{background:var(--bg);border:1px solid var(--bg4);padding:10px 14px;border-radius:var(--rad);width:100%;font-size:14px}.input:focus{border-color:var(--acc)} |
| .auth-overlay{position:fixed;inset:0;background:rgba(15,23,42,0.95);backdrop-filter:blur(10px);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px} |
| .auth-box{background:var(--bg2);padding:30px;border-radius:16px;width:100%;max-width:380px;border:1px solid var(--bg3);box-shadow:0 25px 50px -12px rgba(0,0,0,0.5)} |
| .auth-box h2{margin-bottom:10px;font-size:22px;font-weight:600} |
| .auth-box p{color:var(--mut);font-size:14px;margin-bottom:20px} |
| |
| /* Mobile App Layout */ |
| .mobile-header{display:none;background:var(--bg2);height:60px;align-items:center;padding:0 20px;border-bottom:1px solid var(--bg3);justify-content:space-between;z-index:900} |
| .hamburger{font-size:24px;background:none;border:none;color:var(--fg);cursor:pointer} |
| .app-layout{display:flex;flex:1;height:100%;overflow:hidden;position:relative} |
| .sidebar{width:250px;background:var(--bg2);border-right:1px solid var(--bg3);display:flex;flex-direction:column;transition:transform 0.3s ease;z-index:1000} |
| .brand{padding:20px;font-size:20px;font-weight:700;display:flex;align-items:center;gap:10px;color:var(--acc);border-bottom:1px solid var(--bg3)} |
| .nav{padding:15px;flex:1;overflow-y:auto} |
| .nav-item{padding:12px 15px;margin-bottom:5px;border-radius:var(--rad);cursor:pointer;font-size:15px;color:var(--mut);display:flex;align-items:center;gap:12px;transition:all .2s} |
| .nav-item:hover{background:var(--bg3);color:var(--fg)}.nav-item.active{background:var(--acc);color:#0f172a;font-weight:600} |
| |
| .main{flex:1;display:flex;flex-direction:column;min-width:0;height:100%} |
| .topbar{height:70px;border-bottom:1px solid var(--bg3);display:flex;align-items:center;padding:0 25px;justify-content:space-between;background:var(--bg2)} |
| .status-badge{display:flex;align-items:center;gap:8px;background:var(--bg3);padding:6px 14px;border-radius:20px;font-size:13px;font-weight:600;border:1px solid var(--bg4)} |
| .dot{width:10px;height:10px;border-radius:50%;background:var(--mut)}.dot.on{background:var(--succ);box-shadow:0 0 10px var(--succ)} |
| |
| .content{flex:1;overflow-y:auto;padding:25px;background:var(--bg);-webkit-overflow-scrolling:touch} |
| |
| /* Responsive Grid */ |
| .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px;margin-bottom:20px} |
| .card{background:var(--bg2);border:1px solid var(--bg3);border-radius:12px;padding:20px} |
| .card h3{font-size:14px;color:var(--mut);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px} |
| .card .val{font-size:32px;font-weight:700;font-family:var(--mono)} |
| |
| /* Terminal */ |
| .term-wrap{background:var(--bg2);border:1px solid var(--bg3);border-radius:12px;display:flex;flex-direction:column;height:calc(100vh - 150px)} |
| .term-body{flex:1;overflow-y:auto;padding:15px;font-family:var(--mono);font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-all;color:#d1d5db} |
| .term-input{display:flex;padding:15px;border-top:1px solid var(--bg3);background:var(--bg);gap:10px} |
| .log-err{color:#fca5a5}.log-warn{color:#fde047}.log-hfos{color:var(--acc);font-weight:bold} |
| |
| /* Files */ |
| .file-list{background:var(--bg2);border:1px solid var(--bg3);border-radius:12px;overflow:hidden;overflow-x:auto} |
| .file-row{display:flex;align-items:center;padding:15px 20px;border-bottom:1px solid var(--bg3);font-size:14px;transition:background .15s;min-width:500px} |
| .file-row:hover{background:var(--bg3)} |
| .file-row:last-child{border:none} |
| .f-icon{width:35px;font-size:20px}.f-name{flex:1;cursor:pointer;font-weight:500}.f-size{width:100px;color:var(--mut);font-family:var(--mono);font-size:12px}.f-date{width:160px;color:var(--mut);font-size:12px}.f-acts{display:flex;gap:8px} |
| .path-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:15px;background:var(--bg2);padding:15px;border-radius:12px;border:1px solid var(--bg3)} |
| |
| /* Editor */ |
| .modal{position:fixed;inset:0;background:rgba(0,0,0,0.8);display:none;align-items:center;justify-content:center;z-index:2000;padding:20px} |
| .modal-c{background:var(--bg2);width:100%;max-width:900px;height:85vh;border-radius:12px;display:flex;flex-direction:column;border:1px solid var(--bg3)} |
| .modal-h{padding:15px 20px;border-bottom:1px solid var(--bg3);display:flex;justify-content:space-between;align-items:center} |
| .modal-b{flex:1;padding:0} |
| .modal-b textarea{width:100%;height:100%;border:none;background:var(--bg);padding:20px;font-family:var(--mono);font-size:14px;resize:none;color:var(--fg)} |
| |
| #toast{position:fixed;bottom:25px;right:25px;background:var(--bg3);color:var(--fg);padding:14px 24px;border-radius:8px;font-size:15px;box-shadow:0 10px 25px rgba(0,0,0,0.5);border:1px solid var(--bg4);transform:translateY(100px);opacity:0;transition:all .3s;z-index:9999;font-weight:500} |
| #toast.show{transform:translateY(0);opacity:1} |
| |
| @media (max-width: 768px) { |
| .mobile-header { display: flex; } |
| .sidebar { position: absolute; left: 0; top: 0; bottom: 0; transform: translateX(-100%); } |
| .sidebar.open { transform: translateX(0); box-shadow: 20px 0 50px rgba(0,0,0,0.5); } |
| .topbar { display: none; } |
| .content { padding: 15px; } |
| .grid { grid-template-columns: 1fr; } |
| .term-wrap { height: calc(100vh - 120px); } |
| .file-row { padding: 12px 15px; } |
| .path-bar { flex-direction: column; align-items: stretch; } |
| #toast { left: 20px; right: 20px; text-align: center; bottom: 20px; } |
| } |
| </style> |
| </head> |
| <body> |
| |
| <div id="toast"></div> |
| |
| <!-- Mobile Nav Bar --> |
| <div class="mobile-header"> |
| <div style="font-weight:700;font-size:18px;color:var(--acc)">π HFOS</div> |
| <button class="hamburger" onclick="toggleSidebar()">β°</button> |
| </div> |
| |
| <!-- Auth --> |
| <div class="auth-overlay" id="authScreen"> |
| <div class="auth-box"> |
| <h2>HFOS Secure Login</h2> |
| <p>Enter your environment password.</p> |
| <input type="password" id="passInp" class="input" placeholder="Password" style="margin-bottom:15px" onkeyup="if(event.key==='Enter')login()"> |
| <button class="btn primary" id="btnLogin" style="width:100%" onclick="login()">Authenticate</button> |
| <p id="authErr" style="color:var(--err);font-size:13px;margin-top:12px;text-align:center;display:none;"></p> |
| </div> |
| </div> |
| |
| <!-- Editor --> |
| <div class="modal" id="editModal"> |
| <div class="modal-c"> |
| <div class="modal-h"> |
| <h3 id="editTitle" style="font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%">Editing...</h3> |
| <div style="display:flex;gap:10px"> |
| <button class="btn" onclick="document.getElementById('editModal').style.display='none'">Cancel</button> |
| <button class="btn primary" onclick="saveFile()">Save</button> |
| </div> |
| </div> |
| <div class="modal-b"><textarea id="editArea" spellcheck="false"></textarea></div> |
| </div> |
| </div> |
| |
| <!-- Main App --> |
| <div class="app-layout" id="app" style="display:none"> |
| <!-- Sidebar Overlay for mobile --> |
| <div id="sidebarOverlay" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,0.5);z-index:999" onclick="toggleSidebar()"></div> |
| |
| <div class="sidebar" id="sidebar"> |
| <div class="brand">π HFOS <span style="font-size:12px;color:var(--mut);margin-left:auto">v2.1</span></div> |
| <div class="nav"> |
| <div class="nav-item active" onclick="nav('dash')">π Dashboard</div> |
| <div class="nav-item" onclick="nav('term')">π» Console</div> |
| <div class="nav-item" onclick="nav('files')">π Files</div> |
| <div class="nav-item" onclick="nav('plugins')">π§© Add Plugin</div> |
| <div class="nav-item" onclick="nav('settings')">βοΈ Settings</div> |
| </div> |
| <div style="padding:15px;border-top:1px solid var(--bg3)"> |
| <button class="btn danger" style="width:100%" onclick="logout()">Logout</button> |
| </div> |
| </div> |
| |
| <div class="main"> |
| <div class="topbar"> |
| <h2 id="viewTitle" style="font-size:20px;font-weight:600">Dashboard</h2> |
| <div class="status-badge"><div class="dot" id="sDot"></div><span id="sTxt">Unknown</span></div> |
| </div> |
| <div class="content" id="cArea"></div> |
| </div> |
| </div> |
| |
| <script> |
| let tk = localStorage.getItem('hfos_tk') || ''; |
| let view = 'dash', currentPath = '', editFile = ''; |
| let cAfter = 0; |
| let tStatus, tTerm; |
| |
| // CRITICAL FIX: Relative routing for HuggingFace Space Proxies |
| const API_BASE = window.location.pathname.replace(/\\/$/, '') + '/api'; |
| |
| const req = async (path, opt={}) => { |
| opt.headers = opt.headers || {}; |
| opt.headers['Authorization'] = 'Bearer ' + tk; |
| const r = await fetch(API_BASE + path, opt); |
| if(r.status === 401) { localStorage.removeItem('hfos_tk'); location.reload(); } |
| return r; |
| }; |
| |
| const showToast = msg => { |
| const t = document.getElementById('toast'); |
| t.textContent = msg; t.classList.add('show'); |
| setTimeout(() => t.classList.remove('show'), 3000); |
| }; |
| |
| const toggleSidebar = () => { |
| const sb = document.getElementById('sidebar'); |
| const ov = document.getElementById('sidebarOverlay'); |
| const isOpen = sb.classList.contains('open'); |
| if(isOpen) { sb.classList.remove('open'); ov.style.display = 'none'; } |
| else { sb.classList.add('open'); ov.style.display = 'block'; } |
| }; |
| |
| const login = async () => { |
| const btn = document.getElementById('btnLogin'); |
| const err = document.getElementById('authErr'); |
| const pwd = document.getElementById('passInp').value; |
| |
| btn.innerText = 'Authenticating...'; |
| btn.disabled = true; |
| err.style.display = 'none'; |
| |
| try { |
| const fd = new FormData(); fd.append('pass', pwd); |
| // Use relative path routing |
| const r = await fetch(API_BASE + '/auth', {method:'POST', body:fd}); |
| const d = await r.json(); |
| if(d.token) { |
| tk = d.token; |
| localStorage.setItem('hfos_tk', tk); |
| init(); |
| } else { |
| err.innerText = d.error || 'Access Denied'; |
| err.style.display = 'block'; |
| } |
| } catch(e) { |
| err.innerText = 'Network error. Are you on a HuggingFace proxy?'; |
| err.style.display = 'block'; |
| } |
| |
| btn.innerText = 'Authenticate'; |
| btn.disabled = false; |
| }; |
| |
| const logout = () => { localStorage.removeItem('hfos_tk'); location.reload(); }; |
| |
| const formatSize = b => { |
| if(b===0)return '0 B'; const k=1024, s=['B','KB','MB','GB'], i=Math.floor(Math.log(b)/Math.log(k)); |
| return parseFloat((b/Math.pow(k,i)).toFixed(1))+' '+s[i]; |
| }; |
| |
| const formatDate = ts => new Date(ts*1000).toLocaleString(undefined, {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); |
| |
| const init = () => { |
| document.getElementById('authScreen').style.display = 'none'; |
| document.getElementById('app').style.display = 'flex'; |
| nav('dash'); |
| setInterval(pollStatus, 3000); pollStatus(); |
| }; |
| |
| const pollStatus = async () => { |
| try { |
| const r = await req('/status'); const d = await r.json(); |
| document.getElementById('sDot').className = 'dot ' + (d.running ? 'on' : ''); |
| document.getElementById('sTxt').textContent = d.running ? 'Online' : 'Offline'; |
| |
| if(view === 'dash') { |
| const mem = Math.round(d.ram_used/1024/1024/1024 * 10)/10; |
| const total = Math.round(d.ram_total/1024/1024/1024 * 10)/10; |
| document.getElementById('cArea').innerHTML = ` |
| <div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px"> |
| <button class="btn primary" style="flex:1;min-width:140px" onclick="power('start')">βΆ Start</button> |
| <button class="btn" style="flex:1;min-width:140px" onclick="power('stop')">βΉ Stop</button> |
| <button class="btn danger" style="flex:1;min-width:140px" onclick="power('kill')">β‘ Kill</button> |
| </div> |
| <div class="grid"> |
| <div class="card"><h3>CPU Usage</h3><div class="val">${d.cpu}%</div></div> |
| <div class="card"><h3>Memory</h3><div class="val">${mem} / ${total} GB</div></div> |
| <div class="card"><h3>Players</h3><div class="val">${d.players.length}</div></div> |
| <div class="card"><h3>Active Engine</h3><div class="val" style="font-size:22px">${d.version||'Booting...'}</div></div> |
| </div> |
| <div class="card"> |
| <h3>Storage Persistent Volume</h3> |
| <p style="color:var(--mut);font-size:14px;margin-top:5px;line-height:1.5"> |
| HFOS v2.1 saves all server data directly to your HuggingFace Space Persistent Storage Drive.<br> |
| <span style="color:var(--succ);font-weight:600">β Real-time disk writing active.</span> |
| </p> |
| </div>`; |
| } |
| } catch(e) {} |
| }; |
| |
| const power = async (act) => { |
| const fd = new FormData(); fd.append('action', act); |
| await req('/power', {method:'POST', body:fd}); showToast('Signal sent: ' + act); |
| pollStatus(); |
| }; |
| |
| const nav = (v) => { |
| view = v; |
| if(window.innerWidth <= 768) toggleSidebar(); // auto close on mobile |
| |
| document.querySelectorAll('.nav-item').forEach(e => e.classList.toggle('active', e.textContent.toLowerCase().includes(v.replace('plugins','plugin')))); |
| const c = document.getElementById('cArea'); |
| clearInterval(tTerm); |
| |
| let title = 'Dashboard'; |
| |
| if(v === 'dash') { pollStatus(); } |
| else if(v === 'term') { |
| title = 'Server Console'; |
| c.innerHTML = `<div class="term-wrap"><div class="term-body" id="tBody"></div><div class="term-input"><input class="input" id="tInp" placeholder="Execute command (e.g. op username)..." onkeyup="if(event.key==='Enter')sendCmd()"><button class="btn primary" onclick="sendCmd()">Send</button></div></div>`; |
| cAfter = 0; tTerm = setInterval(pollTerm, 1500); pollTerm(); |
| } |
| else if(v === 'files') { title = 'File Manager'; loadFs(''); } |
| else if(v === 'plugins') { |
| title = 'Plugin Installer'; |
| c.innerHTML = ` |
| <div class="card" style="max-width:700px; margin:0 auto;"> |
| <h3 style="font-size:18px;color:var(--fg);margin-bottom:8px">URL-Based Installer</h3> |
| <p style="color:var(--mut); font-size:14px; margin-bottom:20px; line-height:1.6"> |
| Paste the direct web link to any Modrinth plugin, or paste a direct <span style="font-family:var(--mono)">.jar</span> download link. HFOS will automatically resolve the file and place it in your <span style="font-family:var(--mono)">/plugins</span> directory. |
| </p> |
| |
| <label style="display:block;margin-bottom:8px;font-size:13px;color:var(--mut);font-weight:600">MODRINTH URL OR DIRECT JAR LINK</label> |
| <input class="input" id="pUrl" placeholder="e.g. https://modrinth.com/plugin/essentialsx" style="margin-bottom:15px;padding:12px;font-size:15px"> |
| |
| <button class="btn primary" id="pBtn" onclick="installPluginFromUrl()" style="width:100%;padding:12px;font-size:16px">Download & Install Plugin</button> |
| </div>`; |
| } |
| else if(v === 'settings') { |
| title = 'System Configuration'; |
| req('/config').then(r=>r.json()).then(d => { |
| c.innerHTML = ` |
| <div class="card" style="max-width:600px;margin:0 auto;margin-bottom:20px"> |
| <div style="margin-bottom:20px"><label style="display:block;margin-bottom:8px;font-size:13px;font-weight:600;color:var(--mut)">Server Engine</label> |
| <select class="input" id="cfgProv"><option value="purpur" ${d.provider==='purpur'?'selected':''}>Purpur (Recommended)</option><option value="paper" ${d.provider==='paper'?'selected':''}>Paper</option></select></div> |
| |
| <div style="margin-bottom:20px"><label style="display:block;margin-bottom:8px;font-size:13px;font-weight:600;color:var(--mut)">Minecraft Version</label> |
| <input class="input" id="cfgVer" value="${d.version}" placeholder="e.g. 1.20.4 or latest"></div> |
| |
| <div style="margin-bottom:25px"><label style="display:block;margin-bottom:8px;font-size:13px;font-weight:600;color:var(--mut)">Allocated RAM (-Xmx)</label> |
| <input class="input" id="cfgMem" value="${d.memory}"></div> |
| |
| <button class="btn primary" style="width:100%" onclick="saveCfg()">Save Configuration & Apply</button> |
| </div> |
| |
| <div class="card" style="max-width:600px;margin:0 auto;border-color:rgba(245,158,11,0.3)"> |
| <h3 style="color:var(--warn)">Repair & Diagnostics</h3> |
| <p style="color:var(--mut);font-size:13px;margin-bottom:15px;line-height:1.5">If your server crashed with a ZipException or corrupted library, click this button to wipe the cache. It forces Purpur to re-download the vanilla files safely.</p> |
| <button class="btn warn" style="width:100%" onclick="repairSystem()">Clear Cache & Repair Jars</button> |
| </div>`; |
| }); |
| } |
| |
| document.getElementById('viewTitle').innerText = title; |
| const mobTitle = document.querySelector('.mobile-header div'); |
| if(mobTitle) mobTitle.innerText = title; |
| }; |
| |
| const pollTerm = async () => { |
| if(view !== 'term') return; |
| try { |
| const r = await req('/console?after='+cAfter); const d = await r.json(); |
| if(d.lines && d.lines.length > 0) { |
| const tb = document.getElementById('tBody'); |
| d.lines.forEach(l => { |
| const cls = l.includes('ERROR')?'log-err':l.includes('WARN')?'log-warn':l.includes('[HFOS]')?'log-hfos':''; |
| tb.insertAdjacentHTML('beforeend', `<div class="${cls}">${l.replace(/</g,'<')}</div>`); |
| }); |
| cAfter = d.after; tb.scrollTop = tb.scrollHeight; |
| } |
| } catch(e) {} |
| }; |
| |
| const sendCmd = async () => { |
| const i = document.getElementById('tInp'); |
| if(!i.value.trim()) return; |
| const fd = new FormData(); fd.append('cmd', i.value); |
| await req('/console', {method:'POST', body:fd}); |
| i.value = ''; pollTerm(); |
| }; |
| |
| const loadFs = async (path) => { |
| currentPath = path; |
| const r = await req('/fs/list?path='+encodeURIComponent(path)); const files = await r.json(); |
| const c = document.getElementById('cArea'); |
| |
| let html = `<div class="path-bar"> |
| <button class="btn" onclick="loadFs('')">π Home</button> |
| <span style="color:var(--mut);font-weight:500">/ ${path.replace(/\\/g, '/')}</span> |
| <div style="flex:1"></div> |
| <button class="btn primary" onclick="document.getElementById('fUp').click()">+ Upload File</button> |
| <input type="file" id="fUp" style="display:none" onchange="uploadF(this)"> |
| </div><div class="file-list">`; |
| |
| if(path !== '') html += `<div class="file-row"><div class="f-icon">π</div><div class="f-name" onclick="loadFs('${path.split('/').slice(0,-1).join('/')}')">Go Back ..</div></div>`; |
| if(files.length === 0 && path === '') html += `<div style="padding:30px;text-align:center;color:var(--mut)">Directory is empty.</div>`; |
| |
| files.forEach(f => { |
| const fp = path ? path + '/' + f.name : f.name; |
| const isTxt = f.name.match(/\\.(txt|json|yml|yaml|properties|xml|log|sh)$/i); |
| html += ` |
| <div class="file-row"> |
| <div class="f-icon">${f.is_dir?'π':'π'}</div> |
| <div class="f-name" onclick="${f.is_dir ? `loadFs('${fp}')` : ''}">${f.name}</div> |
| <div class="f-size">${formatSize(f.size)}</div> |
| <div class="f-date">${formatDate(f.date)}</div> |
| <div class="f-acts"> |
| ${!f.is_dir && isTxt ? `<button class="btn" onclick="openEdit('${fp}')">Edit</button>` : ''} |
| <button class="btn danger" onclick="deleteF('${fp}')">Del</button> |
| </div> |
| </div>`; |
| }); |
| html += `</div>`; c.innerHTML = html; |
| }; |
| |
| const deleteF = async (p) => { |
| if(!confirm('Permanently delete ' + p + '?')) return; |
| const fd = new FormData(); fd.append('path', p); |
| await req('/fs/delete', {method:'POST', body:fd}); |
| loadFs(currentPath); showToast('Deleted ' + p); |
| }; |
| |
| const uploadF = async (inp) => { |
| const f = inp.files[0]; if(!f) return; |
| const fd = new FormData(); fd.append('path', currentPath); fd.append('file', f); |
| showToast('Uploading...'); |
| await req('/fs/upload', {method:'POST', body:fd}); |
| loadFs(currentPath); showToast('Upload complete'); |
| inp.value = ''; // reset |
| }; |
| |
| const openEdit = async (p) => { |
| const fd = new FormData(); fd.append('path', p); |
| const r = await req('/fs/read', {method:'POST', body:fd}); const d = await r.json(); |
| if(d.error) return alert(d.error); |
| editFile = p; |
| document.getElementById('editTitle').innerText = 'Editing: ' + p.split('/').pop(); |
| document.getElementById('editArea').value = d.content; |
| document.getElementById('editModal').style.display = 'flex'; |
| }; |
| |
| const saveFile = async () => { |
| const fd = new FormData(); fd.append('path', editFile); fd.append('content', document.getElementById('editArea').value); |
| await req('/fs/write', {method:'POST', body:fd}); |
| document.getElementById('editModal').style.display = 'none'; showToast('File saved successfully.'); |
| }; |
| |
| const installPluginFromUrl = async () => { |
| const i = document.getElementById('pUrl'); |
| const b = document.getElementById('pBtn'); |
| const url = i.value.trim(); |
| if(!url) { showToast('Please enter a URL'); return; } |
| |
| b.innerText = 'Downloading & Installing...'; b.disabled = true; |
| const fd = new FormData(); fd.append('url', url); |
| |
| try { |
| const r = await req('/plugins/install_url', {method:'POST', body:fd}); |
| const d = await r.json(); |
| if(d.ok) { |
| showToast('Installed: ' + d.file); |
| i.value = ''; |
| } else { |
| showToast('Error: ' + d.error); |
| } |
| } catch(e) { |
| showToast('Network error occurred.'); |
| } |
| b.innerText = 'Download & Install Plugin'; b.disabled = false; |
| }; |
| |
| const saveCfg = async () => { |
| const fd = new FormData(); |
| fd.append('provider', document.getElementById('cfgProv').value); |
| fd.append('version', document.getElementById('cfgVer').value); |
| fd.append('memory', document.getElementById('cfgMem').value); |
| await req('/config', {method:'POST', body:fd}); |
| showToast('Settings saved.'); |
| }; |
| |
| const repairSystem = async () => { |
| if(!confirm('This will wipe all cached MoJang libraries and force Purpur to re-download them. Ensure the server is stopped first. Continue?')) return; |
| try { |
| const r = await req('/system/repair', {method:'POST'}); |
| const d = await r.json(); |
| if(d.ok) showToast(d.msg); |
| else showToast('Error: ' + d.error); |
| } catch(e) { showToast('Repair failed.'); } |
| }; |
| |
| if(tk) init(); |
| </script> |
| </body> |
| </html>""" |
|
|
| if __name__ == "__main__": |
| port = int(os.environ.get("PORT", 7860)) |
| app.run(host="0.0.0.0", port=port, threaded=True) |