Spaces:
Running
Running
Commit ·
5e7cff3
1
Parent(s): 5c1450a
Optimize security, stability, FPS rate limiting, and add elegant quit
Browse files- runner.py +30 -2
- server.py +25 -8
- templates/player.html +16 -1
runner.py
CHANGED
|
@@ -38,6 +38,9 @@ shared_mouse_pos = (0, 0)
|
|
| 38 |
shared_mouse_buttons = (False, False, False)
|
| 39 |
shared_webcam_frame = None
|
| 40 |
|
|
|
|
|
|
|
|
|
|
| 41 |
# Key map: JavaScript event.key name -> Pygame key constant
|
| 42 |
import pygame # Import pygame now that SDL environment is set
|
| 43 |
KEY_MAP = {
|
|
@@ -100,6 +103,10 @@ def reader_thread_func(sock):
|
|
| 100 |
event_type = data.get('type') # 'keydown' or 'keyup'
|
| 101 |
key_name = data.get('key')
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
pg_key = KEY_MAP.get(key_name)
|
| 104 |
if pg_key is not None:
|
| 105 |
if event_type == 'keydown':
|
|
@@ -156,6 +163,13 @@ def reader_thread_func(sock):
|
|
| 156 |
# Stdin input from browser terminal
|
| 157 |
text = payload.decode('utf-8', errors='ignore')
|
| 158 |
stdin_queue.put(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
except Exception as e:
|
| 161 |
print(f"[Runner-TCP] Reader error: {e}", file=sys.__stderr__)
|
|
@@ -252,6 +266,14 @@ pygame.display.set_mode = custom_set_mode
|
|
| 252 |
|
| 253 |
# 2. Patch flip and update to capture surface and stream it
|
| 254 |
def send_pygame_frame():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
surf = pygame.display.get_surface()
|
| 256 |
if surf:
|
| 257 |
import io
|
|
@@ -358,13 +380,19 @@ def custom_Font(filename, size):
|
|
| 358 |
pygame.font.Font = custom_Font
|
| 359 |
|
| 360 |
|
| 361 |
-
|
| 362 |
-
|
| 363 |
# ----------------- Monkeypatching OpenCV (cv2) -----------------
|
| 364 |
import cv2
|
| 365 |
|
| 366 |
# 1. Patch imshow to capture frames
|
| 367 |
def custom_imshow(winname, mat):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
if mat is not None:
|
| 369 |
try:
|
| 370 |
ret, jpeg = cv2.imencode('.jpg', mat, [cv2.IMWRITE_JPEG_QUALITY, 70])
|
|
|
|
| 38 |
shared_mouse_buttons = (False, False, False)
|
| 39 |
shared_webcam_frame = None
|
| 40 |
|
| 41 |
+
last_pygame_frame_time = 0.0
|
| 42 |
+
last_cv2_frame_time = 0.0
|
| 43 |
+
|
| 44 |
# Key map: JavaScript event.key name -> Pygame key constant
|
| 45 |
import pygame # Import pygame now that SDL environment is set
|
| 46 |
KEY_MAP = {
|
|
|
|
| 103 |
event_type = data.get('type') # 'keydown' or 'keyup'
|
| 104 |
key_name = data.get('key')
|
| 105 |
|
| 106 |
+
# Make single character key codes case-insensitive (e.g. for Caps Lock/Shift)
|
| 107 |
+
if key_name and len(key_name) == 1:
|
| 108 |
+
key_name = key_name.lower()
|
| 109 |
+
|
| 110 |
pg_key = KEY_MAP.get(key_name)
|
| 111 |
if pg_key is not None:
|
| 112 |
if event_type == 'keydown':
|
|
|
|
| 163 |
# Stdin input from browser terminal
|
| 164 |
text = payload.decode('utf-8', errors='ignore')
|
| 165 |
stdin_queue.put(text)
|
| 166 |
+
|
| 167 |
+
elif msg_type == 'QUIT':
|
| 168 |
+
# Clean quit request for Pygame
|
| 169 |
+
try:
|
| 170 |
+
pygame.event.post(pygame.event.Event(pygame.QUIT))
|
| 171 |
+
except Exception:
|
| 172 |
+
pass
|
| 173 |
|
| 174 |
except Exception as e:
|
| 175 |
print(f"[Runner-TCP] Reader error: {e}", file=sys.__stderr__)
|
|
|
|
| 266 |
|
| 267 |
# 2. Patch flip and update to capture surface and stream it
|
| 268 |
def send_pygame_frame():
|
| 269 |
+
global last_pygame_frame_time
|
| 270 |
+
now = time.time()
|
| 271 |
+
if now - last_pygame_frame_time < 0.033: # limit to ~30 FPS
|
| 272 |
+
return
|
| 273 |
+
if send_queue.qsize() > 3: # drop frames if buffer is backed up
|
| 274 |
+
return
|
| 275 |
+
last_pygame_frame_time = now
|
| 276 |
+
|
| 277 |
surf = pygame.display.get_surface()
|
| 278 |
if surf:
|
| 279 |
import io
|
|
|
|
| 380 |
pygame.font.Font = custom_Font
|
| 381 |
|
| 382 |
|
|
|
|
|
|
|
| 383 |
# ----------------- Monkeypatching OpenCV (cv2) -----------------
|
| 384 |
import cv2
|
| 385 |
|
| 386 |
# 1. Patch imshow to capture frames
|
| 387 |
def custom_imshow(winname, mat):
|
| 388 |
+
global last_cv2_frame_time
|
| 389 |
+
now = time.time()
|
| 390 |
+
if now - last_cv2_frame_time < 0.033: # limit to ~30 FPS
|
| 391 |
+
return
|
| 392 |
+
if send_queue.qsize() > 3: # drop frames if buffer is backed up
|
| 393 |
+
return
|
| 394 |
+
last_cv2_frame_time = now
|
| 395 |
+
|
| 396 |
if mat is not None:
|
| 397 |
try:
|
| 398 |
ret, jpeg = cv2.imencode('.jpg', mat, [cv2.IMWRITE_JPEG_QUALITY, 70])
|
server.py
CHANGED
|
@@ -170,11 +170,17 @@ async def serve_static_project_file(student_token: str, project_name: str, path:
|
|
| 170 |
real_class, real_student = decode_class_student(student_token, project_name)
|
| 171 |
if real_class and real_student:
|
| 172 |
file_path = os.path.join(PROJECTS_DIR, real_class, real_student, actual_project_name, actual_path)
|
|
|
|
|
|
|
|
|
|
| 173 |
if os.path.exists(file_path) and not os.path.isdir(file_path):
|
| 174 |
return FileResponse(file_path)
|
| 175 |
return HTMLResponse("Not Found", status_code=404)
|
| 176 |
|
| 177 |
file_path = os.path.join(PROJECTS_DIR, real_class, real_student, project_name, path)
|
|
|
|
|
|
|
|
|
|
| 178 |
if not os.path.exists(file_path) or os.path.isdir(file_path):
|
| 179 |
return HTMLResponse("File Not Found", status_code=404)
|
| 180 |
|
|
@@ -370,8 +376,8 @@ async def play_project_ws_internal(websocket: WebSocket, real_class: str, real_s
|
|
| 370 |
|
| 371 |
# We run the subprocess and redirect stdout/stderr so we don't pollute the main server logs,
|
| 372 |
# and to capture anything missed by monkeypatching
|
| 373 |
-
process =
|
| 374 |
-
cmd,
|
| 375 |
stdout=subprocess.DEVNULL,
|
| 376 |
stderr=subprocess.DEVNULL
|
| 377 |
)
|
|
@@ -386,6 +392,7 @@ async def play_project_ws_internal(websocket: WebSocket, real_class: str, real_s
|
|
| 386 |
print(f"[Server] 錯誤: 執行器進程 {process.pid} 建立 TCP 連結逾時。")
|
| 387 |
await websocket.send_json({"type": "log", "data": "伺服器錯誤: 執行模擬器連線超時!\n"})
|
| 388 |
process.terminate()
|
|
|
|
| 389 |
tcp_server.close()
|
| 390 |
await tcp_server.wait_closed()
|
| 391 |
await websocket.close()
|
|
@@ -462,6 +469,11 @@ async def play_project_ws_internal(websocket: WebSocket, real_class: str, real_s
|
|
| 462 |
runner_writer.write(b'INPT' + len(payload).to_bytes(4, byteorder='big') + payload)
|
| 463 |
await runner_writer.drain()
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
elif "bytes" in data:
|
| 466 |
# Binary data represents the browser webcam JPEG frame
|
| 467 |
payload = data["bytes"]
|
|
@@ -477,12 +489,17 @@ async def play_project_ws_internal(websocket: WebSocket, real_class: str, real_s
|
|
| 477 |
await asyncio.gather(tcp_to_ws(), ws_to_tcp())
|
| 478 |
finally:
|
| 479 |
# --- Clean up resources ---
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
if runner_writer:
|
| 488 |
runner_writer.close()
|
|
|
|
| 170 |
real_class, real_student = decode_class_student(student_token, project_name)
|
| 171 |
if real_class and real_student:
|
| 172 |
file_path = os.path.join(PROJECTS_DIR, real_class, real_student, actual_project_name, actual_path)
|
| 173 |
+
# Prevent Path Traversal
|
| 174 |
+
if not os.path.abspath(file_path).startswith(os.path.abspath(PROJECTS_DIR)):
|
| 175 |
+
return HTMLResponse("Access Denied", status_code=403)
|
| 176 |
if os.path.exists(file_path) and not os.path.isdir(file_path):
|
| 177 |
return FileResponse(file_path)
|
| 178 |
return HTMLResponse("Not Found", status_code=404)
|
| 179 |
|
| 180 |
file_path = os.path.join(PROJECTS_DIR, real_class, real_student, project_name, path)
|
| 181 |
+
# Prevent Path Traversal
|
| 182 |
+
if not os.path.abspath(file_path).startswith(os.path.abspath(PROJECTS_DIR)):
|
| 183 |
+
return HTMLResponse("Access Denied", status_code=403)
|
| 184 |
if not os.path.exists(file_path) or os.path.isdir(file_path):
|
| 185 |
return HTMLResponse("File Not Found", status_code=404)
|
| 186 |
|
|
|
|
| 376 |
|
| 377 |
# We run the subprocess and redirect stdout/stderr so we don't pollute the main server logs,
|
| 378 |
# and to capture anything missed by monkeypatching
|
| 379 |
+
process = await asyncio.create_subprocess_exec(
|
| 380 |
+
*cmd,
|
| 381 |
stdout=subprocess.DEVNULL,
|
| 382 |
stderr=subprocess.DEVNULL
|
| 383 |
)
|
|
|
|
| 392 |
print(f"[Server] 錯誤: 執行器進程 {process.pid} 建立 TCP 連結逾時。")
|
| 393 |
await websocket.send_json({"type": "log", "data": "伺服器錯誤: 執行模擬器連線超時!\n"})
|
| 394 |
process.terminate()
|
| 395 |
+
await process.wait()
|
| 396 |
tcp_server.close()
|
| 397 |
await tcp_server.wait_closed()
|
| 398 |
await websocket.close()
|
|
|
|
| 469 |
runner_writer.write(b'INPT' + len(payload).to_bytes(4, byteorder='big') + payload)
|
| 470 |
await runner_writer.drain()
|
| 471 |
|
| 472 |
+
elif mtype == 'pygame_quit':
|
| 473 |
+
# Forward clean quit request to Pygame
|
| 474 |
+
runner_writer.write(b'QUIT' + (0).to_bytes(4, byteorder='big') + b'')
|
| 475 |
+
await runner_writer.drain()
|
| 476 |
+
|
| 477 |
elif "bytes" in data:
|
| 478 |
# Binary data represents the browser webcam JPEG frame
|
| 479 |
payload = data["bytes"]
|
|
|
|
| 489 |
await asyncio.gather(tcp_to_ws(), ws_to_tcp())
|
| 490 |
finally:
|
| 491 |
# --- Clean up resources ---
|
| 492 |
+
if 'process' in locals() and process:
|
| 493 |
+
print(f"[Server] 清理專案執行資源 (Process ID: {process.pid})")
|
| 494 |
+
try:
|
| 495 |
+
process.terminate()
|
| 496 |
+
try:
|
| 497 |
+
await asyncio.wait_for(process.wait(), timeout=1.0)
|
| 498 |
+
except asyncio.TimeoutError:
|
| 499 |
+
process.kill()
|
| 500 |
+
await process.wait()
|
| 501 |
+
except Exception as e:
|
| 502 |
+
print(f"[Server] 終止進程異常: {e}")
|
| 503 |
|
| 504 |
if runner_writer:
|
| 505 |
runner_writer.close()
|
templates/player.html
CHANGED
|
@@ -755,7 +755,7 @@
|
|
| 755 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
| 756 |
重設
|
| 757 |
</button>
|
| 758 |
-
<button class="btn-action btn-stop" onclick="
|
| 759 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
| 760 |
終止
|
| 761 |
</button>
|
|
@@ -1057,6 +1057,21 @@
|
|
| 1057 |
setTimeout(connectWebSocket, 1000);
|
| 1058 |
}
|
| 1059 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
function appendConsole(text) {
|
| 1061 |
if (consoleLogs) {
|
| 1062 |
consoleLogs.textContent += text;
|
|
|
|
| 755 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
| 756 |
重設
|
| 757 |
</button>
|
| 758 |
+
<button class="btn-action btn-stop" onclick="stopProjectElegant()">
|
| 759 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
| 760 |
終止
|
| 761 |
</button>
|
|
|
|
| 1057 |
setTimeout(connectWebSocket, 1000);
|
| 1058 |
}
|
| 1059 |
|
| 1060 |
+
function stopProjectElegant() {
|
| 1061 |
+
appendConsole("\n[系統] 正在發送退出訊號並結束專案...\n");
|
| 1062 |
+
if (projectType === "python" && socket && socket.readyState === WebSocket.OPEN) {
|
| 1063 |
+
socket.send(JSON.stringify({
|
| 1064 |
+
type: "pygame_quit"
|
| 1065 |
+
}));
|
| 1066 |
+
// Wait 1.5s before closing to let Pygame script do graceful cleanup
|
| 1067 |
+
setTimeout(() => {
|
| 1068 |
+
window.close();
|
| 1069 |
+
}, 1500);
|
| 1070 |
+
} else {
|
| 1071 |
+
window.close();
|
| 1072 |
+
}
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
function appendConsole(text) {
|
| 1076 |
if (consoleLogs) {
|
| 1077 |
consoleLogs.textContent += text;
|