steveyehtw commited on
Commit
eb58522
·
1 Parent(s): 7730264

Encrypt class and student IDs together in URLs

Browse files
Files changed (3) hide show
  1. server.py +97 -20
  2. templates/index.html +2 -3
  3. templates/player.html +3 -2
server.py CHANGED
@@ -4,7 +4,7 @@ import json
4
  import asyncio
5
  import subprocess
6
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
7
- from fastapi.responses import HTMLResponse, JSONResponse
8
  from fastapi.staticfiles import StaticFiles
9
  from fastapi.templating import Jinja2Templates
10
 
@@ -20,9 +20,6 @@ TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
20
  os.makedirs(TEMPLATES_DIR, exist_ok=True)
21
  templates = Jinja2Templates(directory=TEMPLATES_DIR)
22
 
23
- # Static projects mount
24
- app.mount("/static_projects", StaticFiles(directory=PROJECTS_DIR), name="static_projects")
25
-
26
  # ----------------- Scanning Projects -----------------
27
  def scan_projects():
28
  """
@@ -94,10 +91,39 @@ def get_id(name: str) -> str:
94
  def decode_class_student(class_name_or_id: str, student_name_or_id: str = None):
95
  """
96
  Decodes ID back to real folder names for class and student.
97
- Supports transparent fallback if real names are passed.
 
 
98
  """
99
  all_projects = scan_projects()
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  # 1. Decode Class
102
  real_class = None
103
  for c in all_projects:
@@ -108,10 +134,7 @@ def decode_class_student(class_name_or_id: str, student_name_or_id: str = None):
108
  if class_name_or_id in all_projects:
109
  real_class = class_name_or_id
110
 
111
- if not student_name_or_id:
112
- return real_class, None
113
-
114
- # 2. Decode Student (Student ID is hashed from class_name + "/" + student_name)
115
  real_student = None
116
  if real_class and real_class in all_projects:
117
  for s in all_projects[real_class]:
@@ -124,6 +147,33 @@ def decode_class_student(class_name_or_id: str, student_name_or_id: str = None):
124
 
125
  return real_class, real_student
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  # ----------------- HTTP Routes -----------------
128
  @app.get("/", response_class=HTMLResponse)
129
  async def read_index(request: Request):
@@ -146,10 +196,10 @@ async def read_class(request: Request, class_name: str):
146
  "restrict_student": ""
147
  })
148
 
149
- @app.get("/student/{class_name}/{student_name}", response_class=HTMLResponse)
150
- async def read_student(request: Request, class_name: str, student_name: str):
151
- """Renders the catalog restricted to a specific student (supports IDs or Names)."""
152
- real_class, real_student = decode_class_student(class_name, student_name)
153
  if not real_class or not real_student:
154
  return HTMLResponse("Student not found", status_code=404)
155
  return templates.TemplateResponse("index.html", {
@@ -158,6 +208,15 @@ async def read_student(request: Request, class_name: str, student_name: str):
158
  "restrict_student": real_student
159
  })
160
 
 
 
 
 
 
 
 
 
 
161
  @app.get("/api/projects", response_class=JSONResponse)
162
  async def get_projects(class_name: str = None, student_name: str = None):
163
  """
@@ -191,10 +250,10 @@ async def get_projects(class_name: str = None, student_name: str = None):
191
  "student_ids": student_ids
192
  }
193
 
194
- @app.get("/run/{class_name}/{student_name}/{project_name}", response_class=HTMLResponse)
195
- async def run_project_page(request: Request, class_name: str, student_name: str, project_name: str):
196
- """Renders the player viewport wrapper (supports IDs or Names for class/student)."""
197
- real_class, real_student = decode_class_student(class_name, student_name)
198
  if not real_class or not real_student:
199
  return HTMLResponse("Project not found", status_code=404)
200
 
@@ -218,15 +277,23 @@ async def run_project_page(request: Request, class_name: str, student_name: str,
218
  "request": request,
219
  "class_name": real_class,
220
  "student_name": real_student,
 
221
  "project_name": project_name,
222
  "project_type": proj_type,
223
  "external_url": external_url
224
  })
225
 
