steveyehtw commited on
Commit
5e7cff3
·
1 Parent(s): 5c1450a

Optimize security, stability, FPS rate limiting, and add elegant quit

Browse files
Files changed (3) hide show
  1. runner.py +30 -2
  2. server.py +25 -8
  3. 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 = subprocess.Popen(
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
- print(f"[Server] 清理專案執行資源 (Process ID: {process.pid})")
481
- process.terminate()
482
- try:
483
- process.wait(timeout=1.0)
484
- except subprocess.TimeoutExpired:
485
- process.kill()
 
 
 
 
 
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="window.close()">
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;