2026-03-07 05:43:51 +00:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2026-03-10 07:41:38 +08:00
|
|
|
|
Linux 学习平台 Server(知识导向版)
|
|
|
|
|
|
- 提供课程结构、练习判题、沙盒执行
|
|
|
|
|
|
- 课程模型:module -> lesson -> exercise
|
2026-03-07 05:43:51 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import hashlib
|
2026-03-07 05:43:51 +00:00
|
|
|
|
import http.server
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
2026-03-10 07:30:42 +08:00
|
|
|
|
import urllib.parse
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from sandbox import LinuxSandbox
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
|
|
|
|
|
USERS = {
|
|
|
|
|
|
"admin": hashlib.sha256(b"safe_linux_2026").hexdigest(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
|
|
|
|
|
|
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html")
|
2026-03-10 21:45:11 +08:00
|
|
|
|
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
|
2026-03-07 05:43:51 +00:00
|
|
|
|
SANDBOX = LinuxSandbox()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
def load_course() -> dict[str, Any]:
|
2026-03-07 05:43:51 +00:00
|
|
|
|
try:
|
|
|
|
|
|
with open(TASKS_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
|
return json.load(f)
|
|
|
|
|
|
except Exception as e:
|
2026-03-10 07:41:38 +08:00
|
|
|
|
print(f"Error loading course: {e}")
|
|
|
|
|
|
return {"meta": {}, "modules": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
COURSE = load_course()
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
def flatten_exercises() -> list[dict[str, Any]]:
|
|
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
|
|
for module in COURSE.get("modules", []):
|
|
|
|
|
|
for lesson in module.get("lessons", []):
|
|
|
|
|
|
for exercise in lesson.get("exercises", []):
|
|
|
|
|
|
item = dict(exercise)
|
|
|
|
|
|
item["module_id"] = module.get("id")
|
|
|
|
|
|
item["module_title"] = module.get("title")
|
|
|
|
|
|
item["lesson_id"] = lesson.get("id")
|
|
|
|
|
|
item["lesson_title"] = lesson.get("title")
|
|
|
|
|
|
item["lesson_goal"] = lesson.get("goal")
|
|
|
|
|
|
item["lesson_command"] = lesson.get("command")
|
|
|
|
|
|
rows.append(item)
|
|
|
|
|
|
return rows
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
def find_exercise(ex_id: str) -> dict[str, Any] | None:
|
|
|
|
|
|
for item in flatten_exercises():
|
|
|
|
|
|
if item.get("id") == ex_id:
|
|
|
|
|
|
return item
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
2026-03-10 07:30:42 +08:00
|
|
|
|
def log_message(self, format, *args):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
def send_json(self, data: Any, status=200):
|
2026-03-10 07:30:42 +08:00
|
|
|
|
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
2026-03-07 05:43:51 +00:00
|
|
|
|
self.send_response(status)
|
|
|
|
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
2026-03-10 07:30:42 +08:00
|
|
|
|
self.send_header("Content-Length", str(len(raw)))
|
2026-03-07 05:43:51 +00:00
|
|
|
|
self.end_headers()
|
2026-03-10 07:30:42 +08:00
|
|
|
|
self.wfile.write(raw)
|
|
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
def send_file(self, path: str, content_type: str):
|
2026-03-10 07:30:42 +08:00
|
|
|
|
with open(path, "rb") as f:
|
2026-03-10 07:41:38 +08:00
|
|
|
|
body = f.read()
|
2026-03-10 07:30:42 +08:00
|
|
|
|
self.send_response(200)
|
|
|
|
|
|
self.send_header("Content-Type", content_type)
|
2026-03-10 07:41:38 +08:00
|
|
|
|
self.send_header("Content-Length", str(len(body)))
|
2026-03-07 05:43:51 +00:00
|
|
|
|
self.end_headers()
|
2026-03-10 07:41:38 +08:00
|
|
|
|
self.wfile.write(body)
|
2026-03-10 07:30:42 +08:00
|
|
|
|
|
|
|
|
|
|
def check_auth(self, auth_header: str, token: str) -> bool:
|
|
|
|
|
|
if self.client_address[0] == "127.0.0.1":
|
|
|
|
|
|
return True
|
|
|
|
|
|
if token == "safe_linux_2026":
|
|
|
|
|
|
return True
|
|
|
|
|
|
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
|
|
|
|
|
def do_GET(self):
|
|
|
|
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
|
|
|
|
path = parsed.path
|
|
|
|
|
|
|
2026-03-10 21:45:11 +08:00
|
|
|
|
if path not in ["/", "/privacy", "/privacy.html", "/api/login", "/api/logout", "/api/course", "/api/health"]:
|
2026-03-07 05:43:51 +00:00
|
|
|
|
auth_header = self.headers.get("Authorization", "")
|
|
|
|
|
|
token = self.headers.get("X-Token", "")
|
2026-03-10 07:30:42 +08:00
|
|
|
|
if not self.check_auth(auth_header, token) and "xiaoxiaoluohao.indevs.in" in self.headers.get("Host", ""):
|
|
|
|
|
|
self.send_json({"error": "Authentication required"}, 401)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-07 05:43:51 +00:00
|
|
|
|
if path == "/":
|
2026-03-10 07:41:38 +08:00
|
|
|
|
self.send_file(HTML_FILE, "text/html; charset=utf-8")
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-10 21:45:11 +08:00
|
|
|
|
if path in {"/privacy", "/privacy.html"}:
|
|
|
|
|
|
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
if path == "/api/health":
|
2026-03-10 07:41:38 +08:00
|
|
|
|
self.send_json({"ok": True, "cwd": SANDBOX.cwd, "user": SANDBOX.user, "modules": len(COURSE.get("modules", []))})
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if path == "/api/course":
|
|
|
|
|
|
self.send_json(COURSE)
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if path == "/api/run":
|
2026-03-07 05:43:51 +00:00
|
|
|
|
query = urllib.parse.parse_qs(parsed.query)
|
|
|
|
|
|
cmd = query.get("cmd", [""])[0]
|
|
|
|
|
|
if not cmd:
|
|
|
|
|
|
self.send_json({"error": "No command provided"}, 400)
|
|
|
|
|
|
return
|
2026-03-10 07:41:38 +08:00
|
|
|
|
self.send_json(SANDBOX.execute(cmd))
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
if path == "/api/check":
|
2026-03-07 05:43:51 +00:00
|
|
|
|
query = urllib.parse.parse_qs(parsed.query)
|
2026-03-10 07:41:38 +08:00
|
|
|
|
ex_id = query.get("exercise_id", [""])[0]
|
2026-03-10 07:30:42 +08:00
|
|
|
|
cmd = query.get("last_cmd", [""])[0]
|
|
|
|
|
|
output = query.get("output", [""])[0]
|
2026-03-10 07:41:38 +08:00
|
|
|
|
if not ex_id:
|
|
|
|
|
|
self.send_json({"error": "exercise_id required"}, 400)
|
|
|
|
|
|
return
|
|
|
|
|
|
exercise = find_exercise(ex_id)
|
|
|
|
|
|
if not exercise:
|
|
|
|
|
|
self.send_json({"error": "Exercise not found"}, 404)
|
|
|
|
|
|
return
|
|
|
|
|
|
state = {
|
|
|
|
|
|
"cmd": cmd,
|
|
|
|
|
|
"output": output,
|
2026-03-10 07:30:42 +08:00
|
|
|
|
"cwd": SANDBOX.cwd,
|
|
|
|
|
|
"exists": SANDBOX.exists,
|
|
|
|
|
|
"is_executable": SANDBOX.is_executable,
|
|
|
|
|
|
}
|
2026-03-10 07:41:38 +08:00
|
|
|
|
success, reason = self.evaluate_exercise(exercise, state)
|
2026-03-07 05:43:51 +00:00
|
|
|
|
self.send_json({
|
2026-03-10 07:41:38 +08:00
|
|
|
|
"exercise_id": ex_id,
|
2026-03-07 05:43:51 +00:00
|
|
|
|
"success": success,
|
2026-03-10 07:41:38 +08:00
|
|
|
|
"message": exercise.get("success_msg", "✅ 练习通过") if success else reason,
|
|
|
|
|
|
"hint": exercise.get("hint"),
|
|
|
|
|
|
"lesson_title": exercise.get("lesson_title"),
|
|
|
|
|
|
"module_title": exercise.get("module_title"),
|
|
|
|
|
|
"next_suggestion": self.build_next_suggestion(ex_id),
|
2026-03-07 05:43:51 +00:00
|
|
|
|
})
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
self.send_response(404)
|
|
|
|
|
|
self.end_headers()
|
|
|
|
|
|
self.wfile.write(b"Not Found")
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
|
|
|
|
|
def do_POST(self):
|
|
|
|
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
|
|
|
|
path = parsed.path
|
2026-03-10 07:30:42 +08:00
|
|
|
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
|
|
|
|
raw = self.rfile.read(content_length).decode() if content_length else "{}"
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
|
|
|
|
|
if path == "/api/login":
|
|
|
|
|
|
try:
|
2026-03-10 07:30:42 +08:00
|
|
|
|
data = json.loads(raw)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self.send_json({"success": False, "error": "Invalid JSON"}, 400)
|
|
|
|
|
|
return
|
|
|
|
|
|
username = data.get("username", "")
|
|
|
|
|
|
password = data.get("password", "")
|
|
|
|
|
|
if username in USERS and hashlib.sha256(password.encode()).hexdigest() == USERS[username]:
|
|
|
|
|
|
self.send_json({"success": True, "token": "safe_linux_2026", "message": "✅ 登录成功!"})
|
|
|
|
|
|
return
|
|
|
|
|
|
self.send_json({"success": False, "error": "❌ 用户名或密码错误"}, 401)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if path == "/api/logout":
|
2026-03-07 05:43:51 +00:00
|
|
|
|
self.send_json({"success": True, "message": "👋 已退出登录"})
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if path == "/api/reset":
|
|
|
|
|
|
SANDBOX.reset()
|
|
|
|
|
|
self.send_json({"success": True, "message": "♻️ 沙盒环境已重置"})
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self.send_response(404)
|
|
|
|
|
|
self.end_headers()
|
|
|
|
|
|
self.wfile.write(b"Not Found")
|
|
|
|
|
|
|
2026-03-10 07:41:38 +08:00
|
|
|
|
def evaluate_exercise(self, exercise: dict[str, Any], state: dict[str, Any]) -> tuple[bool, str]:
|
|
|
|
|
|
ex_type = exercise.get("type")
|
|
|
|
|
|
if ex_type in {"understanding", "scenario"}:
|
|
|
|
|
|
return False, "📝 这是理解类练习,请先阅读讲解并思考答案。"
|
|
|
|
|
|
|
|
|
|
|
|
cmd = state["cmd"].strip()
|
|
|
|
|
|
if exercise.get("solution"):
|
|
|
|
|
|
for sol in exercise["solution"]:
|
|
|
|
|
|
if cmd == sol.strip():
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return True, ""
|
2026-03-10 07:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
success_test = exercise.get("success_test")
|
2026-03-10 07:30:42 +08:00
|
|
|
|
if not success_test:
|
2026-03-10 07:41:38 +08:00
|
|
|
|
return False, "❌ 暂未命中练习要求"
|
|
|
|
|
|
|
2026-03-10 07:30:42 +08:00
|
|
|
|
try:
|
|
|
|
|
|
ok = bool(eval(success_test, {"__builtins__": {}}, state))
|
2026-03-10 07:41:38 +08:00
|
|
|
|
return ok, "❌ 结果还没达到练习要求,再试一次"
|
2026-03-10 07:30:42 +08:00
|
|
|
|
except Exception:
|
2026-03-10 07:41:38 +08:00
|
|
|
|
return False, "❌ 当前命令没有通过判定,建议对照示例重新尝试"
|
|
|
|
|
|
|
|
|
|
|
|
def build_next_suggestion(self, current_ex_id: str) -> str | None:
|
|
|
|
|
|
rows = flatten_exercises()
|
|
|
|
|
|
for i, item in enumerate(rows):
|
|
|
|
|
|
if item.get("id") == current_ex_id and i + 1 < len(rows):
|
|
|
|
|
|
nxt = rows[i + 1]
|
|
|
|
|
|
return f"继续下一练:{nxt.get('title') or nxt.get('question') or nxt.get('id')}"
|
2026-03-10 07:30:42 +08:00
|
|
|
|
return None
|
2026-03-07 05:43:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-03-10 07:41:38 +08:00
|
|
|
|
port = 8084
|
|
|
|
|
|
print(f"🐧 Linux 学习平台启动中... http://127.0.0.1:{port}")
|
|
|
|
|
|
print("📚 线上地址: https://linux.xiaoxiaoluohao.indevs.in")
|
|
|
|
|
|
http.server.ThreadingHTTPServer(("127.0.0.1", port), LinuxLearningHandler).serve_forever()
|