226
- # ----------------- WebSocket Communication Proxy -----------------
227
- @app.websocket("/ws/play/{class_name}/{student_name}/{project_name}")
228
- async def play_project_ws(websocket: WebSocket, class_name: str, student_name: str, project_name: str):
229
  real_class, real_student = decode_class_student(class_name, student_name)
 
 
 
 
 
 
 
230
  if not real_class or not real_student:
231
  await websocket.accept()
232
  await websocket.send_json({"type": "log", "data": "錯誤: 找不到該專案的執行路徑!\n"})
@@ -390,6 +457,16 @@ async def play_project_ws(websocket: WebSocket, class_name: str, student_name: s
390
  except Exception:
391
  pass
392
 
 
 
 
 
 
 
 
 
 
 
393
  # Main entry point to run server via python command directly if wanted
394
  if __name__ == "__main__":
395
  import uvicorn
 
4
  import asyncio
5
  import subprocess
6
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
7
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
8
  from fastapi.staticfiles import StaticFiles
9
  from fastapi.templating import Jinja2Templates
10
 
 
20
  os.makedirs(TEMPLATES_DIR, exist_ok=True)
21
  templates = Jinja2Templates(directory=TEMPLATES_DIR)
22
 
 
 
 
23
  # ----------------- Scanning Projects -----------------
24
  def scan_projects():
25
  """
 
91
  def decode_class_student(class_name_or_id: str, student_name_or_id: str = None):
92
  """
93
  Decodes ID back to real folder names for class and student.
94
+ Supports:
95
+ 1. Single token representing student_id (get_id(class/student)) or "class/student"
96
+ 2. Two arguments (class_name_or_id, student_name_or_id)
97
  """
98
  all_projects = scan_projects()
99
 
100
+ # Case A: We only have one argument (class_name_or_id)
101
+ if not student_name_or_id:
102
+ # Check if it matches a hashed student ID (get_id(class + "/" + student))
103
+ for c in all_projects:
104
+ for s in all_projects[c]:
105
+ if get_id(f"{c}/{s}") == class_name_or_id:
106
+ return c, s
107
+
108
+ # Check if it is a raw path "class_name/student_name"
109
+ if "/" in class_name_or_id:
110
+ parts = class_name_or_id.split("/", 1)
111
+ c, s = parts[0], parts[1]
112
+ if c in all_projects and s in all_projects[c]:
113
+ return c, s
114
+
115
+ # Otherwise, treat class_name_or_id as a class-only identifier
116
+ real_class = None
117
+ for c in all_projects:
118
+ if get_id(c) == class_name_or_id:
119
+ real_class = c
120
+ break
121
+ if not real_class:
122
+ if class_name_or_id in all_projects:
123
+ real_class = class_name_or_id
124
+ return real_class, None
125
+
126
+ # Case B: We have two arguments
127
  # 1. Decode Class
128
  real_class = None
129
  for c in all_projects:
 
134
  if class_name_or_id in all_projects:
135
  real_class = class_name_or_id
136
 
137
+ # 2. Decode Student
 
 
 
138
  real_student = None
139
  if real_class and real_class in all_projects:
140
  for s in all_projects[real_class]:
 
147
 
148
  return real_class, real_student
149
 
150
+ # ----------------- Custom Static Files Routing wrapper -----------------
151
+ @app.get("/static_projects/{student_token}/{project_name}/{path:path}")
152
+ async def serve_static_project_file(student_token: str, project_name: str, path: str):
153
+ real_class, real_student = decode_class_student(student_token)
154
+
155
+ # If not a valid student, check if it's actually the old format: class_name/student_name/project_name/path
156
+ if not real_class or not real_student:
157
+ parts = path.split("/", 1)
158
+ if len(parts) == 2:
159
+ actual_project_name = parts[0]
160
+ actual_path = parts[1]
161
+ real_class, real_student = decode_class_student(student_token, project_name)
162
+ if real_class and real_student:
163
+ file_path = os.path.join(PROJECTS_DIR, real_class, real_student, actual_project_name, actual_path)
164
+ if os.path.exists(file_path) and not os.path.isdir(file_path):
165
+ return FileResponse(file_path)
166
+ return HTMLResponse("Not Found", status_code=404)
167
+
168
+ file_path = os.path.join(PROJECTS_DIR, real_class, real_student, project_name, path)
169
+ if not os.path.exists(file_path) or os.path.isdir(file_path):
170
+ return HTMLResponse("File Not Found", status_code=404)
171
+
172
+ return FileResponse(file_path)
173
+
174
+ # Fallback direct mounting for direct access / other assets
175
+ app.mount("/static_projects", StaticFiles(directory=PROJECTS_DIR), name="static_projects")
176
+
177
  # ----------------- HTTP Routes -----------------
178
  @app.get("/", response_class=HTMLResponse)
179
  async def read_index(request: Request):
 
196
  "restrict_student": ""
197
  })
198
 
199
+ @app.get("/student/{student_token}", response_class=HTMLResponse)
200
+ async def read_student(request: Request, student_token: str):
201
+ """Renders the catalog restricted to a specific student (supports combined ID or "class_name/student_name")."""
202
+ real_class, real_student = decode_class_student(student_token)
203
  if not real_class or not real_student:
204
  return HTMLResponse("Student not found", status_code=404)
205
  return templates.TemplateResponse("index.html", {
 
208
  "restrict_student": real_student
209
  })
210
 
211
+ @app.get("/student/{class_name}/{student_name}", response_class=HTMLResponse)
212
+ async def read_student_old(request: Request, class_name: str, student_name: str):
213
+ """Redirects old student URLs to the new single-token URL."""
214
+ real_class, real_student = decode_class_student(class_name, student_name)
215
+ if not real_class or not real_student:
216
+ return HTMLResponse("Student not found", status_code=404)
217
+ student_token = get_id(f"{real_class}/{real_student}")
218
+ return RedirectResponse(url=f"/student/{student_token}", status_code=307)
219
+
220
  @app.get("/api/projects", response_class=JSONResponse)
221
  async def get_projects(class_name: str = None, student_name: str = None):
222
  """
 
250
  "student_ids": student_ids
251
  }
