import os
import asyncio
import collections
import shutil
from fastapi import FastAPI, WebSocket, Form, UploadFile, File, HTTPException
from fastapi.responses import HTMLResponse, Response, FileResponse
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
mc_process = None
output_history = collections.deque(maxlen=300)
connected_clients = set()
BASE_DIR = os.path.abspath("/app")
HTML_CONTENT = """
Server Engine
Plugin Manager
system.status = "COMING_SOON"
"""
# -----------------
# UTILITIES
# -----------------
def get_safe_path(subpath: str):
subpath = (subpath or "").strip("/")
target = os.path.abspath(os.path.join(BASE_DIR, subpath))
if not target.startswith(BASE_DIR):
raise HTTPException(status_code=403, detail="Access denied outside /app")
return target
async def broadcast(message: str):
output_history.append(message)
dead_clients = set()
for client in connected_clients:
try:
await client.send_text(message)
except:
dead_clients.add(client)
connected_clients.difference_update(dead_clients)
# -----------------
# SERVER PROCESSES
# -----------------
async def read_stream(stream, prefix=""):
while True:
try:
line = await stream.readline()
if not line: break
line_str = line.decode('utf-8', errors='replace').rstrip('\r\n')
await broadcast(prefix + line_str)
except Exception:
break
async def start_minecraft():
global mc_process
java_args = [
"java", "-server", "-Xmx8G", "-Xms8G", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled",
"-XX:ParallelGCThreads=2", "-XX:ConcGCThreads=1", "-XX:MaxGCPauseMillis=50",
"-XX:+UnlockExperimentalVMOptions", "-XX:+DisableExplicitGC", "-XX:+AlwaysPreTouch",
"-XX:G1NewSizePercent=30", "-XX:G1MaxNewSizePercent=50", "-XX:G1HeapRegionSize=16M",
"-XX:G1ReservePercent=15", "-XX:G1HeapWastePercent=5", "-XX:G1MixedGCCountTarget=3",
"-XX:InitiatingHeapOccupancyPercent=10", "-XX:G1MixedGCLiveThresholdPercent=90",
"-XX:G1RSetUpdatingPauseTimePercent=5", "-XX:SurvivorRatio=32", "-XX:+PerfDisableSharedMem",
"-XX:MaxTenuringThreshold=1", "-XX:G1SATBBufferEnqueueingThresholdPercent=30",
"-XX:G1ConcMarkStepDurationMillis=5", "-XX:G1ConcRSHotCardLimit=16",
"-XX:+UseStringDeduplication", "-Dfile.encoding=UTF-8", "-Dspring.output.ansi.enabled=ALWAYS",
"-jar", "purpur.jar", "--nogui"
]
mc_process = await asyncio.create_subprocess_exec(
*java_args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=BASE_DIR
)
asyncio.create_task(read_stream(mc_process.stdout))
@app.on_event("startup")
async def startup_event():
os.makedirs(BASE_DIR, exist_ok=True)
asyncio.create_task(start_minecraft())
# -----------------
# API ROUTING
# -----------------
@app.get("/")
def get_panel():
return HTMLResponse(content=HTML_CONTENT)
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.add(websocket)
for line in output_history:
await websocket.send_text(line)
try:
while True:
cmd = await websocket.receive_text()
if mc_process and mc_process.stdin:
mc_process.stdin.write((cmd + "\n").encode('utf-8'))
await mc_process.stdin.drain()
except:
connected_clients.remove(websocket)
@app.get("/api/fs/list")
def fs_list(path: str = ""):
target = get_safe_path(path)
if not os.path.exists(target): return []
items = []
for f in os.listdir(target):
fp = os.path.join(target, f)
items.append({"name": f, "is_dir": os.path.isdir(fp), "size": os.path.getsize(fp) if not os.path.isdir(fp) else 0})
return sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower()))
@app.get("/api/fs/read")
def fs_read(path: str):
target = get_safe_path(path)
if not os.path.isfile(target): raise HTTPException(400, "Not a file")
try:
with open(target, 'r', encoding='utf-8') as f:
return Response(content=f.read(), media_type="text/plain")
except UnicodeDecodeError:
raise HTTPException(400, "File is binary or unsupported encoding")
@app.get("/api/fs/download")
def fs_download(path: str):
target = get_safe_path(path)
if not os.path.isfile(target): raise HTTPException(400, "Not a file")
return FileResponse(target, filename=os.path.basename(target))
@app.post("/api/fs/write")
def fs_write(path: str = Form(...), content: str = Form(...)):
target = get_safe_path(path)
with open(target, 'w', encoding='utf-8') as f:
f.write(content)
return {"status": "ok"}
@app.post("/api/fs/upload")
async def fs_upload(path: str = Form(""), file: UploadFile = File(...)):
target_dir = get_safe_path(path)
os.makedirs(target_dir, exist_ok=True)
target_file = os.path.join(target_dir, file.filename)
with open(target_file, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {"status": "ok"}
@app.post("/api/fs/delete")
def fs_delete(path: str = Form(...)):
target = get_safe_path(path)
if os.path.isdir(target): shutil.rmtree(target)
else: os.remove(target)
return {"status": "ok"}
@app.post("/api/fs/create_dir")
def fs_create_dir(path: str = Form(...)):
target = get_safe_path(path)
try:
os.makedirs(target, exist_ok=True)
return {"status": "ok"}
except Exception as e:
raise HTTPException(400, str(e))
@app.post("/api/fs/create_file")
def fs_create_file(path: str = Form(...)):
target = get_safe_path(path)
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
open(target, 'a').close()
return {"status": "ok"}
except Exception as e:
raise HTTPException(400, str(e))
@app.post("/api/fs/rename")
def fs_rename(old_path: str = Form(...), new_name: str = Form(...)):
src = get_safe_path(old_path)
base_dir = os.path.dirname(src)
dst = os.path.join(base_dir, new_name)
try:
os.rename(src, dst)
return {"status": "ok"}
except Exception as e:
raise HTTPException(400, str(e))
@app.post("/api/fs/move")
def fs_move(source: str = Form(...), dest: str = Form(...)):
src = get_safe_path(source)
dst = get_safe_path(dest)
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(src, dst)
return {"status": "ok"}
except Exception as e:
raise HTTPException(400, str(e))
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="warning")