From 7cec273d5885ce776da0fea6189f2a20944bc71b Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 17:36:11 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=202=20=E9=98=B6=E6=AE=B5=EF=BC=9A?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8E=E7=8F=AD=E7=BA=A7=EF=BC=8C=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E4=BF=9D=E5=AD=98=E5=9C=A8=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品 - 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数 - 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8, 识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码, 导出带密码的名单 - 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存 - 前端加路由:登录页、实验页、班级管理页 - 部署:TEACHER_PASSWORD 必填,数据库放在数据卷 Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +- .gitignore | 3 + README.md | 3 +- bun.lock | 13 +- compose.yaml | 13 +- deploy/README.md | 12 +- deploy/update.sh | 2 +- docs/设计方案.md | 19 +- server/src/access.ts | 58 --- server/src/app.ts | 130 +++++ server/src/auth.ts | 105 ++++ server/src/classes.ts | 141 ++++++ server/src/config.ts | 7 +- server/src/db.ts | 58 +++ server/src/index.ts | 45 +- server/src/works.ts | 38 ++ server/test/api.test.ts | 203 ++++++++ web/package.json | 3 +- web/src/AccessGate.vue | 85 ---- web/src/Root.vue | 20 + web/src/api.ts | 31 ++ web/src/auth.ts | 30 ++ web/src/editor/drafts.ts | 31 -- web/src/editor/works.ts | 21 + web/src/main.ts | 13 +- web/src/pages/LoginPage.vue | 88 ++++ web/src/pages/TeacherPage.vue | 480 +++++++++++++++++++ web/src/{App.vue => pages/WorkbenchPage.vue} | 341 +++++++------ web/src/router.ts | 27 ++ web/src/teacher/roster.test.ts | 43 ++ web/src/teacher/roster.ts | 91 ++++ 31 files changed, 1776 insertions(+), 382 deletions(-) delete mode 100644 server/src/access.ts create mode 100644 server/src/app.ts create mode 100644 server/src/auth.ts create mode 100644 server/src/classes.ts create mode 100644 server/src/db.ts create mode 100644 server/src/works.ts create mode 100644 server/test/api.test.ts delete mode 100644 web/src/AccessGate.vue create mode 100644 web/src/Root.vue create mode 100644 web/src/api.ts create mode 100644 web/src/auth.ts delete mode 100644 web/src/editor/drafts.ts create mode 100644 web/src/editor/works.ts create mode 100644 web/src/pages/LoginPage.vue create mode 100644 web/src/pages/TeacherPage.vue rename web/src/{App.vue => pages/WorkbenchPage.vue} (53%) create mode 100644 web/src/router.ts create mode 100644 web/src/teacher/roster.test.ts create mode 100644 web/src/teacher/roster.ts diff --git a/.env.example b/.env.example index 2fa82f8..679ef7d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # 复制为 .env 后修改。.env 不进 git。 -# 学生进入平台要输入的口令,换口令后所有人要重新输入 -ACCESS_PASSWORD=请改成不容易猜到的口令 +# 老师账号的密码(账号默认 admin,可用 TEACHER_USERNAME 修改) +TEACHER_PASSWORD=请改成不容易猜到的密码 # 平台最多使用的内存,默认 4g # APP_MEM_LIMIT=4g diff --git a/.gitignore b/.gitignore index 50f9026..21bf3d7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ obj/ # 部署配置(含访问口令) .env + +# 本机开发的数据库 +/data/ diff --git a/README.md b/README.md index e4eeef4..995ac19 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 - 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 -- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、访问口令、Docker Compose 部署脚本。 +- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 +- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上。 尚未完成: diff --git a/bun.lock b/bun.lock index 1d8f824..0c9726c 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "monaco-editor": "^0.57.0", "naive-ui": "^2.41.0", "vue": "^3.5.0", + "vue-router": "^4.6.4", }, "devDependencies": { "@types/bun": "^1.2.0", @@ -177,7 +178,7 @@ "@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmjs.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="], @@ -195,6 +196,8 @@ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="], + "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmjs.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + "@vue/language-core": ["@vue/language-core@3.3.13", "https://registry.npmjs.com/@vue/language-core/-/language-core-3.3.13.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="], "@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="], @@ -221,7 +224,7 @@ "date-fns-tz": ["date-fns-tz@3.2.0", "https://registry.npmjs.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="], - "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], + "dompurify": ["dompurify@3.4.15", "https://registry.npmjs.com/dompurify/-/dompurify-3.4.15.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], "entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -245,9 +248,9 @@ "magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], + "marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], - "monaco-editor": ["monaco-editor@0.57.0", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="], + "monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="], "muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], @@ -287,6 +290,8 @@ "vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="], + "vue-router": ["vue-router@4.6.4", "https://registry.npmjs.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], + "vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="], "vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="], diff --git a/compose.yaml b/compose.yaml index 5b81238..0b79820 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# 部署:先在 .env 里写好 ACCESS_PASSWORD(参考 .env.example),再 docker compose up -d +# 部署:先在 .env 里写好 TEACHER_PASSWORD(参考 .env.example),再 docker compose up -d # HTTPS 由 Nginx Proxy Manager 负责,它在 npm_proxy 网络里转发到 iot-sim:80;步骤见 deploy/README.md services: app: @@ -6,16 +6,22 @@ services: build: . restart: unless-stopped environment: - ACCESS_PASSWORD: ${ACCESS_PASSWORD:?请在 .env 里设置 ACCESS_PASSWORD} + # 老师账号(只有一个);学生账号由老师在网页上导入名单 + TEACHER_USERNAME: ${TEACHER_USERNAME:-admin} + TEACHER_PASSWORD: ${TEACHER_PASSWORD:?请在 .env 里设置 TEACHER_PASSWORD} + DATA_DIR: /data # 以下可在 .env 里调整,说明见 deploy/README.md SIM_MAX_RUNS: ${SIM_MAX_RUNS:-150} SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128} SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120} SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60} - # 容器文件系统只读,只有 /tmp(学生工程的临时目录)可写 + # 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写 read_only: true tmpfs: - /tmp:size=1g + # 数据库(账号、班级、学生作品),重新部署不会丢 + volumes: + - data:/data cap_drop: [ALL] security_opt: - no-new-privileges:true @@ -47,6 +53,7 @@ services: - caddy_config:/config volumes: + data: caddy_data: caddy_config: diff --git a/deploy/README.md b/deploy/README.md index f9d3549..6e1804c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -35,7 +35,7 @@ ```bash git clone git@git.xuyue.cc:xuyue/IOT_SIM.git ~/IOT_SIM cd ~/IOT_SIM - echo 'ACCESS_PASSWORD=换成你的口令' > .env + echo 'TEACHER_PASSWORD=换成你的密码' > .env chmod 600 .env ``` @@ -61,7 +61,9 @@ - **打开 Websockets Support**(运行程序靠 WebSocket,不打开会一直连不上) - SSL:申请证书,打开 Force SSL -5. 浏览器打开域名,输入口令进入。 +5. 浏览器打开域名,用老师账号登录,在"班级管理"里建班、导入学生名单。 + +数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。 ## 自动部署(GitHub Actions) @@ -96,7 +98,8 @@ | 做什么 | 命令 | |---|---| | 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` | -| 换口令 | 修改 `.env` 后 `docker compose up -d`(所有人要重新输入口令) | +| 改老师密码 | 修改 `.env` 后 `docker compose up -d` | +| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 | | 看日志 | `docker compose logs -f app` | | 看资源占用 | `docker stats` | | 重启 / 停止 | `docker compose restart` / `docker compose down` | @@ -107,7 +110,8 @@ | 变量 | 默认 | 说明 | |---|---|---| -| `ACCESS_PASSWORD` | (必填) | 访问口令 | +| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 | +| `TEACHER_USERNAME` | `admin` | 老师账号 | | `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 | | `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" | | `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 | diff --git a/deploy/update.sh b/deploy/update.sh index 6b07c1c..3238d16 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -11,7 +11,7 @@ main() { local ref=${1:-origin/main} if [[ ! -f .env ]]; then - echo '还没有 .env:cp .env.example .env 后设置 ACCESS_PASSWORD' + echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD' exit 1 fi diff --git a/docs/设计方案.md b/docs/设计方案.md index 6324b1d..a55dbcb 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -21,6 +21,9 @@ | 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 | | 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 | | 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 | +| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 | +| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 | +| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 | | 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 | | 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 | | 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 | @@ -158,7 +161,7 @@ IOT/ | 前端 | 实时通信 | 浏览器原生 WebSocket | | 后端 | 运行时 | Bun | | 后端 | Web 框架 | Hono | -| 后端 | 数据库 | 计划:SQLite(`bun:sqlite`)+ Drizzle ORM | +| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM | | 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` | | 后端 | 参数校验 | 计划:Zod | | C# 运行时 | 平台 | .NET 10 LTS | @@ -181,12 +184,12 @@ IOT/ 在公网上运行学生代码,必须做三层防护;第 0 阶段实测,100 个学生程序同时运行合计约 1.1 GB 内存。 -**安全措施**(1、2 已完成;3 在第 2 阶段之前先用访问口令代替) +**安全措施**(已完成) 1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。 - 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。 2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。 -3. **账号管控**:不开放自由注册,由老师用班级码或批量导入创建账号(第 2 阶段)。在此之前,全站用一个访问口令(`.env` 里设置,换口令后所有人要重新输入),按 IP 限制输错次数。 +3. **账号管控**:不开放注册,老师上传名单批量创建学生账号;所有接口和运行程序的 WebSocket 都要先登录;按 IP 限制输错次数。会话令牌数据库里只存哈希。学生密码由老师发放、老师可查看,按原样保存(有意的取舍)。 **资源估算** @@ -230,8 +233,8 @@ IOT/ ```mermaid flowchart TB P0["阶段 0 · 技术验证(已完成 2026-10-22)
教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] - P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、访问口令、Docker Compose)✔"] - P2["阶段 2 · 教学闭环(约 2 个月)
班级码登录、提交作业、自动评测、老师看板、成绩导出;一个班试用"] + P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、Docker Compose、GitHub Actions)✔"] + P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔
布置与提交作业、自动评测、老师看板、成绩导出;一个班试用"] P3["阶段 3 · 扩展(下学期)
教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"] P4["阶段 4 · 进阶(之后)
校园街景场景、故障注入题库、导出 VS 工程"] P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 @@ -265,6 +268,10 @@ flowchart TB - 2026-10-22:虚拟实训台按自身宽度自适应排列,可以收起成一行状态(传感器开关仍可点),给窗体显示区腾地方。 - 2026-10-22:部署:学生程序放进 bubblewrap 沙箱(断网、只读、看不到其他进程和服务器环境变量;限制堆内存、CPU 时间、运行时长,全站并发上限);编译检查禁止网络、反射、启动进程等 API;访问口令;Bun 直接提供打包好的网页;Dockerfile + Docker Compose(Caddy 接入 NPM 的 npm_proxy 网络)+ 部署脚本,本机按服务器配置(2 核)完整验证。 +**第 2 阶段进展**: + +- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。 + ## 技术风险与待确认事项 最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。 @@ -280,7 +287,7 @@ flowchart TB | 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 | | 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms | | 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 | -| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查(已完成)+ 访问口令,第 2 阶段换成账号 | +| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) | | ICP 备案 | 办理需要数周 | 已备案 | **待确认** diff --git a/server/src/access.ts b/server/src/access.ts deleted file mode 100644 index 880c7ef..0000000 --- a/server/src/access.ts +++ /dev/null @@ -1,58 +0,0 @@ -// 访问口令:账号系统(第 2 阶段)做好之前,用一个统一口令挡住外人。 -// 输对口令后发一个 cookie,值是口令的哈希;老师换口令后,旧 cookie 全部失效。 -import type { Context } from 'hono' -import { setCookie } from 'hono/cookie' -import { config } from './config' - -const COOKIE = 'iot_access' -// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面 -const MAX_FAILURES = 30 -const FAILURE_WINDOW_MS = 10 * 60_000 - -const token = config.accessPassword - ? new Bun.CryptoHasher('sha256').update(`iot-sim-access:${config.accessPassword}`).digest('hex') - : '' - -export const accessRequired = token !== '' - -/** 请求是否带着有效的口令 cookie(没设口令时总是放行) */ -export function hasAccess(cookieHeader: string | null) { - if (!accessRequired) return true - const value = cookieHeader - ?.split(';') - .map((part) => part.trim().split('=')) - .find(([name]) => name === COOKIE)?.[1] - return value === token -} - -/** 每个 IP 一段时间内的失败次数,防止暴力猜口令 */ -const failures = new Map() - -export function tooManyFailures(ip: string) { - const entry = failures.get(ip) - if (!entry) return false - if (Date.now() - entry.since > FAILURE_WINDOW_MS) { - failures.delete(ip) - return false - } - return entry.count >= MAX_FAILURES -} - -/** 检查口令;对了就设置 cookie */ -export function login(c: Context, password: string, ip: string) { - if (password !== config.accessPassword) { - const entry = failures.get(ip) ?? { count: 0, since: Date.now() } - entry.count++ - failures.set(ip, entry) - return false - } - failures.delete(ip) - setCookie(c, COOKIE, token, { - httpOnly: true, - sameSite: 'Lax', - secure: new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https', - path: '/', - maxAge: 180 * 24 * 3600, - }) - return true -} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..f1cc2ab --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,130 @@ +// HTTP 接口。除登录外都要先登录;/api/teacher/* 只有老师能用。 +import type { Database } from 'bun:sqlite' +import { Hono, type Context } from 'hono' +import { deleteCookie, setCookie } from 'hono/cookie' +import { login, logout, sessionMaxAge, SESSION_COOKIE, sessionToken, sessionUser, tooManyFailures, type User } from './auth' +import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes' +import { listSamples, loadSample } from './samples' +import { serveStatic } from './static' +import { deleteWork, getWork, saveWork, validFiles } from './works' + +/** Bun.serve 的 server 对象(测试里直接调用 app.fetch 时没有) */ +interface Env { + requestIP?: (req: Request) => { address: string } | null +} + +type AppContext = Context<{ Bindings: Env; Variables: { user: User } }> + +/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */ +function clientIp(c: AppContext) { + return c.req.header('x-real-ip') || c.env?.requestIP?.(c.req.raw)?.address || '' +} + +const isHttps = (c: AppContext) => new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https' + +/** 读取 JSON 请求体,格式不对时返回空对象 */ +const body = (c: AppContext) => c.req.json>().catch(() => ({}) as Partial) + +export function createApp(db: Database) { + const app = new Hono<{ Bindings: Env; Variables: { user: User } }>() + + app.post('/api/login', async (c) => { + const ip = clientIp(c) + if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429) + const { username, password } = await body<{ username: string; password: string }>(c) + const token = login(db, String(username ?? ''), String(password ?? ''), ip) + if (!token) return c.json({ message: '账号或密码不对' }, 401) + setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: isHttps(c), path: '/', maxAge: sessionMaxAge }) + return c.json({ user: sessionUser(db, token) }) + }) + + app.post('/api/logout', (c) => { + const token = sessionToken(c.req.header('cookie')) + if (token) logout(db, token) + deleteCookie(c, SESSION_COOKIE, { path: '/' }) + return c.json({}) + }) + + // 其余接口都要先登录 + app.use('/api/*', async (c, next) => { + const user = sessionUser(db, sessionToken(c.req.header('cookie'))) + if (!user) return c.json({ message: '请先登录' }, 401) + c.set('user', user) + await next() + }) + + app.get('/api/me', (c) => c.json({ user: c.get('user') })) + + app.get('/api/samples', async (c) => c.json(await listSamples())) + + app.get('/api/samples/:id', async (c) => { + const files = await loadSample(c.req.param('id')) + return files ? c.json(files) : c.notFound() + }) + + // 自己的作品:key 如 "sample:p2-t1-LED显示屏" + app.get('/api/works/:key', (c) => c.json(getWork(db, c.get('user').id, c.req.param('key')))) + + app.put('/api/works/:key', async (c) => { + const { files } = await body<{ files: unknown }>(c) + if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400) + return c.json({ updatedAt: saveWork(db, c.get('user').id, c.req.param('key'), files) }) + }) + + app.delete('/api/works/:key', (c) => { + deleteWork(db, c.get('user').id, c.req.param('key')) + return c.json({}) + }) + + // ---- 老师 ---- + app.use('/api/teacher/*', async (c, next) => { + if (c.get('user').role !== 'teacher') return c.json({ message: '只有老师能使用' }, 403) + await next() + }) + + const id = (c: AppContext) => Number(c.req.param('id')) + const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result)) + + app.get('/api/teacher/classes', (c) => c.json(listClasses(db))) + + app.post('/api/teacher/classes', async (c) => { + const { name } = await body<{ name: string }>(c) + const result = createClass(db, String(name ?? '')) + return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result) + }) + + app.patch('/api/teacher/classes/:id', async (c) => { + const { name } = await body<{ name: string }>(c) + return reply(c, renameClass(db, id(c), String(name ?? ''))) + }) + + app.delete('/api/teacher/classes/:id', (c) => reply(c, deleteClass(db, id(c)))) + + app.get('/api/teacher/classes/:id/students', (c) => c.json(listStudents(db, id(c)))) + + app.post('/api/teacher/classes/:id/import', async (c) => { + const { rows } = await body<{ rows: ImportRow[] }>(c) + if (!Array.isArray(rows) || rows.some((r) => typeof r?.username !== 'string' || typeof r?.name !== 'string')) { + return c.json({ message: '名单格式不对' }, 400) + } + if (!listClasses(db).some((cls) => cls.id === id(c))) return c.json({ message: '班级不存在' }, 404) + return c.json(importStudents(db, id(c), rows)) + }) + + app.post('/api/teacher/students/:id/reset-password', (c) => { + const password = resetPassword(db, id(c)) + return password ? c.json({ password }) : c.json({ message: '学生不存在' }, 404) + }) + + app.delete('/api/teacher/students/:id', (c) => { + deleteStudent(db, id(c)) + return c.json({}) + }) + + app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404)) + + // 打包好的网页(部署时);开发时网页由 Vite 提供 + app.get('*', (c) => serveStatic(new URL(c.req.url).pathname)) + + return app +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..b49e5ef --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,105 @@ +// 登录与会话:账号密码登录后发一个 HttpOnly cookie,数据库只存令牌的哈希。 +// 老师账号只有一个,账号密码来自配置;学生账号由老师导入名单创建。 +import type { Database } from 'bun:sqlite' +import { config } from './config' + +export const SESSION_COOKIE = 'iot_session' +const SESSION_DAYS = 180 +// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面 +const MAX_FAILURES = 30 +const FAILURE_WINDOW_MS = 10 * 60_000 + +export interface User { + id: number + username: string + name: string + role: 'student' | 'teacher' + classId: number | null + className: string | null +} + +const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex') + +let warnedDefaultPassword = false + +/** 服务器启动时按配置创建或更新老师账号 */ +export function ensureTeacher(db: Database) { + let password = config.teacherPassword + if (!password) { + password = 'admin' + if (!warnedDefaultPassword) console.warn(`没有设置 TEACHER_PASSWORD,老师账号为 ${config.teacherUsername} / admin(只适合本机开发)`) + warnedDefaultPassword = true + } + db.query( + `INSERT INTO users (username, name, password, role, created_at) VALUES (?, '老师', ?, 'teacher', ?) + ON CONFLICT (username) DO UPDATE SET password = excluded.password, role = 'teacher', class_id = NULL`, + ).run(config.teacherUsername, password, Date.now()) +} + +/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */ +const failures = new Map() + +export function tooManyFailures(ip: string) { + const entry = failures.get(ip) + if (!entry) return false + if (Date.now() - entry.since > FAILURE_WINDOW_MS) { + failures.delete(ip) + return false + } + return entry.count >= MAX_FAILURES +} + +/** 账号密码正确时创建会话,返回令牌(放进 cookie);不正确返回 null */ +export function login(db: Database, username: string, password: string, ip: string): string | null { + const row = db.query<{ id: number; password: string }, [string]>('SELECT id, password FROM users WHERE username = ?').get(username.trim()) + if (!row || row.password !== password) { + const entry = failures.get(ip) ?? { count: 0, since: Date.now() } + entry.count++ + failures.set(ip, entry) + return null + } + failures.delete(ip) + const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex') + db.query('INSERT INTO sessions (token_hash, user_id, created_at) VALUES (?, ?, ?)').run(hash(token), row.id, Date.now()) + return token +} + +export function logout(db: Database, token: string) { + db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash(token)) +} + +/** 删除某个用户的所有会话(重置密码、删除学生时,让他已登录的浏览器失效) */ +export function endSessions(db: Database, userId: number) { + db.query('DELETE FROM sessions WHERE user_id = ?').run(userId) +} + +/** 从 Cookie 请求头取出令牌 */ +export function sessionToken(cookieHeader: string | null | undefined) { + return cookieHeader + ?.split(';') + .map((part) => part.trim().split('=')) + .find(([name]) => name === SESSION_COOKIE)?.[1] +} + +/** 令牌对应的用户;没登录或会话已过期返回 null */ +export function sessionUser(db: Database, token: string | undefined): User | null { + if (!token) return null + const row = db + .query< + { id: number; username: string; name: string; role: User['role']; class_id: number | null; class_name: string | null; created_at: number }, + [string] + >( + `SELECT u.id, u.username, u.name, u.role, u.class_id, c.name AS class_name, s.created_at + FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN classes c ON c.id = u.class_id + WHERE s.token_hash = ?`, + ) + .get(hash(token)) + if (!row) return null + if (Date.now() - row.created_at > SESSION_DAYS * 86_400_000) { + logout(db, token) + return null + } + return { id: row.id, username: row.username, name: row.name, role: row.role, classId: row.class_id, className: row.class_name } +} + +export const sessionMaxAge = SESSION_DAYS * 86_400 diff --git a/server/src/classes.ts b/server/src/classes.ts new file mode 100644 index 0000000..92bb6fe --- /dev/null +++ b/server/src/classes.ts @@ -0,0 +1,141 @@ +// 班级和学生名单(老师用)。学生账号由老师上传名单导入,密码由老师发放,学生不能自己改。 +import type { Database } from 'bun:sqlite' +import { endSessions } from './auth' + +export interface ClassInfo { + id: number + name: string + studentCount: number +} + +export interface Student { + id: number + username: string + name: string + password: string +} + +export interface ImportRow { + username: string + name: string + password?: string +} + +export interface ImportResult { + created: number + updated: number + /** 有问题的行(行号从 1 开始,对应上传的名单),这些行没有导入 */ + errors: { row: number; message: string }[] +} + +/** 平台生成的初始密码:6 位数字,学生好输入;老师在名单里能看到 */ +export function generatePassword() { + return String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, '0') +} + +export function listClasses(db: Database): ClassInfo[] { + return db + .query( + `SELECT c.id, c.name, COUNT(u.id) AS studentCount + FROM classes c LEFT JOIN users u ON u.class_id = c.id + GROUP BY c.id ORDER BY c.name`, + ) + .all() +} + +/** 新建班级;名字为空或重名时返回错误信息 */ +export function createClass(db: Database, name: string): { id: number } | { error: string } { + name = name.trim() + if (!name) return { error: '班级名不能为空' } + if (db.query('SELECT 1 FROM classes WHERE name = ?').get(name)) return { error: `已经有叫"${name}"的班级` } + const result = db.query('INSERT INTO classes (name, created_at) VALUES (?, ?)').run(name, Date.now()) + return { id: Number(result.lastInsertRowid) } +} + +export function renameClass(db: Database, id: number, name: string): { error?: string } { + name = name.trim() + if (!name) return { error: '班级名不能为空' } + if (db.query('SELECT 1 FROM classes WHERE name = ? AND id <> ?').get(name, id)) return { error: `已经有叫"${name}"的班级` } + db.query('UPDATE classes SET name = ? WHERE id = ?').run(name, id) + return {} +} + +/** 只能删除没有学生的班级,避免误删全班的作品 */ +export function deleteClass(db: Database, id: number): { error?: string } { + if (db.query('SELECT 1 FROM users WHERE class_id = ?').get(id)) return { error: '班级里还有学生,先删除学生再删除班级' } + db.query('DELETE FROM classes WHERE id = ?').run(id) + return {} +} + +export function listStudents(db: Database, classId: number): Student[] { + return db + .query(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`) + .all(classId) +} + +/** + * 导入名单到一个班:账号不存在就新建,已存在就更新姓名、班级(以及填了的密码)。 + * 密码留空时:新学生生成 6 位数字密码,老学生保持原密码。 + */ +export function importStudents(db: Database, classId: number, rows: ImportRow[]): ImportResult { + const result: ImportResult = { created: 0, updated: 0, errors: [] } + const seen = new Set() + const find = db.query<{ id: number; role: string }, [string]>('SELECT id, role FROM users WHERE username = ?') + const insert = db.query(`INSERT INTO users (username, name, password, role, class_id, created_at) VALUES (?, ?, ?, 'student', ?, ?)`) + const update = db.query('UPDATE users SET name = ?, class_id = ?, password = COALESCE(?, password) WHERE id = ?') + + db.transaction(() => { + for (const [index, row] of rows.entries()) { + const fail = (message: string) => result.errors.push({ row: index + 1, message }) + const username = row.username.trim() + const name = row.name.trim() + const password = row.password?.trim() || undefined + if (!username) { + fail('账号为空') + continue + } + if (/\s/.test(username)) { + fail(`账号"${username}"里有空格`) + continue + } + if (!name) { + fail(`账号"${username}"的姓名为空`) + continue + } + if (seen.has(username)) { + fail(`账号"${username}"在名单里重复`) + continue + } + seen.add(username) + + const existing = find.get(username) + if (existing?.role === 'teacher') { + fail(`"${username}"是老师账号`) + continue + } + if (existing) { + update.run(name, classId, password ?? null, existing.id) + if (password) endSessions(db, existing.id) + result.updated++ + } else { + insert.run(username, name, password ?? generatePassword(), classId, Date.now()) + result.created++ + } + } + })() + return result +} + +/** 重置为新的 6 位数字密码,学生已登录的浏览器需要重新登录 */ +export function resetPassword(db: Database, studentId: number): string | null { + const password = generatePassword() + const changed = db.query(`UPDATE users SET password = ? WHERE id = ? AND role = 'student'`).run(password, studentId).changes + if (!changed) return null + endSessions(db, studentId) + return password +} + +/** 删除学生,连同他的作品和登录会话 */ +export function deleteStudent(db: Database, studentId: number) { + db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId) +} diff --git a/server/src/config.ts b/server/src/config.ts index 9719fa9..8358dbf 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -28,7 +28,10 @@ export const config = { maxRuns: Number(env.SIM_MAX_RUNS ?? 150), /** 一次运行最长多少分钟,到时自动结束 */ maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60), - /** 访问口令;不设置表示不需要口令(本机开发) */ - accessPassword: env.ACCESS_PASSWORD ?? '', + /** SQLite 数据库所在目录(账号、班级、学生作品);部署时放在数据卷里 */ + dataDir: resolve(ROOT, env.DATA_DIR ?? 'data'), + /** 老师账号(只有一个)。开发时不设置密码就用 admin / admin */ + teacherUsername: env.TEACHER_USERNAME ?? 'admin', + teacherPassword: env.TEACHER_PASSWORD ?? '', timeZone: env.TZ ?? 'Asia/Shanghai', } diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..021a88c --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,58 @@ +// SQLite 数据库:账号、班级、登录会话、学生作品。 +// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。 +import { Database } from 'bun:sqlite' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { config } from './config' + +const MIGRATIONS = [ + ` + CREATE TABLE classes ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + -- 学生的密码由老师发放、老师可以查看,所以按原样保存(学生不能自己改) + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('student', 'teacher')), + class_id INTEGER REFERENCES classes (id), + created_at INTEGER NOT NULL + ); + CREATE INDEX users_class ON users (class_id); + -- 只保存令牌的哈希,数据库泄露也拿不到可用的登录令牌 + CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at INTEGER NOT NULL + ); + -- 学生在某个实验上的代码(自动保存),key 如 "sample:p2-t1-LED显示屏" + CREATE TABLE works ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + key TEXT NOT NULL, + files TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, key) + ); + `, +] + +/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */ +export function openDatabase(path = join(config.dataDir, 'iot-sim.db')) { + if (path !== ':memory:') mkdirSync(config.dataDir, { recursive: true }) + const db = new Database(path, { create: true, strict: true }) + db.run('PRAGMA journal_mode = WAL') + db.run('PRAGMA foreign_keys = ON') + db.run('PRAGMA busy_timeout = 5000') + const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version + for (let i = version; i < MIGRATIONS.length; i++) { + db.transaction(() => { + db.run(MIGRATIONS[i]) + db.run(`PRAGMA user_version = ${i + 1}`) + })() + } + return db +} diff --git a/server/src/index.ts b/server/src/index.ts index 063bf5f..c94c9f5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,50 +1,19 @@ -import { Hono } from 'hono' -import type { Server } from 'bun' import type { ClientMessage, ServerMessage } from '@iot/shared' -import { accessRequired, hasAccess, login, tooManyFailures } from './access' +import { createApp } from './app' +import { ensureTeacher, sessionToken, sessionUser } from './auth' import { compileService } from './compile-service' import { config } from './config' +import { openDatabase } from './db' import { cleanupStaleRuns, RunSession } from './run-session' -import { listSamples, loadSample } from './samples' import { checkSandbox, sandboxError } from './sandbox' -import { serveStatic } from './static' await cleanupStaleRuns() await checkSandbox() if (sandboxError) console.error(sandboxError) -const app = new Hono<{ Bindings: Server }>() - -/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */ -function clientIp(req: Request, server: Server) { - return req.headers.get('x-real-ip') || server.requestIP(req)?.address || '' -} - -app.get('/api/auth', (c) => c.json({ required: accessRequired, ok: hasAccess(c.req.header('cookie') ?? null) })) - -app.post('/api/login', async (c) => { - const ip = clientIp(c.req.raw, c.env) - if (tooManyFailures(ip)) return c.json({ ok: false, message: '输错次数太多,请 10 分钟后再试' }, 429) - const { password } = await c.req.json<{ password?: string }>().catch(() => ({ password: undefined })) - if (!login(c, String(password ?? ''), ip)) return c.json({ ok: false, message: '口令不对' }, 401) - return c.json({ ok: true }) -}) - -// 其余接口都要先输入口令 -app.use('/api/*', async (c, next) => { - if (!hasAccess(c.req.header('cookie') ?? null)) return c.json({ message: '请先输入访问口令' }, 401) - await next() -}) - -app.get('/api/samples', async (c) => c.json(await listSamples())) - -app.get('/api/samples/:id', async (c) => { - const files = await loadSample(c.req.param('id')) - return files ? c.json(files) : c.notFound() -}) - -// 打包好的网页(部署时);开发时网页由 Vite 提供 -app.get('*', (c) => serveStatic(new URL(c.req.url).pathname)) +const db = openDatabase() +ensureTeacher(db) +const app = createApp(db) interface SocketData { session?: RunSession @@ -54,7 +23,7 @@ const server = Bun.serve({ port: config.port, fetch(req, server) { if (new URL(req.url).pathname === '/ws') { - if (!hasAccess(req.headers.get('cookie'))) return new Response('请先输入访问口令', { status: 401 }) + if (!sessionUser(db, sessionToken(req.headers.get('cookie')))) return new Response('请先登录', { status: 401 }) if (server.upgrade(req, { data: {} })) return return new Response('WebSocket 升级失败', { status: 400 }) } diff --git a/server/src/works.ts b/server/src/works.ts new file mode 100644 index 0000000..e694e97 --- /dev/null +++ b/server/src/works.ts @@ -0,0 +1,38 @@ +// 学生作品:每个人在每个实验上的代码,浏览器改动后自动保存,换电脑登录也能接着做。 +import type { Database } from 'bun:sqlite' +import type { SourceFile } from '@iot/shared' + +/** 一份作品最大字节数(全部文件内容合计),防止误传大文件 */ +export const MAX_WORK_BYTES = 1_000_000 + +export function getWork(db: Database, userId: number, key: string): { files: SourceFile[]; updatedAt: number } | null { + const row = db + .query<{ files: string; updated_at: number }, [number, string]>('SELECT files, updated_at FROM works WHERE user_id = ? AND key = ?') + .get(userId, key) + return row ? { files: JSON.parse(row.files) as SourceFile[], updatedAt: row.updated_at } : null +} + +export function saveWork(db: Database, userId: number, key: string, files: SourceFile[]) { + const updatedAt = Date.now() + db.query( + `INSERT INTO works (user_id, key, files, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (user_id, key) DO UPDATE SET files = excluded.files, updated_at = excluded.updated_at`, + ).run(userId, key, JSON.stringify(files), updatedAt) + return updatedAt +} + +/** 删除作品(学生点"恢复原始代码") */ +export function deleteWork(db: Database, userId: number, key: string) { + db.query('DELETE FROM works WHERE user_id = ? AND key = ?').run(userId, key) +} + +/** 检查浏览器传来的文件列表:路径和内容都是字符串,总大小不超限 */ +export function validFiles(value: unknown): value is SourceFile[] { + if (!Array.isArray(value) || value.length > 100) return false + let bytes = 0 + for (const file of value) { + if (typeof file?.path !== 'string' || typeof file?.content !== 'string') return false + bytes += file.path.length + file.content.length + } + return bytes <= MAX_WORK_BYTES +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts new file mode 100644 index 0000000..69c191d --- /dev/null +++ b/server/test/api.test.ts @@ -0,0 +1,203 @@ +// HTTP 接口测试:内存数据库,直接调用 app.fetch。 +import { beforeEach, describe, expect, test } from 'bun:test' +import { createApp } from '../src/app' +import { ensureTeacher } from '../src/auth' +import { config } from '../src/config' +import { openDatabase } from '../src/db' + +let app: ReturnType + +beforeEach(() => { + const db = openDatabase(':memory:') + ensureTeacher(db) + app = createApp(db) +}) + +/** 一个浏览器:记住登录 cookie */ +class Browser { + cookie = '' + constructor(readonly ip = '10.0.0.1') {} + + async request(method: string, path: string, json?: unknown) { + const headers: Record = { 'x-real-ip': this.ip } + if (this.cookie) headers.cookie = this.cookie + if (json !== undefined) headers['content-type'] = 'application/json' + const response = await app.fetch( + new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }), + ) + const setCookie = response.headers.get('set-cookie') + if (setCookie) this.cookie = setCookie.split(';')[0] + return { status: response.status, body: (await response.json()) as any } + } + + get = (path: string) => this.request('GET', path) + post = (path: string, json: unknown = {}) => this.request('POST', path, json) + put = (path: string, json: unknown) => this.request('PUT', path, json) + patch = (path: string, json: unknown) => this.request('PATCH', path, json) + delete = (path: string) => this.request('DELETE', path) + + login(username: string, password: string) { + return this.post('/api/login', { username, password }) + } +} + +async function teacher() { + const browser = new Browser() + expect((await browser.login(config.teacherUsername, config.teacherPassword || 'admin')).status).toBe(200) + return browser +} + +/** 老师建一个班并导入名单,返回班级 id */ +async function classWithStudents(t: Browser, rows: { username: string; name: string; password?: string }[]) { + const { body } = await t.post('/api/teacher/classes', { name: '物联网 2401' }) + await t.post(`/api/teacher/classes/${body.id}/import`, { rows }) + return body.id as number +} + +test('没登录时接口返回 401', async () => { + const browser = new Browser() + expect((await browser.get('/api/me')).status).toBe(401) + expect((await browser.get('/api/samples')).status).toBe(401) + expect((await browser.get('/api/works/sample:x')).status).toBe(401) +}) + +test('账号或密码不对时登录失败,老师登录后能看到自己的信息', async () => { + const browser = new Browser() + const wrong = await browser.login('admin', 'wrong') + expect(wrong.status).toBe(401) + expect(wrong.body.message).toBe('账号或密码不对') + + const t = await teacher() + const me = await t.get('/api/me') + expect(me.body.user).toMatchObject({ username: config.teacherUsername, role: 'teacher' }) +}) + +test('同一个 IP 输错 30 次后暂停登录,别的 IP 不受影响', async () => { + const attacker = new Browser('10.0.0.66') + for (let i = 0; i < 30; i++) await attacker.login('admin', `guess${i}`) + expect((await attacker.login('admin', 'whatever')).status).toBe(429) + expect((await new Browser('10.0.0.67').login('admin', 'whatever')).status).toBe(401) +}) + +describe('班级和名单', () => { + test('新建班级,不能重名', async () => { + const t = await teacher() + expect((await t.post('/api/teacher/classes', { name: '物联网 2401' })).status).toBe(200) + const duplicate = await t.post('/api/teacher/classes', { name: ' 物联网 2401 ' }) + expect(duplicate.status).toBe(400) + expect(duplicate.body.message).toContain('已经有') + expect((await t.get('/api/teacher/classes')).body).toEqual([{ id: 1, name: '物联网 2401', studentCount: 0 }]) + }) + + test('导入名单:新学生没填密码时生成 6 位数字,有问题的行单独报出来', async () => { + const t = await teacher() + const { body: cls } = await t.post('/api/teacher/classes', { name: '物联网 2401' }) + const result = await t.post(`/api/teacher/classes/${cls.id}/import`, { + rows: [ + { username: '2024001', name: '张三', password: 'abc123' }, + { username: '2024002', name: '李四' }, + { username: '2024001', name: '张三重复' }, + { username: '2024003', name: '' }, + { username: '2024 004', name: '空格' }, + { username: 'admin', name: '冒充老师' }, + ], + }) + expect(result.body).toEqual({ + created: 2, + updated: 0, + errors: [ + { row: 3, message: '账号"2024001"在名单里重复' }, + { row: 4, message: '账号"2024003"的姓名为空' }, + { row: 5, message: '账号"2024 004"里有空格' }, + { row: 6, message: '"admin"是老师账号' }, + ], + }) + const students = (await t.get(`/api/teacher/classes/${cls.id}/students`)).body + expect(students.map((s: any) => [s.username, s.name])).toEqual([ + ['2024001', '张三'], + ['2024002', '李四'], + ]) + expect(students[0].password).toBe('abc123') + expect(students[1].password).toMatch(/^\d{6}$/) + }) + + test('再次导入:更新姓名;密码留空时保留原密码', async () => { + const t = await teacher() + const classId = await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }]) + const result = await t.post(`/api/teacher/classes/${classId}/import`, { rows: [{ username: '2024001', name: '张叁' }] }) + expect(result.body).toMatchObject({ created: 0, updated: 1 }) + expect((await t.get(`/api/teacher/classes/${classId}/students`)).body[0]).toMatchObject({ name: '张叁', password: 'abc123' }) + }) + + test('学生用老师发的密码登录,不能使用老师接口', async () => { + const t = await teacher() + await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }]) + const student = new Browser() + const login = await student.login('2024001', 'abc123') + expect(login.body.user).toMatchObject({ name: '张三', role: 'student', className: '物联网 2401' }) + expect((await student.get('/api/teacher/classes')).status).toBe(403) + }) + + test('重置密码后,学生已登录的浏览器要重新登录,新密码可以登录', async () => { + const t = await teacher() + const classId = await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }]) + const student = new Browser() + await student.login('2024001', 'abc123') + const [{ id }] = (await t.get(`/api/teacher/classes/${classId}/students`)).body + const { body } = await t.post(`/api/teacher/students/${id}/reset-password`) + expect(body.password).toMatch(/^\d{6}$/) + expect((await student.get('/api/me')).status).toBe(401) + expect((await new Browser().login('2024001', 'abc123')).status).toBe(401) + expect((await new Browser().login('2024001', body.password)).status).toBe(200) + }) + + test('班级里有学生时不能删除;删除学生后可以删除班级', async () => { + const t = await teacher() + const classId = await classWithStudents(t, [{ username: '2024001', name: '张三' }]) + expect((await t.delete(`/api/teacher/classes/${classId}`)).status).toBe(400) + const [{ id }] = (await t.get(`/api/teacher/classes/${classId}/students`)).body + await t.delete(`/api/teacher/students/${id}`) + expect((await t.delete(`/api/teacher/classes/${classId}`)).status).toBe(200) + expect((await t.get('/api/teacher/classes')).body).toEqual([]) + }) +}) + +describe('作品', () => { + const files = [{ path: 'Form1.cs', content: '// 我的代码' }] + + test('保存后再读取;每个人只能看到自己的作品;恢复原始代码会删除作品', async () => { + const t = await teacher() + await classWithStudents(t, [ + { username: 's1', name: '甲', password: 'p1' }, + { username: 's2', name: '乙', password: 'p2' }, + ]) + const s1 = new Browser() + await s1.login('s1', 'p1') + const key = encodeURIComponent('sample:p2-t1-LED显示屏') + expect((await s1.get(`/api/works/${key}`)).body).toBeNull() + expect((await s1.put(`/api/works/${key}`, { files })).status).toBe(200) + expect((await s1.get(`/api/works/${key}`)).body.files).toEqual(files) + + const s2 = new Browser() + await s2.login('s2', 'p2') + expect((await s2.get(`/api/works/${key}`)).body).toBeNull() + + await s1.delete(`/api/works/${key}`) + expect((await s1.get(`/api/works/${key}`)).body).toBeNull() + }) + + test('格式不对或太大的作品拒绝保存', async () => { + const t = await teacher() + expect((await t.put('/api/works/x', { files: 'not-a-list' })).status).toBe(400) + expect((await t.put('/api/works/x', { files: [{ path: 'a.cs' }] })).status).toBe(400) + expect((await t.put('/api/works/x', { files: [{ path: 'a.cs', content: 'x'.repeat(1_000_001) }] })).status).toBe(400) + }) +}) + +test('退出登录后会话失效', async () => { + const t = await teacher() + const cookie = t.cookie + await t.post('/api/logout') + t.cookie = cookie + expect((await t.get('/api/me')).status).toBe(401) +}) diff --git a/web/package.json b/web/package.json index 3e607c3..7891295 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,8 @@ "@iot/shared": "workspace:*", "monaco-editor": "^0.57.0", "naive-ui": "^2.41.0", - "vue": "^3.5.0" + "vue": "^3.5.0", + "vue-router": "^4.6.4" }, "devDependencies": { "@types/bun": "^1.2.0", diff --git a/web/src/AccessGate.vue b/web/src/AccessGate.vue deleted file mode 100644 index a91fa1f..0000000 --- a/web/src/AccessGate.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/web/src/Root.vue b/web/src/Root.vue new file mode 100644 index 0000000..8c66752 --- /dev/null +++ b/web/src/Root.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..0fcba33 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,31 @@ +// 调用服务器接口。出错时抛出 ApiError,message 是服务器给的中文说明; +// 登录过期(401)时回到登录页。 +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + } +} + +/** 登录过期时的处理,由 router.ts 设置(避免这里依赖路由) */ +export const apiHooks = { onUnauthorized: () => {} } + +export async function api(method: string, path: string, json?: unknown, init?: RequestInit): Promise { + let response: Response + try { + response = await fetch(path, { + method, + headers: json === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: json === undefined ? undefined : JSON.stringify(json), + ...init, + }) + } catch { + throw new ApiError('连不上服务器,请检查网络', 0) + } + const data = (await response.json().catch(() => ({}))) as T & { message?: string } + if (response.status === 401 && path !== '/api/login') apiHooks.onUnauthorized() + if (!response.ok) throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status) + return data +} diff --git a/web/src/auth.ts b/web/src/auth.ts new file mode 100644 index 0000000..e99b6cd --- /dev/null +++ b/web/src/auth.ts @@ -0,0 +1,30 @@ +// 当前登录的用户。页面启动时先问一次服务器,之后登录、退出时更新。 +import { ref } from 'vue' +import { api } from './api' + +export interface User { + id: number + username: string + name: string + role: 'student' | 'teacher' + classId: number | null + className: string | null +} + +export const currentUser = ref(null) + +/** 页面启动时调用:已登录(cookie 有效)就取到用户 */ +export async function loadCurrentUser() { + currentUser.value = await api<{ user: User }>('GET', '/api/me') + .then((r) => r.user) + .catch(() => null) +} + +export async function login(username: string, password: string) { + currentUser.value = (await api<{ user: User }>('POST', '/api/login', { username, password })).user +} + +export async function logout() { + await api('POST', '/api/logout').catch(() => {}) + currentUser.value = null +} diff --git a/web/src/editor/drafts.ts b/web/src/editor/drafts.ts deleted file mode 100644 index fef0a5a..0000000 --- a/web/src/editor/drafts.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 学生改过的代码保存在浏览器本地(localStorage),刷新页面不丢失。 -// 这只是方便功能:隐私窗口、清除网站数据等情况下可能读写失败,失败时当作没有草稿。 -// 作业的正式保存以后放到服务器上。 -import type { SourceFile } from '@iot/shared' - -const PREFIX = 'iot-sim:draft:' - -export function loadDraft(sampleId: string): SourceFile[] | null { - try { - const raw = localStorage.getItem(PREFIX + sampleId) - return raw ? (JSON.parse(raw) as SourceFile[]) : null - } catch { - return null - } -} - -export function saveDraft(sampleId: string, files: SourceFile[]) { - try { - localStorage.setItem(PREFIX + sampleId, JSON.stringify(files)) - } catch { - // 存储不可用或已满:忽略 - } -} - -export function clearDraft(sampleId: string) { - try { - localStorage.removeItem(PREFIX + sampleId) - } catch { - // 忽略 - } -} diff --git a/web/src/editor/works.ts b/web/src/editor/works.ts new file mode 100644 index 0000000..89863d2 --- /dev/null +++ b/web/src/editor/works.ts @@ -0,0 +1,21 @@ +// 学生的作品保存在服务器上(按账号),换电脑登录也能接着做。 +// key 标识是哪个实验,如 "sample:p2-t1-LED显示屏"。 +import type { SourceFile } from '@iot/shared' +import { api } from '../api' + +export const sampleWorkKey = (sampleId: string) => `sample:${sampleId}` + +const url = (key: string) => `/api/works/${encodeURIComponent(key)}` + +export async function loadWork(key: string): Promise { + return (await api<{ files: SourceFile[] } | null>('GET', url(key)))?.files ?? null +} + +/** keepalive:页面关闭时也把请求发出去 */ +export async function saveWork(key: string, files: SourceFile[], keepalive = false) { + await api('PUT', url(key), { files }, { keepalive }) +} + +export async function deleteWork(key: string) { + await api('DELETE', url(key)) +} diff --git a/web/src/main.ts b/web/src/main.ts index c1103c0..0120cd9 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,11 +1,10 @@ import { createApp } from 'vue' -import AccessGate from './AccessGate.vue' -import App from './App.vue' +import { loadCurrentUser } from './auth' +import Root from './Root.vue' +import { router } from './router' import './winforms/winforms.css' -// 服务器设置了访问口令而浏览器还没输过时,先显示口令页 -const auth = await fetch('/api/auth') - .then((response) => response.json() as Promise<{ ok: boolean }>) - .catch(() => ({ ok: true })) +// 先确认是否已登录,路由守卫据此决定进登录页还是实验页 +await loadCurrentUser() -createApp(auth.ok ? App : AccessGate).mount('#app') +createApp(Root).use(router).mount('#app') diff --git a/web/src/pages/LoginPage.vue b/web/src/pages/LoginPage.vue new file mode 100644 index 0000000..ac6dc1b --- /dev/null +++ b/web/src/pages/LoginPage.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/web/src/pages/TeacherPage.vue b/web/src/pages/TeacherPage.vue new file mode 100644 index 0000000..8efc67c --- /dev/null +++ b/web/src/pages/TeacherPage.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/web/src/App.vue b/web/src/pages/WorkbenchPage.vue similarity index 53% rename from web/src/App.vue rename to web/src/pages/WorkbenchPage.vue index 8d95fd2..7247fab 100644 --- a/web/src/App.vue +++ b/web/src/pages/WorkbenchPage.vue @@ -1,17 +1,20 @@ + + + + 将删除 {{ deletableForm.code }} 和 {{ deletableForm.designer }},代码里用到这个窗体的地方需要自己修改。确定删除吗? + + + + + - +
+
+ + +
点击"运行"后,学生程序的窗体会显示在这里
+
+ +
+
+ {{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }} +
提示:{{ d.hint }}
+
+
{{ e }}
+
{{ state.console }}
+
+
+ + +