252
 
253
+ @app.get("/run/{student_token}/{project_name}", response_class=HTMLResponse)
254
+ async def run_project_page(request: Request, student_token: str, project_name: str):
255
+ """Renders the player viewport wrapper (supports combined ID or "class_name/student_name")."""
256
+ real_class, real_student = decode_class_student(student_token)
257
  if not real_class or not real_student:
258
  return HTMLResponse("Project not found", status_code=404)
259
 
 
277
  "request": request,
278
  "class_name": real_class,
279
  "student_name": real_student,
280
+ "student_token": student_token,
281
  "project_name": project_name,
282
  "project_type": proj_type,
283
  "external_url": external_url
284
  })
285
 
286
+ @app.get("/run/{class_name}/{student_name}/{project_name}", response_class=HTMLResponse)
287
+ async def run_project_page_old(request: Request, class_name: str, student_name: str, project_name: str):
288
+ """Redirects old run URLs to the new single-token URL."""
289
  real_class, real_student = decode_class_student(class_name, student_name)
290
+ if not real_class or not real_student:
291
+ return HTMLResponse("Project not found", status_code=404)
292
+ student_token = get_id(f"{real_class}/{real_student}")
293
+ return RedirectResponse(url=f"/run/{student_token}/{project_name}", status_code=307)
294
+
295
+ # ----------------- WebSocket Communication Proxy -----------------
296
+ async def play_project_ws_internal(websocket: WebSocket, real_class: str, real_student: str, project_name: str):
297
  if not real_class or not real_student:
298
  await websocket.accept()
299
  await websocket.send_json({"type": "log", "data": "錯誤: 找不到該專案的執行路徑!\n"})
 
457
  except Exception:
458
  pass
459
 
460
+ @app.websocket("/ws/play/{student_token}/{project_name}")
461
+ async def play_project_ws(websocket: WebSocket, student_token: str, project_name: str):
462
+ real_class, real_student = decode_class_student(student_token)
463
+ await play_project_ws_internal(websocket, real_class, real_student, project_name)
464
+
465
+ @app.websocket("/ws/play/{class_name}/{student_name}/{project_name}")
466
+ async def play_project_ws_old(websocket: WebSocket, class_name: str, student_name: str, project_name: str):
467
+ real_class, real_student = decode_class_student(class_name, student_name)
468
+ await play_project_ws_internal(websocket, real_class, real_student, project_name)
469
+
470
  # Main entry point to run server via python command directly if wanted
471
  if __name__ == "__main__":
472
  import uvicorn
templates/index.html CHANGED
@@ -819,7 +819,7 @@
819
  if (selectedStudent) {
820
  const studentKey = `${selectedClass}/${selectedStudent}`;
821
  const studentId = studentIds[studentKey];
822
- urlResult.value = `${baseUrl}/student/${classId}/${studentId}`;
823
  } else {
824
  urlResult.value = `${baseUrl}/class/${classId}`;
825
  }
@@ -986,11 +986,10 @@
986
  }
987
 
988
  function launchProject(projectName) {
989
- const classId = classIds[activeClass] || activeClass;
990
  const studentKey = `${activeClass}/${activeStudent}`;
991
  const studentId = studentIds[studentKey] || activeStudent;
992
 
993
- const url = `/run/${encodeURIComponent(classId)}/${encodeURIComponent(studentId)}/${encodeURIComponent(projectName)}`;
994
  window.open(url, '_blank');
995
  }
996
 
 
819
  if (selectedStudent) {
820
  const studentKey = `${selectedClass}/${selectedStudent}`;
821
  const studentId = studentIds[studentKey];
822
+ urlResult.value = `${baseUrl}/student/${studentId}`;
823
  } else {
824
  urlResult.value = `${baseUrl}/class/${classId}`;
825
  }
 
986
  }
987
 
988
  function launchProject(projectName) {
 
989
  const studentKey = `${activeClass}/${activeStudent}`;
990
  const studentId = studentIds[studentKey] || activeStudent;
991
 
992
+ const url = `/run/${encodeURIComponent(studentId)}/${encodeURIComponent(projectName)}`;
993
  window.open(url, '_blank');
994
  }
995
 
templates/player.html CHANGED
@@ -543,6 +543,7 @@
543
  <script>
544
  const className = "{{ class_name }}";
545
  const studentName = "{{ student_name }}";
 
546
  const projectName = "{{ project_name }}";
547
  const projectType = "{{ project_type }}";
548
  const externalUrl = "{{ external_url }}";
@@ -605,7 +606,7 @@
605
  iframe.allow = "autoplay; fullscreen; microphone; camera";
606
 
607
  if (projectType === "html") {
608
- iframe.src = `/static_projects/${encodeURIComponent(className)}/${encodeURIComponent(studentName)}/${encodeURIComponent(projectName)}/index.html`;
609
  } else if (projectType === "external") {
610
  iframe.src = formatEmbedUrl(externalUrl);
611
  }
@@ -618,7 +619,7 @@
618
  function connectWebSocket() {
619
  const loc = window.location;
620
  const wsProto = loc.protocol === "https:" ? "wss:" : "ws:";
621
- const wsUrl = `${wsProto}//${loc.host}/ws/play/${encodeURIComponent(className)}/${encodeURIComponent(studentName)}/${encodeURIComponent(projectName)}`;
622
 
623
  socket = new WebSocket(wsUrl);
624
  socket.binaryType = "blob";
 
543
  <script>
544
  const className = "{{ class_name }}";
545
  const studentName = "{{ student_name }}";
546
+ const studentToken = "{{ student_token }}";
547
  const projectName = "{{ project_name }}";
548
  const projectType = "{{ project_type }}";
549
  const externalUrl = "{{ external_url }}";
 
606
  iframe.allow = "autoplay; fullscreen; microphone; camera";
607
 
608
  if (projectType === "html") {
609
+ iframe.src = `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/index.html`;
610
  } else if (projectType === "external") {
611
  iframe.src = formatEmbedUrl(externalUrl);
612
  }
 
619
  function connectWebSocket() {
620
  const loc = window.location;
621
  const wsProto = loc.protocol === "https:" ? "wss:" : "ws:";
622
+ const wsUrl = `${wsProto}//${loc.host}/ws/play/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}`;
623
 
624
  socket = new WebSocket(wsUrl);
625
  socket.binaryType = "blob";