diff --git a/CHANGELOG.md b/CHANGELOG.md index daf26e2..2028986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 未发布 +## v0.4.0 — 2026-11-13 + +- 桌面「呼叫老师」悬浮球不再始终置顶:PPT 放映或最大化窗口会盖住它,回到桌面仍可见;悬浮球加大、改为醒目的橙色和白色粗体大字,确认面板与按钮同步放大。 +- 大屏主界面重新对齐:页眉、内容、页脚使用同一页宽;「呼叫老师」卡片补齐样式;整体字号加大;修复绑定页装饰图压住步骤文字、「READY TO CONNECT」上方多出横线的问题。 +- 网络稳定性:教师网页实时连接随心跳续期令牌,不再每 15 分钟断开;重连后补齐所有数据。大屏连接 10 秒无响应即重试,网络恢复时立即重连。 +- 服务端:登录、注册、改密的密码哈希移出全局锁并限制并发;不存在的账号与错误密码耗时一致。通知列表改用「时间 + ID」分页,同一时刻的记录不再漏掉。撤回通知不再把「失败」「过期」改写为「已关闭」;解绑屏幕时未完成的投递立即标记失败。教师建班校验年级长度。发送过快的提示按实际冷却秒数显示。 +- 数据量与性能:学生呼叫保留 90 天、审计日志 180 天,已用或过期的协作邀请自动清理;写入时按行编码、失败时从上次提交还原,每次写操作少一次整库序列化。 +- 清理:移除 0.1.3 之前的共享刷新 Cookie 兼容与已废弃的会话字段。**从 0.1.2 及更早版本直接升级的浏览器需重新登录一次。** + ## v0.3.5 — 2026-11-13 - 新增学生呼叫:Windows 桌面悬浮入口二次确认后呼叫本班全部关联教师;教师工作台新增「学生呼叫」、未读红色数量、在线实时提醒和手动共享已读,保留处理老师与时间。记录随服务持久化及备份,支持离线后补查;同班 30 秒防重复呼叫。不使用 Web Push,Windows 系统集成仍需实机验收。 diff --git a/api/openapi.yaml b/api/openapi.yaml index 7cedb24..5d7183b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "课堂叮咚 API", - "version": "0.3.5" + "version": "0.4.0" }, "servers": [ { diff --git a/api/ws/frame.schema.json b/api/ws/frame.schema.json index f8279f4..6b150c4 100644 --- a/api/ws/frame.schema.json +++ b/api/ws/frame.schema.json @@ -611,7 +611,11 @@ }, "d": { "type": "object", - "properties": {}, + "properties": { + "token": { + "type": "string" + } + }, "required": [] } }, diff --git a/deploy/.env.example b/deploy/.env.example index 5511347..c5d84a2 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,5 +1,5 @@ DOMAIN=classding.example.com -CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.3.5 +CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.4.0 # openssl rand -hex 32 JWT_SECRET= # 仅首次部署设置;创建成功后清空用户名/密码并重新创建容器 diff --git a/deploy/compose.1panel.yaml b/deploy/compose.1panel.yaml index 669e3b0..ffcf119 100644 --- a/deploy/compose.1panel.yaml +++ b/deploy/compose.1panel.yaml @@ -1,7 +1,7 @@ # 粘贴到 1Panel 的编排编辑器。首次部署请填写两项密码;升级保留原值和原编排名称。 services: classding: - image: ghcr.io/alumos/classding-server:0.3.5 + image: ghcr.io/alumos/classding-server:0.4.0 platform: linux/amd64 restart: unless-stopped ports: diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 1269a89..c385cb2 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,6 +1,6 @@ services: server: - image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.3.5} + image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.4.0} restart: unless-stopped environment: DATA_DIR: /data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 385b817..f50bb40 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ ## 主服务(`server/internal/app`) -整个数据库以 `State` 的形式常驻内存,由 `App.mu` 保护;每次成功的写操作后,`repo.Store` 只把变化的行写回 SQLite。 +整个数据库以 `State` 的形式常驻内存,由 `App.mu` 保护;每次成功的写操作后,`State.rows()` 逐行编码,`repo.Store` 只把变化的行写回 SQLite。命令失败或保存失败时,内存状态由 `repo.Store.Restore` 从最近一次提交还原,成功路径不再整份拍快照。 | 文件 | 职责 | | --- | --- | @@ -27,10 +27,12 @@ | `routes.go` | 全部路由,一行一个接口,标明访问级别与读写 | | `app.go` | 启动、旧数据升级、限流、审计、会话撤销 | | `auth.go`、`security.go`、`validate.go` | 登录与刷新会话、令牌与密码哈希、输入校验 | +| `passwords.go` | Argon2id 在取得 `App.mu` 之前计算(并发上限受控),登录、注册、改密不阻塞其他请求;不存在的账号也做同样耗时的校验 | +| `state.go`、`cursor.go` | 按表逐行编码状态;分页游标(创建时间 + ID,同一时刻的记录不漏不重) | | `classes.go`、`devices.go`、`messages.go`、`homework.go`、`student_calls.go`、`audio.go`、`tools.go` | 各业务领域 | | `admin.go`、`settings.go`、`backup.go` | 管理后台、站点设置、备份与恢复 | | `realtime.go`、`hub.go` | WebSocket 协议与连接池 | -| `maintenance.go` | 定时清理过期会话、消息和录音 | +| `maintenance.go` | 定时清理:过期会话;消息与学生呼叫保留 90 天、审计 180 天、录音 7 天;已用或过期的协作邀请 | | `migration.go` | 0.3.0 之前的大屏迁移核对接口 | 几条约定: @@ -49,9 +51,12 @@ | `storage.go` | 以 Windows DPAPI 加密保存前端状态 | | `displays.go`、`volume.go`、`network.go` | 显示器选择、播报时的系统音量、内网 IPv4 | | `homework*.go` | 桌面作业卡片(Win32 GDI 绘制) | +| `student_call*.go` | 桌面「呼叫老师」悬浮球:普通窗口层级(不置顶,PPT 放映或最大化窗口会盖住它),点击后才提到前台展开二次确认 | | `frontend/src/service.ts` | 连接、离线队列、播报、服务器切换与回退的状态机 | -`service.ts` 中所有状态修改都经 `mutate()` 串行执行并立即持久化,失败时整体回滚。每次重连使 `generation` 加一,旧连接上迟到的消息会被丢弃。 +`service.ts` 中所有状态修改都经 `mutate()` 串行执行并立即持久化,失败时整体回滚。每次重连使 `generation` 加一,旧连接上迟到的消息会被丢弃。连接 10 秒内未收到欢迎帧即放弃重试;系统报告网络恢复(`online` 事件)时立即重连,不等退避。 + +教师网页的实时连接每 25 秒心跳时附带当前访问令牌,令牌临近过期先刷新,服务端据此续期,连接不再每 15 分钟断开一次;重连后同时刷新作业、消息、班级、设备和学生呼叫。 ## 网页端 diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index df28ae4..199516e 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -14,7 +14,7 @@ - AuthResponse: {access_token,user};refresh token 放 HttpOnly Cookie。 ## REST -GET config => {speech_enabled:false,registration_enabled:true,version:'0.3.5'} +GET config => {speech_enabled:false,registration_enabled:true,version:'0.4.0'} POST auth/register {username,password,display_name} => AuthResponse POST auth/login {username,password} => AuthResponse POST auth/refresh => AuthResponse;POST auth/logout => {ok:true} @@ -33,7 +33,7 @@ GET device/me => {device,class:{id,name},volume,sequence,homeworks,homework_revi GET device/homeworks => {items:Homework[],homework_revision},设备 Bearer 令牌认证。 GET homeworks?class_id= => {items:Homework[]};POST homeworks {class_id,subject,text,visible,always_on_top} => Homework;PATCH homeworks/:id {subject?,text?,visible?,always_on_top?} => Homework;DELETE homeworks/:id => {ok:true}。 POST messages {class_ids,kind,text,student_name,signature,style,display_sec,repeat,chime,play_mode,audio_id?} => Message;repeat 为朗读或播放遍数:文字 0–3(0 只显示不朗读),原声 1–3 -GET messages?class_id=&cursor= => {items:Message[],next_cursor:string|null} +GET messages?class_id=&cursor= => {items:Message[],next_cursor:string|null}(cursor 原样传回上一页的 next_cursor,按创建时间 + ID 倒序,同一时刻不漏不重) GET messages/:id => Message;POST messages/:id/recall => Message;POST messages/:id/resend => Message GET phrases => Phrase[];POST phrases {text} => Phrase;PATCH phrases/:id {text} => Phrase;DELETE => {ok:true} POST audio multipart file => {audio_id,url,transcript?} diff --git a/docs/LOCAL_TESTING.md b/docs/LOCAL_TESTING.md index 4101db5..24394dd 100644 --- a/docs/LOCAL_TESTING.md +++ b/docs/LOCAL_TESTING.md @@ -30,7 +30,7 @@ 2. 两位教师在线打开工作台时均应看到「学生呼叫」红色未读数量和提醒;进入页面不消除未读。任一教师手动标记已读后,两端同步消除该条未读,记录保留处理老师和时间。 3. 另一教师关闭页面期间发起呼叫,重新登录后仍可查看未读;重启服务、刷新网页后记录和已读状态保留。未加入班级的账号不得读取或处理该班记录。 4. 30 秒内新呼叫应提示稍后再试;断网不得显示发送成功,重试同一请求不产生重复记录。解绑后悬浮入口不可发送。 -5. 在真实 Windows 上检查主窗口隐藏/托盘驻留、悬浮球拖动、多屏工作区与 DPI 缩放、确认/取消、发送中防重复、全屏通知期间隐藏及恢复。浏览器兜底入口和交叉编译不能替代这项实机验收。 +5. 在真实 Windows 上检查主窗口隐藏/托盘驻留、悬浮球不置顶(PPT 放映时被覆盖,回到桌面可见)、悬浮球拖动、多屏工作区与 DPI 缩放、确认/取消、发送中防重复、全屏通知期间隐藏及恢复。浏览器兜底入口和交叉编译不能替代这项实机验收。 6. 手机 320/390px、桌面及 150% 字号检查六项导航和未读徽标,不应溢出或遮挡操作。 ## 已自动验证与待验证 diff --git a/docs/releases/v0.4.0.md b/docs/releases/v0.4.0.md new file mode 100644 index 0000000..9065121 --- /dev/null +++ b/docs/releases/v0.4.0.md @@ -0,0 +1,39 @@ +# 课堂叮咚 v0.4.0 + +一次以稳定性和体验为主的大版本:桌面呼叫悬浮球不再挡住 PPT,大屏界面重新对齐并放大字号,教师端实时连接更稳,服务端更省资源。 + +## 大屏客户端 + +- **悬浮球不再始终置顶**:「呼叫老师」悬浮球处于普通窗口层级。PPT 放映、最大化的窗口会正常盖住它;回到桌面或在普通窗口旁仍可看到和点击。只有点击后展开的确认面板会提到最前。 +- **更大更醒目**:悬浮球加大到 112×92(按缩放比例放大),橙色底、深橙描边、白色粗体大字「呼叫 / 老师」并居中;确认面板、标题、正文和按钮都同步加大。 +- **界面对齐**:页眉、内容和页脚使用同一页宽,左右边缘对齐;「呼叫老师」卡片补齐样式并与悬浮球同色;正文、设置项、按钮字号整体加大;修复绑定页铃铛装饰压住步骤文字、卡片小标题上方多出横线的问题。 +- **断网恢复更快**:连接 10 秒无响应会放弃并重试,不再长时间停在「正在连接」;Windows 报告网络恢复时立即重连。 + +## 教师与管理端 + +- 实时连接在心跳中续期登录令牌,不再每 15 分钟断开一次;重连后同时刷新作业、发送记录、班级、设备和学生呼叫。 +- 发送记录分页改为「时间 + ID」,同一时刻发出的多条通知不会被漏掉。 +- 撤回通知时,已经「失败」或「过期」的投递保留原状态。 + +## 服务端 + +- 密码哈希在全局锁外计算并限制并发,登录高峰不再拖慢大屏回执和其他请求;不存在的账号与密码错误耗时一致。 +- 学生呼叫保留 90 天、审计日志保留 180 天,已用或过期的协作邀请自动清理,数据库不再无限增长。 +- 写入按行编码、失败时从上次提交还原,每次写操作少一次整库序列化。 +- 解绑屏幕时,未完成的投递立即标记为失败;教师建班时校验年级长度(最多 40 字);发送过快的提示显示实际冷却秒数。 + +## 升级 + +Linux amd64 镜像:`ghcr.io/alumos/classding-server:0.4.0`。保留现有编排和数据卷,仅更新镜像后重建。旧数据库和旧版备份可直接使用;首次运行的定时清理会删除超出保留期的学生呼叫、审计记录和失效邀请。 + +Windows amd64 安装包:`ClassDingScreen-amd64-installer.exe`。悬浮球与界面调整需要**大屏升级到 v0.4.0**。 + +## 不兼容变更 + +- 移除了 0.1.3 之前共用的刷新 Cookie(`classding_refresh`)。只有从 0.1.2 及更早版本直接升级、且浏览器仍持有旧 Cookie 的用户需要重新登录一次。 +- 发送记录的分页游标改为不透明字符串;直接调用接口的脚本应原样传回 `next_cursor`,不要自行构造时间戳。 + +## 已知限制 + +- 悬浮球层级、字号与界面对齐需在真实 Windows 设备(不同缩放比例、多显示器、PPT 放映)上验收。 +- 学生呼叫仍只在教师网页打开时提醒,不使用 Web Push。 diff --git a/package.json b/package.json index 0d0f050..f263fd1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "classding", "private": true, - "version": "0.3.5", + "version": "0.4.0", "type": "module", "packageManager": "pnpm@11.25.0", "engines": { diff --git a/screen/README.md b/screen/README.md index 60bba12..6fa2b2f 100644 --- a/screen/README.md +++ b/screen/README.md @@ -52,7 +52,7 @@ wails build -platform windows/amd64 -nsis -installscope user ## 学生呼叫老师 -绑定后 Windows 桌面左侧会显示「呼叫老师」原生悬浮入口;主窗口关闭到托盘后仍可使用,不依赖网页中的浮层。拖动可移到其他显示器;松手停在最近显示器工作区内,避开任务栏,按显示器 DPI 调整大小。悬浮入口平时不抢焦点,只有点击展开二次确认时才激活;取消或 Escape 不发送,默认键盘焦点在取消按钮,Tab 可切换到确认按钮。全屏通知、课堂工具展示期间临时隐藏,结束后恢复,不移动或改变已有作业窗。 +绑定后 Windows 桌面左侧会显示橙色「呼叫老师」原生悬浮球(白色粗体大字)。它处在普通窗口层级,不会一直置顶:PPT 放映、最大化窗口会正常盖住它,回到桌面或其他窗口旁边时仍可见;主窗口关闭到托盘后仍可使用,不依赖网页中的浮层。拖动可移到其他显示器;松手停在最近显示器工作区内,避开任务栏,按显示器 DPI 调整大小。悬浮入口平时不抢焦点,只有点击展开二次确认时才激活;取消或 Escape 不发送,默认键盘焦点在取消按钮,Tab 可切换到确认按钮。全屏通知、课堂工具展示期间临时隐藏,结束后恢复,不移动或改变已有作业窗。 确认后由原有 WebView service 复用设备认证和 loopback 代理发送 `POST /api/v1/device/student-calls`,Go 原生层不保存另一份密钥。发送中禁止重复提交;成功须收到有效的服务器记录才显示「呼叫已发送」。本班所有关联老师在打开的教师网页收到提醒,任一老师标记已读后共享为已处理。服务端按同班 30 秒限流,客户端显示 `Retry-After` 等待时间。网络异常或响应丢失会显示结果未确认,用户主动重试沿用同一个幂等编号;不会离线排队或自动补发,退出程序后不保留未确认编号。 diff --git a/screen/frontend/package.json b/screen/frontend/package.json index 9d0af0f..7af1e76 100644 --- a/screen/frontend/package.json +++ b/screen/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@classding/screen", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/screen/frontend/src/main.tsx b/screen/frontend/src/main.tsx index 50ebe59..c8f0705 100644 --- a/screen/frontend/src/main.tsx +++ b/screen/frontend/src/main.tsx @@ -250,7 +250,7 @@ function App() {
- READY TO CONNECT + READY TO CONNECT

绑定到班级

打开教师端「班级」,获取 6 位绑定码

@@ -373,16 +373,25 @@ function App() {
-

需要老师来教室?

-

向本班所有关联老师发送呼叫。Windows 关闭主窗口后仍可使用桌面悬浮入口。

+ + + +
+

需要老师来教室?

+

向本班所有关联老师发送呼叫。关闭主窗口后,桌面上的橙色悬浮球仍可呼叫。

+ {state.studentCallMessage && ( +

+ {state.studentCallMessage} +

+ )} +
- {state.studentCallMessage &&

{state.studentCallMessage}

}
diff --git a/screen/frontend/src/service.ts b/screen/frontend/src/service.ts index 173d1fe..2954081 100644 --- a/screen/frontend/src/service.ts +++ b/screen/frontend/src/service.ts @@ -28,6 +28,8 @@ const LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const REQUEST_TIMEOUT_MS = 5000; const HEARTBEAT_MS = 25000; const SILENCE_MS = 60000; +// A socket that neither opens nor receives its welcome in time is abandoned and retried. +const CONNECT_TIMEOUT_MS = 10000; const MAX_PENDING = 4096; const CLIENT_OS = navigator.userAgent.includes('Windows') ? 'windows' : 'browser'; interface StoredState { @@ -205,6 +207,13 @@ export class ScreenService { private bindingEpoch = 0; private callController?: AbortController; private callID = ''; + private connectTimer?: ReturnType; + // Reconnect at once when Windows reports the network is back, instead of waiting out the backoff. + private readonly onNetworkBack = () => { + if (this.view.connection === 'online' || !this.state.token || this.disposed) return; + this.attempts = 0; + this.connect(); + }; private callOff?: () => void; private callNativeQueue: Promise = Promise.resolve(); private callNativeKey = ''; @@ -268,6 +277,7 @@ export class ScreenService { } } async init() { + window.addEventListener?.('online', this.onNetworkBack); this.callOff = window.runtime?.EventsOnMultiple( 'student-call:send', (id, binding) => { @@ -386,6 +396,7 @@ export class ScreenService { private connect() { if (!this.state.token || this.disposed) return; clearTimeout(this.retry); + clearTimeout(this.connectTimer); clearInterval(this.heartbeat); const generation = ++this.generation; this.validated.clear(); @@ -401,6 +412,9 @@ export class ScreenService { this.emit({ connection: 'connecting' }); try { const ws = (this.socket = new WebSocket(url)); + this.connectTimer = setTimeout(() => { + if (generation === this.generation && this.view.connection !== 'online') ws.close(); + }, CONNECT_TIMEOUT_MS); ws.onopen = () => { if (generation !== this.generation) return; this.lastFrame = Date.now(); @@ -426,6 +440,7 @@ export class ScreenService { }; ws.onclose = () => { if (generation !== this.generation) return; + clearTimeout(this.connectTimer); clearInterval(this.heartbeat); this.emit({ connection: 'offline' }); void this.verifyAfterClose(generation); @@ -1062,6 +1077,8 @@ export class ScreenService { } dispose() { this.disposed = true; + window.removeEventListener?.('online', this.onNetworkBack); + clearTimeout(this.connectTimer); this.callOff?.(); this.callOff = undefined; this.halt(); diff --git a/screen/frontend/src/styles.css b/screen/frontend/src/styles.css index 782f975..94a260f 100644 --- a/screen/frontend/src/styles.css +++ b/screen/frontend/src/styles.css @@ -22,6 +22,12 @@ body { min-width: 320px; } +/* One page width for header, content and footer, so their edges line up. */ +:root { + --cd-gutter: clamp(20px, 4vw, 56px); + --cd-page: min(1120px, calc(100vw - 2 * var(--cd-gutter))); + --cd-page-inset: max(var(--cd-gutter), calc((100% - var(--cd-page)) / 2)); +} .shell { min-height: 100vh; display: flex; @@ -33,7 +39,7 @@ body { display: flex; justify-content: space-between; align-items: center; - padding: 0 clamp(28px, 5vw, 84px); + padding: 0 var(--cd-page-inset); border-bottom: 1px solid #e8eee5; background: #fbfcf9; } @@ -68,7 +74,7 @@ body { display: flex; justify-content: space-between; gap: 20px; - padding: 25px clamp(28px, 5vw, 84px); + padding: 25px var(--cd-page-inset); color: #a2afa1; font-size: 11px; } @@ -76,11 +82,11 @@ body { color: #82ac76; } .bind-layout { - width: min(1180px, calc(100% - 80px)); + width: var(--cd-page); margin: auto; display: grid; - grid-template-columns: 1fr 490px; - gap: clamp(55px, 8vw, 130px); + grid-template-columns: minmax(0, 1fr) 490px; + gap: clamp(40px, 6vw, 110px); align-items: center; padding: 60px 0; } @@ -122,8 +128,8 @@ body { .bind-intro > p, .dashboard-heading p { margin-top: 22px; - max-width: 390px; - font-size: 14px; + max-width: 460px; + font-size: 16px; line-height: 1.9; color: #87988a; } @@ -132,7 +138,7 @@ body { gap: 20px; margin-top: 49px; color: #6d8072; - font-size: 13px; + font-size: 15px; } .intro-steps div { display: flex; @@ -146,15 +152,21 @@ body { height: 30px; border: 1px solid #dce9d9; border-radius: 50%; + flex-shrink: 0; color: #88aa87; - font-size: 9px; + font-size: 11px; font-weight: 700; } +/* Sits to the right of the steps, behind them, never over their text. */ +.intro-steps { + position: relative; + z-index: 1; +} .intro-decoration { position: absolute; z-index: 0; - bottom: -118px; - left: 110px; + right: 0; + bottom: -40px; transform: rotate(-18deg); color: #e8f1e3; pointer-events: none; @@ -179,9 +191,9 @@ body { .card-heading { margin: 25px 0 27px; } -.overline, +.card-overline, .section-label { - font-size: 10px; + font-size: 11px; letter-spacing: 1.7px; color: #9bad9d; font-weight: 700; @@ -192,14 +204,14 @@ body { color: #2d4939; } .card-heading p { - font-size: 12px; + font-size: 14px; color: #99a79a; margin-top: 9px; } .input-label { display: block; margin-bottom: 10px; - font-size: 12px; + font-size: 14px; font-weight: 650; color: #536958; } @@ -247,7 +259,7 @@ body { gap: 7px; color: #87988b; text-align: left; - font-size: 12px; + font-size: 14px; } .advanced-toggle svg:last-child { margin-left: auto; @@ -288,9 +300,9 @@ body { } .bind-button { width: 100%; - height: 49px; + height: 52px; justify-content: center; - font-size: 13px; + font-size: 16px; } .binding-foot { display: flex; @@ -311,7 +323,7 @@ body { font-size: 12px; } .dashboard { - width: min(1050px, calc(100% - 80px)); + width: var(--cd-page); margin: auto; padding: 56px 0; } @@ -334,8 +346,9 @@ body { } .dashboard-grid { display: grid; - grid-template-columns: 1fr 1.15fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 20px; + align-items: stretch; } .class-card, .ready-card, @@ -345,7 +358,7 @@ body { } .class-card { padding: 30px; - min-height: 320px; + min-height: 0; display: flex; flex-direction: column; } @@ -375,7 +388,7 @@ body { color: #2a4939; } .class-card > p { - font-size: 12px; + font-size: 14px; color: #9ba99b; margin-top: 7px; } @@ -398,17 +411,71 @@ body { .class-summary span { display: grid; gap: 4px; - font-size: 11px; + font-size: 13px; color: #9ba99b; } .class-summary strong { color: #536c59; - font-size: 12px; + font-size: 15px; } .dashboard-side { - display: grid; + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} +/* The call card uses the same orange as the desktop floating ball. */ +.student-call-card { + display: flex; + align-items: center; gap: 20px; - grid-template-rows: 1fr 1fr; + padding: 22px 26px; + border-color: #fed7aa; + background: linear-gradient(135deg, #fff7ed, #ffffff 70%); + box-shadow: 0 15px 50px #c2410c0d; +} +.student-call-card > div { + flex: 1; + min-width: 0; +} +.call-icon { + width: 56px; + height: 56px; + flex-shrink: 0; + display: grid; + place-items: center; + border-radius: 18px; + background: #f97316; + color: #fff; + box-shadow: 0 8px 20px #f9731633; +} +.student-call-card h3 { + font-size: 18px; + color: #9a3412; +} +.student-call-card p { + margin-top: 6px; + font-size: 13px; + line-height: 1.6; + color: #8a7466; +} +.student-call-card .call-status { + color: #c2410c; + font-weight: 600; +} +.student-call-card .call-button { + flex-shrink: 0; + min-height: 46px; + padding: 0 22px; + font-size: 16px; + font-weight: 700; + background: #f97316; + border-color: #f97316; + color: #fff; +} +.student-call-card .call-button:hover:not(:disabled) { + background: #ea580c; + border-color: #ea580c; } .ready-card { display: flex; @@ -447,20 +514,20 @@ body { inset: 0; } .ready-card h3 { - font-size: 16px; + font-size: 18px; color: #3a5b43; } .ready-card p, .setting-row p { color: #9aa99a; - font-size: 12px; + font-size: 13px; line-height: 1.6; margin-top: 6px; } .ready-card .cd-button { margin-top: 13px; - min-height: 36px; - font-size: 11px; + min-height: 40px; + font-size: 14px; } .settings-card { padding: 4px 24px; @@ -483,12 +550,12 @@ body { flex: 1; } .setting-row strong { - font-size: 12px; + font-size: 15px; color: #58705c; } .setting-row p { margin-top: 3px; - font-size: 11px; + font-size: 13px; } .muted-check { color: #91b094; @@ -502,7 +569,7 @@ body { gap: 20px; margin-top: 24px; color: #a4b1a2; - font-size: 11px; + font-size: 13px; } .dashboard-note span:first-child { display: flex; @@ -628,8 +695,8 @@ body { .intro-decoration { display: none; } - .dashboard { - width: calc(100% - 40px); + .student-call-card { + flex-wrap: wrap; } .dashboard-grid { grid-template-columns: 1fr; @@ -641,7 +708,6 @@ body { @media (max-width: 550px) { .topbar { height: 72px; - padding: 0 20px; } .desktop-label { display: none; diff --git a/screen/frontend/tests/service.test.ts b/screen/frontend/tests/service.test.ts index 69e6037..dbf28a6 100644 --- a/screen/frontend/tests/service.test.ts +++ b/screen/frontend/tests/service.test.ts @@ -556,6 +556,63 @@ describe('大屏消息可靠性', () => { }); }); +describe('网络恢复', () => { + const bound = () => + JSON.stringify({ + version: 1, + token: 'device-token', + deviceId: 'd1', + classId: 'c1', + deviceName: '教室', + className: '三年二班', + volume: 70, + lastSeq: 0, + queue: [], + ledger: {}, + }); + it('连接迟迟无响应时放弃并重试,不会一直卡在“正在连接”', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 503 }) as Response), + ); + mocks.disk = bound(); + const service = makeService(); + await service.init(); + const stalled = socket(); + await vi.advanceTimersByTimeAsync(9999); + expect(stalled.readyState).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(stalled.readyState).toBe(3); + expect(service.getSnapshot().connection).toBe('offline'); + }); + it('系统报告网络恢复后立即重连,不等待退避', async () => { + const listeners: Record void> = {}; + vi.stubGlobal('window', { + location: { origin: 'http://localhost:5175', href: 'http://localhost:5175/' }, + addEventListener: (type: string, listener: () => void) => (listeners[type] = listener), + removeEventListener: (type: string) => delete listeners[type], + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 503 }) as Response), + ); + mocks.disk = bound(); + const service = makeService(); + await service.init(); + socket().close(); + const before = Socket.instances.length; + listeners.online(); + expect(Socket.instances.length).toBe(before + 1); + socket().frame(welcome); + await vi.waitFor(() => expect(service.getSnapshot().connection).toBe('online')); + listeners.online(); + expect(Socket.instances.length).toBe(before + 1); + service.dispose(); + expect(listeners.online).toBeUndefined(); + }); +}); + describe('切换服务器', () => { const plan = { server_url: 'https://new.example', revision: 'r1' }; const saved = () => ({ diff --git a/screen/student_call.go b/screen/student_call.go index 113469d..f7a1ab1 100644 --- a/screen/student_call.go +++ b/screen/student_call.go @@ -171,6 +171,31 @@ func studentCallAcknowledged(previous, next studentCallView, sentRevision int) b (next.Revision > sentRevision && (next.Sending || next.Message != "")) } +// Sizes are in 96-DPI pixels. The ball is large enough to read from a desk and +// to hit on a touch board; the confirmation leaves room for two big buttons. +const ( + studentCallBallWidth, studentCallBallHeight = 112, 92 + studentCallPanelWidth, studentCallPanelHeight = 480, 280 + studentCallBallFont, studentCallTitleFont = 26, 28 + studentCallBodyFont, studentCallButtonFont = 21, 22 + studentCallButtonHeight, studentCallMargin = 54, 24 +) + +// The ball is a vivid orange so students find it at a glance; text is white. +var ( + studentCallBallFill = [3]uint8{249, 115, 22} + studentCallBallEdge = [3]uint8{194, 65, 12} + studentCallAccent = [3]uint8{194, 65, 12} +) + +// studentCallButtons lays out the cancel and confirm buttons side by side along +// the bottom of the panel, with equal outer margins and one gap between them. +func studentCallButtons(width, height, margin, gap, buttonHeight int) [2][4]int { + buttonWidth := max(1, (width-2*margin-gap)/2) + top := height - margin - buttonHeight + return [2][4]int{{margin, top, buttonWidth, buttonHeight}, {width - margin - buttonWidth, top, buttonWidth, buttonHeight}} +} + // Geometry is in physical pixels, including negative monitor origins. func studentCallClamp(x, y, width, height, left, top, right, bottom int) (int, int) { return max(left, min(x, right-width)), max(top, min(y, bottom-height)) diff --git a/screen/student_call_test.go b/screen/student_call_test.go index 0604c12..cb2f41a 100644 --- a/screen/student_call_test.go +++ b/screen/student_call_test.go @@ -129,3 +129,23 @@ func TestStudentCallPhysicalGeometry(t *testing.T) { t.Fatal("DPI drag threshold") } } + +func TestStudentCallButtonsAlignToPanelMargins(t *testing.T) { + for _, dpi := range []int{96, 144, 192} { + px := func(v int) int { return v * dpi / 96 } + width, height, margin, gap := px(studentCallPanelWidth), px(studentCallPanelHeight), px(studentCallMargin), px(12) + b := studentCallButtons(width, height, margin, gap, px(studentCallButtonHeight)) + if b[0][0] != margin || b[1][0]+b[1][2] != width-margin { + t.Fatalf("dpi %d: buttons not inside equal margins: %v", dpi, b) + } + if b[0][2] != b[1][2] || b[0][1] != b[1][1] || b[0][1]+b[0][3] != height-margin { + t.Fatalf("dpi %d: buttons not aligned: %v", dpi, b) + } + if gotGap := b[1][0] - (b[0][0] + b[0][2]); gotGap < gap || gotGap > gap+1 { + t.Fatalf("dpi %d: gap %d, want %d", dpi, gotGap, gap) + } + } + if studentCallBallFont < 24 || studentCallBallWidth < 100 { + t.Fatal("ball text must stay large enough to read from a desk") + } +} diff --git a/screen/student_call_windows.go b/screen/student_call_windows.go index eaf19a9..4903e99 100644 --- a/screen/student_call_windows.go +++ b/screen/student_call_windows.go @@ -157,9 +157,9 @@ func (m *nativeStudentCallWindow) place() { m.dpi = int(dpi) } } - m.width, m.height = m.px(84), m.px(68) + m.width, m.height = m.px(studentCallBallWidth), m.px(studentCallBallHeight) if m.expanded { - m.width, m.height = m.px(420), m.px(240) + m.width, m.height = m.px(studentCallPanelWidth), m.px(studentCallPanelHeight) } monitor, _, _ := user32.NewProc("MonitorFromWindow").Call(m.hwnd, 2) info := monitorInfo{size: uint32(unsafe.Sizeof(monitorInfo{}))} @@ -174,10 +174,14 @@ func (m *nativeStudentCallWindow) place() { m.width = min(m.width, int(w.right-w.left)) m.height = min(m.height, int(w.bottom-w.top)) m.x, m.y = studentCallClamp(m.x, m.y, m.width, m.height, int(w.left), int(w.top), int(w.right), int(w.bottom)) - setWindowPos.Call(m.hwnd, ^uintptr(0), uintptr(m.x), uintptr(m.y), uintptr(m.width), uintptr(m.height), 0x0010) // TOPMOST / NOACTIVATE - radius := m.px(24) + // The ball lives in the normal window layer: it sits on the desktop and + // stays visible beside ordinary windows, but a PowerPoint slide show or any + // maximised window covers it. NOZORDER keeps the periodic re-layout from + // ever raising it; only a click on the ball brings the confirmation forward. + setWindowPos.Call(m.hwnd, 0, uintptr(m.x), uintptr(m.y), uintptr(m.width), uintptr(m.height), 0x0004|0x0010) // NOZORDER / NOACTIVATE + radius := m.height / 2 if m.expanded { - radius = m.px(12) + radius = m.px(16) } region, _, _ := hwRoundRegion.Call(0, 0, uintptr(m.width+1), uintptr(m.height+1), uintptr(radius*2), uintptr(radius*2)) if region != 0 { @@ -186,7 +190,7 @@ func (m *nativeStudentCallWindow) place() { } } if m.fontDPI != m.dpi { - if font := hwFont(m.px(18), 500); font != 0 { + if font := hwFont(m.px(studentCallButtonFont), 600); font != 0 { previous := m.buttonFont m.buttonFont, m.fontDPI = font, m.dpi for _, button := range []uintptr{m.cancel, m.confirm} { @@ -197,9 +201,10 @@ func (m *nativeStudentCallWindow) place() { } } } + layout := studentCallButtons(m.width, m.height, m.px(studentCallMargin), m.px(12), m.px(studentCallButtonHeight)) for i, button := range []uintptr{m.cancel, m.confirm} { - buttonWidth := max(1, (m.width-m.px(48))/2) - setWindowPos.Call(button, 0, uintptr(m.px(20)+i*(buttonWidth+m.px(8))), uintptr(m.height-m.px(64)), uintptr(buttonWidth), uintptr(m.px(44)), 0x0004|0x0010) + b := layout[i] + setWindowPos.Call(button, 0, uintptr(b[0]), uintptr(b[1]), uintptr(b[2]), uintptr(b[3]), 0x0004|0x0010) show := uintptr(0) if m.expanded { show = 4 @@ -225,6 +230,7 @@ func (m *nativeStudentCallWindow) expand() { // Only this explicit user action activates the confirmation. Idle ball never steals focus. user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), 0x80) m.place() + setWindowPos.Call(m.hwnd, 0, 0, 0, 0, 0, 0x0001|0x0002) // HWND_TOP, keep size and position setForegroundWindow.Call(m.hwnd) user32.NewProc("SetFocus").Call(m.cancel) // Cancel is the safe default. } @@ -352,29 +358,57 @@ func (m *nativeStudentCallWindow) paint() { } defer hwEndPaint.Call(m.hwnd, uintptr(unsafe.Pointer(&paint))) full := displayRect{0, 0, int32(m.width), int32(m.height)} - fill := [3]uint8{243, 249, 245} - ink := [3]uint8{25, 94, 67} - brush, _, _ := hwCreateBrush.Call(hwRGB(fill)) - hwFillRect.Call(dc, uintptr(unsafe.Pointer(&full)), brush) - hwDeleteObject.Call(brush) hwSetBkMode.Call(dc, 1) - hwSetTextColor.Call(dc, hwRGB(ink)) - font := hwFont(m.px(18), 600) - old, _, _ := hwSelectObject.Call(dc, font) - defer func() { hwSelectObject.Call(dc, old); hwDeleteObject.Call(font) }() if !m.expanded { - hwText(dc, "呼叫\n老师", &full, 0x0001|0x0010) + m.paintBall(dc, full) return } - title := displayRect{int32(m.px(20)), int32(m.px(16)), int32(m.width - m.px(20)), int32(m.px(48))} - hwText(dc, "呼叫老师", &title, 0x0020) - body := displayRect{title.left, int32(m.px(58)), title.right, int32(m.height - m.px(72))} - text := m.view.ClassName + ":确定呼叫本班所有关联老师吗?\n取消不会发送。" + background := [3]uint8{255, 250, 245} + brush, _, _ := hwCreateBrush.Call(hwRGB(background)) + hwFillRect.Call(dc, uintptr(unsafe.Pointer(&full)), brush) + hwDeleteObject.Call(brush) + // An orange band on top ties the panel to the ball that opened it. + band := displayRect{0, 0, int32(m.width), int32(m.px(8))} + hwFillRound(dc, band, 0, studentCallBallFill) + + margin := m.px(studentCallMargin) + title := displayRect{int32(margin), int32(m.px(24)), int32(m.width - margin), int32(m.px(24 + 40))} + titleFont := hwFont(m.px(studentCallTitleFont), 700) + old, _, _ := hwSelectObject.Call(dc, titleFont) + hwSetTextColor.Call(dc, hwRGB(studentCallAccent)) + hwText(dc, "呼叫老师", &title, 0x0020|0x0004) // SINGLELINE / VCENTER + bodyFont := hwFont(m.px(studentCallBodyFont), 500) + hwSelectObject.Call(dc, bodyFont) + hwDeleteObject.Call(titleFont) + defer func() { hwSelectObject.Call(dc, old); hwDeleteObject.Call(bodyFont) }() + hwSetTextColor.Call(dc, hwRGB([3]uint8{41, 37, 36})) + body := displayRect{title.left, title.bottom + int32(m.px(12)), title.right, int32(m.height - margin - m.px(studentCallButtonHeight) - m.px(12))} + text := m.view.ClassName + ":确定呼叫本班所有关联老师吗?\n点“取消”不会发送。" if !m.view.Online { text = "连接中断,呼叫未发送。请恢复网络后重试。" } if m.view.Message != "" { text = m.view.Message } - hwText(dc, text, &body, 0x0010) + hwText(dc, text, &body, 0x0010) // WORDBREAK +} + +// paintBall draws the collapsed ball: an orange pill with a darker rim and two +// centred lines of bold white text. +func (m *nativeStudentCallWindow) paintBall(dc uintptr, full displayRect) { + radius := int(full.bottom) / 2 + hwFillRound(dc, full, radius, studentCallBallEdge) + rim := int32(max(2, m.px(3))) + inner := displayRect{full.left + rim, full.top + rim, full.right - rim, full.bottom - rim} + hwFillRound(dc, inner, radius-int(rim), studentCallBallFill) + font := hwFont(m.px(studentCallBallFont), 700) + old, _, _ := hwSelectObject.Call(dc, font) + defer func() { hwSelectObject.Call(dc, old); hwDeleteObject.Call(font) }() + hwSetTextColor.Call(dc, hwRGB([3]uint8{255, 255, 255})) + line := int32(m.px(studentCallBallFont) * 5 / 4) + top := (full.bottom - 2*line) / 2 + for i, label := range []string{"呼叫", "老师"} { + row := displayRect{full.left, top + int32(i)*line, full.right, top + int32(i+1)*line} + hwText(dc, label, &row, 0x0001|0x0020|0x0004) // CENTER / SINGLELINE / VCENTER + } } diff --git a/screen/wails.json b/screen/wails.json index 64ff2b0..04fd593 100644 --- a/screen/wails.json +++ b/screen/wails.json @@ -11,7 +11,7 @@ "info": { "companyName": "ClassDing", "productName": "课堂叮咚大屏端", - "productVersion": "0.3.5", + "productVersion": "0.4.0", "copyright": "ClassDing" } } diff --git a/scripts/api-generator/package.json b/scripts/api-generator/package.json index 9bd6a24..207d1ae 100644 --- a/scripts/api-generator/package.json +++ b/scripts/api-generator/package.json @@ -1,6 +1,6 @@ { "name": "@classding/api-generator", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/generate-contract.mjs b/scripts/generate-contract.mjs index f0222c3..a5fe7f7 100644 --- a/scripts/generate-contract.mjs +++ b/scripts/generate-contract.mjs @@ -541,7 +541,8 @@ const frames = { [], ), unbind: obj({}), - ping: obj({}), + // Teachers may hand over a refreshed access token to keep their socket open. + ping: obj({ token: str }, []), pong: obj({ server_time: num }), receipt: obj({ message_id: str, class_id: str, device_id: str, status: str, at: timestamp }), presence: obj({ class_id: str, device_id: str, online: bool }), diff --git a/server/internal/app/admin.go b/server/internal/app/admin.go index 916af52..5867c90 100644 --- a/server/internal/app/admin.go +++ b/server/internal/app/admin.go @@ -65,7 +65,7 @@ func (a *App) adminCreateUser(_ http.ResponseWriter, r *http.Request, u *User) ( if _, taken := a.userByUsername(username); taken { return nil, usernameTaken() } - teacher := User{ID: id("u"), Username: username, DisplayName: name, Role: RoleTeacher, OrgID: orgID, Status: AccountActive, PasswordHash: passwordHash(in.Password)} + teacher := User{ID: id("u"), Username: username, DisplayName: name, Role: RoleTeacher, OrgID: orgID, Status: AccountActive, PasswordHash: freshHash(r, in.Password)} a.state.Users[teacher.ID] = teacher a.audit(u, "admin.user.create", teacher.ID) return teacher.Public(), nil @@ -139,7 +139,7 @@ func (a *App) adminUpdateUser(_ http.ResponseWriter, r *http.Request, u *User) ( if !validPassword(*in.Password) { return nil, bad("密码至少 10 位,且包含字母和数字。") } - teacher.PasswordHash = passwordHash(*in.Password) + teacher.PasswordHash = freshHash(r, *in.Password) revoke = true } if revoke { @@ -545,7 +545,7 @@ func (a *App) recallClass(classID string) { func (a *App) adminMessages(_ http.ResponseWriter, r *http.Request, u *User) (any, error) { a.audit(u, "admin.message.read", r.URL.Query().Get("class_id")) - return a.messageList(governedBy(u), r.URL.Query().Get("class_id"), r.URL.Query().Get("cursor")), nil + return a.messageList(governedBy(u), r.URL.Query().Get("class_id"), r.URL.Query().Get("cursor")) } func (a *App) auditLogs(_ http.ResponseWriter, _ *http.Request, _ *User) (any, error) { diff --git a/server/internal/app/app.go b/server/internal/app/app.go index b8154d6..4cb1dd2 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -152,7 +152,24 @@ func (a *App) Close() error { } // Save writes the state through to the database. Callers hold a.mu. -func (a *App) Save() error { return a.store.Save(context.Background(), a.state) } +func (a *App) Save() error { + rows, err := a.state.rows() + if err != nil { + return err + } + return a.store.Save(context.Background(), rows) +} + +// rollback returns the in-memory state to the last committed one. Callers hold a.mu. +func (a *App) rollback() error { + var restored State + if err := a.store.Restore(&restored); err != nil { + return err + } + upgradeState(&restored) + a.state = restored + return nil +} func (a *App) deferJob(fn func()) { a.jobs = append(a.jobs, fn) } func (a *App) deferRollback(fn func()) { a.rollbackJobs = append(a.rollbackJobs, fn) } diff --git a/server/internal/app/auth.go b/server/internal/app/auth.go index acb944b..91357df 100644 --- a/server/internal/app/auth.go +++ b/server/internal/app/auth.go @@ -14,9 +14,6 @@ const ( refreshGrace = 30 * time.Second minUIScale = 85 maxUIScale = 150 - - // legacyRefreshCookie was shared by both workspaces before 0.1.3. - legacyRefreshCookie = "classding_refresh" ) // Each workspace keeps its own refresh cookie, so a teacher and an @@ -44,23 +41,16 @@ func requestScope(r *http.Request) string { } } -// refreshCookie prefers the workspace's own cookie and falls back to the legacy -// shared one. A request without a scope accepts either workspace's cookie. +// refreshCookie reads the workspace's own cookie. A request without a scope +// accepts either workspace's cookie. func refreshCookie(r *http.Request) (*http.Cookie, error) { - scope := requestScope(r) - if scope != "" { - if cookie, err := r.Cookie(refreshCookieName(scope)); err == nil { - return cookie, nil - } - } - if cookie, err := r.Cookie(legacyRefreshCookie); err == nil { - return cookie, nil + candidates := []string{scopeTeacher, scopeAdmin} + if scope := requestScope(r); scope != "" { + candidates = []string{scope} } - if scope == "" { - for _, candidate := range []string{scopeTeacher, scopeAdmin} { - if cookie, err := r.Cookie(refreshCookieName(candidate)); err == nil { - return cookie, nil - } + for _, candidate := range candidates { + if cookie, err := r.Cookie(refreshCookieName(candidate)); err == nil { + return cookie, nil } } return nil, http.ErrNoCookie @@ -143,7 +133,7 @@ func (a *App) register(w http.ResponseWriter, r *http.Request, _ *User) (any, er if _, taken := a.userByUsername(username); taken { return nil, usernameTaken() } - u := User{ID: id("u"), Username: username, DisplayName: name, Role: RoleTeacher, OrgID: id("org"), Status: AccountActive, PasswordHash: passwordHash(in.Password)} + u := User{ID: id("u"), Username: username, DisplayName: name, Role: RoleTeacher, OrgID: id("org"), Status: AccountActive, PasswordHash: freshHash(r, in.Password)} a.state.Users[u.ID] = u a.audit(&u, "auth.register", u.ID) return a.issue(w, r, u), nil @@ -175,7 +165,11 @@ func (a *App) login(w http.ResponseWriter, r *http.Request, _ *User) (any, error return nil, tooMany("密码错误次数过多,请在 " + seconds + " 秒后重试,或联系管理员解除登录限制。") } u, found := a.userByUsername(username) - if !found || !passwordMatches(in.Password, u.PasswordHash) || !u.Active() { + encoded := dummyPasswordHash + if found { + encoded = u.PasswordHash + } + if !checkPassword(r, in.Password, encoded) || !found || !u.Active() { a.limit(accountLoginKey(ip, username), accountLoginAttempts, time.Minute) a.limit(addressLoginKey(ip), addressLoginAttempts, time.Minute) return nil, fail(401, "INVALID_CREDENTIALS", "用户名或密码不正确,或账号已被停用。") @@ -269,9 +263,6 @@ func (a *App) refresh(w http.ResponseWriter, r *http.Request, _ *User) (any, err } } a.setRefreshCookie(w, r, refreshCookieName(scope), raw) - if cookie.Name == legacyRefreshCookie { - a.setRefreshCookie(w, r, legacyRefreshCookie, "") - } return a.signedIn(u), nil } @@ -299,13 +290,10 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request, _ *User) (any, erro a.state.Users[userID] = u } switch { - case cookieErr != nil: - if scope != "" { - a.setRefreshCookie(w, r, refreshCookieName(scope), "") - } - // Keep another account's legacy cookie when this scope signed nobody out. - case cookie.Name != legacyRefreshCookie || scope == "" || userID != "": + case cookieErr == nil: a.setRefreshCookie(w, r, cookie.Name, "") + case scope != "": + a.setRefreshCookie(w, r, refreshCookieName(scope), "") } return ok(), nil } @@ -349,13 +337,13 @@ func (a *App) changePassword(_ http.ResponseWriter, r *http.Request, u *User) (a if !a.limit("password:"+u.ID, 5, time.Minute) { return nil, tooMany("尝试过于频繁。") } - if !passwordMatches(in.Current, u.PasswordHash) { + if !checkPassword(r, in.Current, u.PasswordHash) { return nil, bad("当前密码不正确。") } if !validPassword(in.New) { return nil, bad("新密码至少 10 位,且包含字母和数字。") } - u.PasswordHash = passwordHash(in.New) + u.PasswordHash = freshHash(r, in.New) a.revokeSessions(u) a.state.Users[u.ID] = *u a.audit(u, "auth.password", u.ID) diff --git a/server/internal/app/backup.go b/server/internal/app/backup.go index 9edb38e..d063a73 100644 --- a/server/internal/app/backup.go +++ b/server/internal/app/backup.go @@ -582,7 +582,11 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) { writeError(w, fail(500, "RESTORE_FAILED", "无法恢复录音。")) return } - if err = a.store.Replace(context.Background(), state); err != nil { + rows, err := state.rows() + if err == nil { + err = a.store.Replace(context.Background(), rows) + } + if err != nil { if removeErr := os.RemoveAll(next); removeErr != nil { slog.Error("remove failed restore audio", "error", removeErr) } diff --git a/server/internal/app/backup_test.go b/server/internal/app/backup_test.go index 5324e00..38c8a92 100644 --- a/server/internal/app/backup_test.go +++ b/server/internal/app/backup_test.go @@ -181,6 +181,7 @@ func restoreBackup(t *testing.T, f *securityFixture, token string, data []byte) r.Header.Set("Content-Type", "application/zip") r.Header.Set("X-ClassDing-Restore", "replace") w := httptest.NewRecorder() + commitState(f.app) f.app.Handler().ServeHTTP(w, r) return w } @@ -225,7 +226,7 @@ func TestBackupRestoreToDifferentDirectory(t *testing.T) { } expectStatus(t, dst.request(t, "GET", "/api/v1/me", oldAccess, nil, nil), 401) expectStatus(t, dst.request(t, "GET", "/api/v1/admin/settings", oldAdmin, nil, nil), 401) - expectStatus(t, dst.request(t, "POST", "/api/v1/auth/refresh", "", nil, &http.Cookie{Name: "classding_refresh", Value: "old-refresh"}), 401) + expectStatus(t, dst.request(t, "POST", "/api/v1/auth/refresh", "", nil, &http.Cookie{Name: "classding_refresh_teacher", Value: "old-refresh"}), 401) me := dst.request(t, "GET", "/api/v1/device/me", "token-a", nil, nil) expectStatus(t, me, 200) if !strings.Contains(me.Body.String(), "revision-1") { @@ -278,6 +279,7 @@ func TestRestoreAuthenticatesBeforeBodyAndChecksOrigin(t *testing.T) { r.Header.Set("Content-Type", "application/zip") r.Header.Set("X-ClassDing-Restore", "replace") w := httptest.NewRecorder() + commitState(f.app) f.app.Handler().ServeHTTP(w, r) expectStatus(t, w, tc.status) if body.read { diff --git a/server/internal/app/classes.go b/server/internal/app/classes.go index 295b3a5..9ef9773 100644 --- a/server/internal/app/classes.go +++ b/server/internal/app/classes.go @@ -124,9 +124,9 @@ func (a *App) createClass(_ http.ResponseWriter, r *http.Request, u *User) (any, if err := decode(r, &in); err != nil { return nil, err } - name := strings.TrimSpace(in.Name) - if !validClassName(name) { - return nil, bad("班级名称需为 1–40 字。") + name, grade := strings.TrimSpace(in.Name), strings.TrimSpace(in.Grade) + if !validClassName(name) || !runesBetween(grade, 0, maxClassName) { + return nil, bad("班级名称需为 1–40 字,年级最多 40 字。") } code, exists := a.state.Codes[strings.ToUpper(strings.TrimSpace(in.Code))] if !exists || code.Status != LicenseUnused || code.Expired() { @@ -135,7 +135,7 @@ func (a *App) createClass(_ http.ResponseWriter, r *http.Request, u *User) (any, if err := a.redeemFor(u, code); err != nil { return nil, err } - c := a.activateClass(code, Class{Name: name, Grade: in.Grade, OwnerID: u.ID, OrgID: u.OrgID}, *u) + c := a.activateClass(code, Class{Name: name, Grade: grade, OwnerID: u.ID, OrgID: u.OrgID}, *u) a.audit(u, "class.create", c.ID) return a.classView(c, u), nil } diff --git a/server/internal/app/cursor.go b/server/internal/app/cursor.go new file mode 100644 index 0000000..31a7b26 --- /dev/null +++ b/server/internal/app/cursor.go @@ -0,0 +1,63 @@ +package app + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "strings" +) + +// pageKey orders records newest first, with the ID breaking ties so that +// records created in the same instant are never skipped or repeated. +type pageKey struct { + CreatedAt string `json:"created_at"` + ID string `json:"id"` +} + +// newestFirst compares parsed times, not RFC 3339 strings: a whole second and +// a fractional second do not sort lexically at the boundary. +func newestFirst(x, y pageKey) int { + left, _ := parseTime(x.CreatedAt) + right, _ := parseTime(y.CreatedAt) + if order := right.Compare(left); order != 0 { + return order + } + return strings.Compare(y.ID, x.ID) +} + +func (k pageKey) encode() *string { + data, _ := json.Marshal(k) + encoded := base64.RawURLEncoding.EncodeToString(data) + return &encoded +} + +// decodeCursor reads an opaque page cursor issued by pageKey.encode. +func decodeCursor(raw string) (*pageKey, bool) { + if raw == "" { + return nil, true + } + if len(raw) > 1024 { + return nil, false + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, false + } + var key pageKey + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&key) != nil || key.ID == "" || len(key.ID) > 128 { + return nil, false + } + if decoder.Decode(new(any)) != io.EOF { + return nil, false + } + if _, valid := parseTime(key.CreatedAt); !valid { + return nil, false + } + return &key, true +} + +// after reports whether the record belongs on a page that follows cursor. +func (k pageKey) after(cursor *pageKey) bool { return cursor == nil || newestFirst(k, *cursor) > 0 } diff --git a/server/internal/app/devices.go b/server/internal/app/devices.go index 0d92e86..7434f73 100644 --- a/server/internal/app/devices.go +++ b/server/internal/app/devices.go @@ -196,12 +196,15 @@ func (a *App) removeDevice(_ http.ResponseWriter, r *http.Request, u *User) (any a.unbind(d) delete(a.state.Devices, d.ID) for key, m := range a.state.Messages { + changed := false for i, t := range m.Targets { - if t.DeviceID == d.ID && t.Status == DeliveryQueued { - m.Targets[i].Status = DeliveryFailed + if t.DeviceID == d.ID && t.Status.Pending() { + m.Targets[i].Status, changed = DeliveryFailed, true } } - a.state.Messages[key] = m + if changed { + a.state.Messages[key] = m + } } a.audit(u, "device.unbind", d.ID) return ok(), nil diff --git a/server/internal/app/handler.go b/server/internal/app/handler.go index c398405..2a60d5b 100644 --- a/server/internal/app/handler.go +++ b/server/internal/app/handler.go @@ -68,6 +68,7 @@ func (a *App) handle(level access, persist bool, fn endpoint) http.HandlerFunc { writeError(w, err) return } + r = a.preparePasswords(r) a.mu.Lock() user, err := a.authorize(r, level) @@ -78,11 +79,7 @@ func (a *App) handle(level access, persist bool, fn endpoint) http.HandlerFunc { } var tx *transaction if persist { - if tx, err = a.begin(w); err != nil { - a.mu.Unlock() - writeError(w, err) - return - } + tx = a.begin(w) } a.jobs, a.rollbackJobs = nil, nil result, err := fn(w, r, user) @@ -139,23 +136,18 @@ func bearerToken(r *http.Request) string { } // transaction remembers everything a command may change so that a failed -// command, or a failed save, leaves no trace. +// command, or a failed save, leaves no trace. Persistent state is restored +// from the store's last commit, so the success path takes no snapshot. type transaction struct { app *App w http.ResponseWriter - state []byte bindings map[string]binding lastSent map[string]time.Time headers http.Header } -func (a *App) begin(w http.ResponseWriter) (*transaction, error) { - state, err := json.Marshal(a.state) - if err != nil { - slog.Error("snapshot state", "error", err) - return nil, storageFailed() - } - return &transaction{a, w, state, maps.Clone(a.bindings), maps.Clone(a.lastSent), w.Header().Clone()}, nil +func (a *App) begin(w http.ResponseWriter) *transaction { + return &transaction{a, w, maps.Clone(a.bindings), maps.Clone(a.lastSent), w.Header().Clone()} } // finish commits the command's changes, or restores the snapshot on failure. @@ -171,11 +163,8 @@ func (tx *transaction) finish(err error) error { clear(tx.w.Header()) maps.Copy(tx.w.Header(), tx.headers) } - var restored State - if restoreErr := json.Unmarshal(tx.state, &restored); restoreErr != nil { + if restoreErr := a.rollback(); restoreErr != nil { slog.Error("restore state", "error", restoreErr) - } else { - a.state = restored } a.bindings, a.lastSent = tx.bindings, tx.lastSent return err diff --git a/server/internal/app/http_session_test.go b/server/internal/app/http_session_test.go index 0f0d184..bf4e053 100644 --- a/server/internal/app/http_session_test.go +++ b/server/internal/app/http_session_test.go @@ -18,6 +18,7 @@ func scopedRequest(a *App, path, scope string, body any, cookies ...*http.Cookie r.AddCookie(cookie) } w := httptest.NewRecorder() + commitState(a) a.Handler().ServeHTTP(w, r) return w } @@ -67,7 +68,7 @@ func TestProductionHTTPSessionsAndSeparateScopes(t *testing.T) { expectStatus(t, scopedRequest(a, "/auth/refresh", "teacher", nil, adminTeacher.Result().Cookies()[0]), 200) } -func TestCookieProtocolAndLegacyMigration(t *testing.T) { +func TestCookieProtocol(t *testing.T) { f := newSecurityFixture(t) for _, tc := range []struct { name, url, remote, forwarded, mode string @@ -91,16 +92,6 @@ func TestCookieProtocolAndLegacyMigration(t *testing.T) { } }) } - f.app.config.CookieSecure = "auto" - u := f.users["super"] - raw := "legacy-admin-refresh-token" - f.app.state.Sessions[hash(raw)] = Session{ID: hash(raw), UserID: u.ID, ExpiresAt: after(refreshGrace), Version: u.TokenVersion} - response := scopedRequest(f.app, "/auth/refresh", "admin", nil, &http.Cookie{Name: "classding_refresh", Value: raw}) - expectStatus(t, response, 200) - cookies := response.Result().Cookies() - if len(cookies) != 2 || cookies[0].Name != "classding_refresh_admin" || cookies[1].Name != "classding_refresh" || cookies[1].MaxAge >= 0 { - t.Fatalf("legacy migration cookies: %+v", cookies) - } } func TestFailedLoginLimitAndAdministratorUnlock(t *testing.T) { diff --git a/server/internal/app/hub.go b/server/internal/app/hub.go index 4e74b00..4f46d37 100644 --- a/server/internal/app/hub.go +++ b/server/internal/app/hub.go @@ -19,7 +19,7 @@ type Peer struct { ID string // Device ID, for screens. ClassID string // For screens. UserID string // For teachers. - // Immutable once registered; student-call broadcasts recheck the live session. + // Guarded by Hub.mu; student-call broadcasts recheck the live session. teacherSession claims } @@ -126,6 +126,13 @@ func (h *Hub) addTeacher(p *Peer) { h.teachers[p.UserID][p] = true } +// renewTeacher records the newer access token a teacher socket presented. +func (h *Hub) renewTeacher(p *Peer, session claims) { + h.mu.Lock() + defer h.mu.Unlock() + p.teacherSession = session +} + func (h *Hub) removeTeacherPeer(p *Peer) { h.mu.Lock() defer h.mu.Unlock() diff --git a/server/internal/app/maintenance.go b/server/internal/app/maintenance.go index 6609e1a..56226a9 100644 --- a/server/internal/app/maintenance.go +++ b/server/internal/app/maintenance.go @@ -1,7 +1,6 @@ package app import ( - "encoding/json" "fmt" "log/slog" "os" @@ -10,8 +9,10 @@ import ( ) const ( - messageRetention = 90 * 24 * time.Hour - audioRetention = 7 * 24 * time.Hour + messageRetention = 90 * 24 * time.Hour + studentCallRetention = 90 * 24 * time.Hour + auditRetention = 180 * 24 * time.Hour + audioRetention = 7 * 24 * time.Hour ) func (a *App) maintenance() { @@ -37,10 +38,13 @@ type expiry struct { messages []string // Past retention; deleted. stale []string // Expired, with deliveries still pending. audio []string + calls []string + audit []string + invites []string // Used or expired. } func (e expiry) empty() bool { - return len(e.sessions) == 0 && len(e.messages) == 0 && len(e.stale) == 0 && len(e.audio) == 0 + return len(e.sessions)+len(e.messages)+len(e.stale)+len(e.audio)+len(e.calls)+len(e.audit)+len(e.invites) == 0 } func (a *App) expiredAt(at time.Time) expiry { @@ -75,6 +79,21 @@ func (a *App) expiredAt(at time.Time) expiry { e.audio = append(e.audio, key) } } + for key, call := range a.state.StudentCalls { + if older(call.CreatedAt, studentCallRetention) { + e.calls = append(e.calls, key) + } + } + for key, entry := range a.state.Audit { + if older(entry.At, auditRetention) { + e.audit = append(e.audit, key) + } + } + for key, invite := range a.state.Invites { + if invite.Used || past(invite.ExpiresAt) { + e.invites = append(e.invites, key) + } + } return e } @@ -96,17 +115,21 @@ func (a *App) maintenanceSweep(at time.Time) error { if e.empty() { return nil } - backup, err := json.Marshal(a.state) - if err != nil { - return fmt.Errorf("snapshot state: %w", err) - } - for _, key := range e.sessions { delete(a.state.Sessions, key) } for _, key := range e.messages { delete(a.state.Messages, key) } + for _, key := range e.calls { + delete(a.state.StudentCalls, key) + } + for _, key := range e.audit { + delete(a.state.Audit, key) + } + for _, key := range e.invites { + delete(a.state.Invites, key) + } var receipts []func() for _, key := range e.stale { message := a.state.Messages[key] @@ -126,11 +149,9 @@ func (a *App) maintenanceSweep(at time.Time) error { } if err := a.Save(); err != nil { - var restored State - if restoreErr := json.Unmarshal(backup, &restored); restoreErr != nil { + if restoreErr := a.rollback(); restoreErr != nil { return fmt.Errorf("save state: %v; restore state: %w", err, restoreErr) } - a.state = restored return err } for _, path := range files { diff --git a/server/internal/app/messages.go b/server/internal/app/messages.go index 2e8a964..e41cdd9 100644 --- a/server/internal/app/messages.go +++ b/server/internal/app/messages.go @@ -1,6 +1,8 @@ package app import ( + "fmt" + "math" "net/http" "os" "slices" @@ -107,7 +109,7 @@ func (a *App) createMessage(u *User, in messageInput) (Message, error) { return Message{}, err } if last, sent := a.lastSent[classID]; sent && time.Since(last) < a.config.SendCooldown { - return Message{}, fail(429, "SEND_TOO_FAST", "同一班级请间隔 5 秒再发送。") + return Message{}, fail(429, "SEND_TOO_FAST", fmt.Sprintf("同一班级请间隔 %d 秒再发送。", int(math.Ceil(a.config.SendCooldown.Seconds())))) } classes = append(classes, c) } @@ -195,12 +197,17 @@ type messagePage struct { NextCursor *string `json:"next_cursor"` } -// messageList pages through visible messages, newest first. The cursor is the -// creation time of the last message on the previous page. -func (a *App) messageList(visible func(Class) bool, classID, cursor string) messagePage { +func (m Message) key() pageKey { return pageKey{m.CreatedAt, m.ID} } + +// messageList pages through visible messages, newest first. +func (a *App) messageList(visible func(Class) bool, classID, rawCursor string) (messagePage, error) { + cursor, valid := decodeCursor(rawCursor) + if !valid { + return messagePage{}, bad("分页标识无效。") + } out := []Message{} for _, m := range a.state.Messages { - if cursor != "" && m.CreatedAt >= cursor { + if !m.key().after(cursor) { continue } view, shown := a.messageView(m, visible) @@ -215,18 +222,17 @@ func (a *App) messageList(visible func(Class) bool, classID, cursor string) mess } out = append(out, view) } - slices.SortFunc(out, func(x, y Message) int { return strings.Compare(y.CreatedAt, x.CreatedAt) }) + slices.SortFunc(out, func(x, y Message) int { return newestFirst(x.key(), y.key()) }) page := messagePage{Items: out} if len(out) > messagePageSize { page.Items = out[:messagePageSize] - next := page.Items[messagePageSize-1].CreatedAt - page.NextCursor = &next + page.NextCursor = page.Items[messagePageSize-1].key().encode() } - return page + return page, nil } func (a *App) listMessages(_ http.ResponseWriter, r *http.Request, u *User) (any, error) { - return a.messageList(a.memberOf(u), r.URL.Query().Get("class_id"), r.URL.Query().Get("cursor")), nil + return a.messageList(a.memberOf(u), r.URL.Query().Get("class_id"), r.URL.Query().Get("cursor")) } func (a *App) getMessage(_ http.ResponseWriter, r *http.Request, u *User) (any, error) { @@ -268,7 +274,7 @@ func (a *App) recallMessage(_ http.ResponseWriter, r *http.Request, u *User) (an recalled := now() m.RecalledAt = &recalled for i := range m.Targets { - if m.Targets[i].Status != DeliveryPlayed { + if m.Targets[i].Status.Pending() { m.Targets[i].Status = DeliveryDismissed } } diff --git a/server/internal/app/models.go b/server/internal/app/models.go index 09ebd76..6277ac8 100644 --- a/server/internal/app/models.go +++ b/server/internal/app/models.go @@ -3,7 +3,7 @@ package app import "time" // Version is the release reported by the server and offered to classroom clients. -const Version = "0.3.5" +const Version = "0.4.0" // Role decides which workspace an account may sign in to and what it governs. type Role string @@ -258,7 +258,6 @@ type Session struct { UserID string `json:"user_id"` ExpiresAt string `json:"expires_at"` Version int `json:"version"` - AdminVerified bool `json:"admin_verified,omitempty"` // Legacy field; no longer used for authentication. RotationUntil string `json:"rotation_until,omitempty"` Scope string `json:"auth_scope,omitempty"` } diff --git a/server/internal/app/passwords.go b/server/internal/app/passwords.go new file mode 100644 index 0000000..681a7c1 --- /dev/null +++ b/server/internal/app/passwords.go @@ -0,0 +1,119 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "runtime" + "strings" +) + +// Argon2id takes tens of milliseconds and 32 MiB per call. preparePasswords +// does that work before App.mu is taken, so one sign-in never stalls every +// other request and classroom socket. Handlers then read the results with +// freshHash and checkPassword, which fall back to computing inline. + +type passwordWork struct { + hashes map[string]string // Plain text → new encoded hash. + matches map[string]bool // Plain text + "\x00" + encoded hash → verified. +} + +type passwordKey struct{} + +// argonSlots bounds concurrent hashing so a burst of sign-ins cannot exhaust memory. +var argonSlots = make(chan struct{}, max(2, min(runtime.NumCPU(), 8))) + +// dummyPasswordHash is verified against when an account does not exist, so a +// response takes the same time whether or not the username is registered. +var dummyPasswordHash = passwordHash("classding-unknown-account-0") + +func withArgon[T any](fn func() T) T { + argonSlots <- struct{}{} + defer func() { <-argonSlots }() + return fn() +} + +func (a *App) preparePasswords(r *http.Request) *http.Request { + if r.Body == nil || r.Body == http.NoBody || r.Method == http.MethodGet { + return r + } + raw, err := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(raw)) + if err != nil || !bytes.Contains(raw, []byte("password")) { + return r + } + var in struct { + Username string `json:"username"` + Password *string `json:"password"` + Current *string `json:"current_password"` + New *string `json:"new_password"` + } + if json.Unmarshal(raw, &in) != nil { + return r + } + login := strings.HasSuffix(r.URL.Path, "/auth/login") + var fresh []string + if !login && in.Password != nil && validPassword(*in.Password) { + fresh = append(fresh, *in.Password) + } + if in.New != nil && validPassword(*in.New) { + fresh = append(fresh, *in.New) + } + type check struct{ plain, encoded string } + var checks []check + a.mu.Lock() + switch { + case login && in.Password != nil: + username := normalizeUsername(in.Username) + if a.loginWait(a.clientIP(r), username) == 0 { + encoded := dummyPasswordHash + if u, found := a.userByUsername(username); found { + encoded = u.PasswordHash + } + checks = append(checks, check{*in.Password, encoded}) + } + case in.Current != nil: + if u, err := a.authenticate(r); err == nil { + checks = append(checks, check{*in.Current, u.PasswordHash}) + } + } + a.mu.Unlock() + if len(fresh) == 0 && len(checks) == 0 { + return r + } + work := &passwordWork{hashes: map[string]string{}, matches: map[string]bool{}} + for _, plain := range fresh { + work.hashes[plain] = withArgon(func() string { return passwordHash(plain) }) + } + for _, c := range checks { + work.matches[c.plain+"\x00"+c.encoded] = withArgon(func() bool { return passwordMatches(c.plain, c.encoded) }) + } + return r.WithContext(context.WithValue(r.Context(), passwordKey{}, work)) +} + +func requestPasswords(r *http.Request) *passwordWork { + work, _ := r.Context().Value(passwordKey{}).(*passwordWork) + return work +} + +// freshHash returns the hash prepared for plain, or computes one. +func freshHash(r *http.Request, plain string) string { + if work := requestPasswords(r); work != nil { + if encoded, exists := work.hashes[plain]; exists { + return encoded + } + } + return passwordHash(plain) +} + +// checkPassword returns the prepared verification of plain against encoded, or computes it. +func checkPassword(r *http.Request, plain, encoded string) bool { + if work := requestPasswords(r); work != nil { + if matched, exists := work.matches[plain+"\x00"+encoded]; exists { + return matched + } + } + return passwordMatches(plain, encoded) +} diff --git a/server/internal/app/proxy_test.go b/server/internal/app/proxy_test.go index f7f1858..a36f99d 100644 --- a/server/internal/app/proxy_test.go +++ b/server/internal/app/proxy_test.go @@ -92,6 +92,7 @@ func TestProxyClientIPRateLimits(t *testing.T) { r.RemoteAddr = "10.0.0.1:1234" r.Header.Set("X-Forwarded-For", forwarded) w := httptest.NewRecorder() + commitState(a) a.Handler().ServeHTTP(w, r) return w.Code } diff --git a/server/internal/app/realtime.go b/server/internal/app/realtime.go index 13b1038..32d667d 100644 --- a/server/internal/app/realtime.go +++ b/server/internal/app/realtime.go @@ -223,15 +223,18 @@ func (a *App) connectDevice(p *Peer, hello deviceHello, publicIP string) (Device if !exists { return d, nil, false } - p.ID, p.ClassID = d.ID, d.ClassID - a.hub.addDevice(p) + previous := d d.LastSeenAt = now() hello.record(&d, publicIP) a.state.Devices[d.ID] = d if err = a.Save(); err != nil { - a.hub.RemoveDevice(d.ID) + // The older socket, if any, keeps serving the screen. + a.state.Devices[d.ID] = previous + slog.Error("save device connection", "error", err) return d, nil, false } + p.ID, p.ClassID = d.ID, d.ClassID + a.hub.addDevice(p) recalled := []string{} for _, messageID := range hello.PendingIDs { @@ -346,9 +349,22 @@ func (a *App) teacherWS(w http.ResponseWriter, r *http.Request) { defer a.hub.removeTeacherPeer(p) readFrames(p, func(frame wire) bool { a.mu.Lock() + defer a.mu.Unlock() + // A ping may carry a newer access token for the same account, which keeps + // the socket open across token refreshes instead of dropping it every 15 minutes. + if frame.T == "ping" { + var in struct { + Token string `json:"token"` + } + if json.Unmarshal(frame.D, &in) == nil && in.Token != "" { + if c, err := a.verify(in.Token); err == nil && c.Subject == session.Subject && c.Expires > session.Expires { + session = c + a.hub.renewTeacher(p, c) + } + } + } u, exists := a.state.Users[session.Subject] - valid := exists && u.Active() && u.TokenVersion == session.Version && time.Now().Unix() < session.Expires - a.mu.Unlock() + valid := exists && u.Active() && u.TokenVersion == session.Version && session.Epoch == a.authEpoch() && time.Now().Unix() < session.Expires if valid && frame.T == "ping" { p.send(pong()) } diff --git a/server/internal/app/realtime_test.go b/server/internal/app/realtime_test.go index 00e0765..ab3c13d 100644 --- a/server/internal/app/realtime_test.go +++ b/server/internal/app/realtime_test.go @@ -48,6 +48,7 @@ func rtSetup(t *testing.T) (*App, *httptest.Server, string) { if err != nil { t.Fatal(err) } + commitState(a) s := httptest.NewServer(a.Handler()) t.Cleanup(func() { s.Close(); a.Close() }) var auth struct { diff --git a/server/internal/app/review_fixes_test.go b/server/internal/app/review_fixes_test.go new file mode 100644 index 0000000..4b507d2 --- /dev/null +++ b/server/internal/app/review_fixes_test.go @@ -0,0 +1,196 @@ +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +func TestTeacherClassGradeIsBounded(t *testing.T) { + f := newSecurityFixture(t) + f.app.state.Codes["NEW-CODE"] = Code{Code: "NEW-CODE", Status: LicenseUnused, MaxTeachers: 2, MaxDevices: 1} + body := map[string]any{"name": "新班", "grade": strings.Repeat("年", maxClassName+1), "activation_code": "NEW-CODE"} + expectStatus(t, f.request(t, "POST", "/api/v1/classes", f.token("owner"), body, nil), 400) + body["grade"] = " 三年级 " + w := f.request(t, "POST", "/api/v1/classes", f.token("owner"), body, nil) + expectStatus(t, w, 200) + var view ClassView + if err := json.Unmarshal(w.Body.Bytes(), &view); err != nil || view.Grade != "三年级" { + t.Fatalf("grade = %q, %v", view.Grade, err) + } +} + +func TestRecallKeepsFinishedDeliveryOutcomes(t *testing.T) { + f := newSecurityFixture(t) + m := f.app.state.Messages["message_a"] + m.Targets = []Target{ + {ClassID: "class_a", DeviceID: "d1", Status: DeliveryDelivered}, + {ClassID: "class_a", DeviceID: "d2", Status: DeliveryFailed}, + {ClassID: "class_a", DeviceID: "d3", Status: DeliveryExpired}, + {ClassID: "class_a", DeviceID: "d4", Status: DeliveryPlayed}, + } + f.app.state.Messages[m.ID] = m + expectStatus(t, f.request(t, "POST", "/api/v1/messages/message_a/recall", f.token("owner"), nil, nil), 200) + want := []DeliveryStatus{DeliveryDismissed, DeliveryFailed, DeliveryExpired, DeliveryPlayed} + for i, target := range f.app.state.Messages[m.ID].Targets { + if target.Status != want[i] { + t.Fatalf("target %d = %s, want %s", i, target.Status, want[i]) + } + } +} + +func TestMessagePagesDoNotSkipTiedTimestamps(t *testing.T) { + f := newSecurityFixture(t) + for id := range f.app.state.Messages { + delete(f.app.state.Messages, id) + } + at := now() + for i := range messagePageSize + 5 { + id := "tied_" + string(rune('a'+i/26)) + string(rune('a'+i%26)) + f.app.state.Messages[id] = Message{ID: id, Kind: "text", SenderID: "owner_a", CreatedAt: at, ExpiresAt: after(time.Hour), Targets: []Target{{ClassID: "class_a"}}} + } + seen := map[string]bool{} + cursor := "" + for range 3 { + path := "/api/v1/messages" + if cursor != "" { + path += "?cursor=" + cursor + } + w := f.request(t, "GET", path, f.token("owner"), nil, nil) + expectStatus(t, w, 200) + var page messagePage + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + for _, item := range page.Items { + if seen[item.ID] { + t.Fatalf("%s repeated", item.ID) + } + seen[item.ID] = true + } + if page.NextCursor == nil { + break + } + cursor = *page.NextCursor + } + if len(seen) != messagePageSize+5 { + t.Fatalf("saw %d messages, want %d", len(seen), messagePageSize+5) + } + expectStatus(t, f.request(t, "GET", "/api/v1/messages?cursor=bad", f.token("owner"), nil, nil), 400) +} + +func TestSweepAppliesRetentionToCallsAuditAndInvites(t *testing.T) { + f := newSecurityFixture(t) + when := time.Now() + old, fresh := stamp(when.Add(-200*24*time.Hour)), stamp(when) + f.app.state.StudentCalls["old_call"] = studentCallRecord{StudentCall: StudentCall{ID: "old_call", ClassID: "class_a", CreatedAt: old}, RequestID: "r-old"} + f.app.state.StudentCalls["new_call"] = studentCallRecord{StudentCall: StudentCall{ID: "new_call", ClassID: "class_a", CreatedAt: fresh}, RequestID: "r-new"} + f.app.state.Audit["old_audit"] = Audit{ID: "old_audit", At: old} + f.app.state.Audit["new_audit"] = Audit{ID: "new_audit", At: fresh} + f.app.state.Invites["USED"] = Invite{Code: "USED", ClassID: "class_a", ExpiresAt: after(time.Hour), Used: true} + f.app.state.Invites["GONE"] = Invite{Code: "GONE", ClassID: "class_a", ExpiresAt: stamp(when.Add(-time.Minute))} + f.app.state.Invites["OPEN"] = Invite{Code: "OPEN", ClassID: "class_a", ExpiresAt: after(time.Hour)} + f.app.mu.Lock() + err := f.app.maintenanceSweep(when) + f.app.mu.Unlock() + if err != nil { + t.Fatal(err) + } + s := f.app.state + _, oldCall := s.StudentCalls["old_call"] + _, newCall := s.StudentCalls["new_call"] + _, oldAudit := s.Audit["old_audit"] + _, newAudit := s.Audit["new_audit"] + if oldCall || !newCall || oldAudit || !newAudit || len(s.Invites) != 1 || s.Invites["OPEN"].Code == "" { + t.Fatalf("retention left calls=%v audit=%v invites=%v", s.StudentCalls, s.Audit, s.Invites) + } +} + +func TestLoginVerifiesOutsideLockAndRejectsUnknownAccounts(t *testing.T) { + f := newSecurityFixture(t) + u := f.app.state.Users["owner_a"] + u.PasswordHash = passwordHash("Password12345") + f.app.state.Users[u.ID] = u + login := func(username, password string) int { + return f.request(t, http.MethodPost, "/api/v1/auth/login", "", map[string]string{"username": username, "password": password}, nil).Code + } + if code := login("owner_a", "Password12345"); code != 200 { + t.Fatalf("valid login = %d", code) + } + if code := login("owner_a", "Wrong123456"); code != 401 { + t.Fatalf("wrong password = %d", code) + } + if code := login("nobody", "Password12345"); code != 401 { + t.Fatalf("unknown account = %d", code) + } +} + +func TestSendCooldownMessageNamesConfiguredSeconds(t *testing.T) { + f := newSecurityFixture(t) + f.app.config.SendCooldown = 8 * time.Second + body := map[string]any{"class_ids": []string{"class_a"}, "kind": "text", "text": "请安静", "style": "notice", "display_sec": 30, "repeat": 1, "play_mode": "tts"} + expectStatus(t, f.request(t, "POST", "/api/v1/messages", f.token("owner"), body, nil), 200) + w := f.request(t, "POST", "/api/v1/messages", f.token("owner"), body, nil) + expectStatus(t, w, 429) + if !strings.Contains(w.Body.String(), "8 秒") { + t.Fatalf("cooldown message: %s", w.Body.String()) + } +} + +// shortToken signs an access token that expires after ttl. +func shortToken(a *App, u User, ttl time.Duration) string { + body, _ := json.Marshal(claims{Subject: u.ID, Expires: time.Now().Add(ttl).Unix(), Version: u.TokenVersion, Epoch: a.authEpoch()}) + payload := jwtHeader + "." + base64.RawURLEncoding.EncodeToString(body) + return payload + "." + base64.RawURLEncoding.EncodeToString(sign(a.config.JWTSecret, payload)) +} + +func TestTeacherSocketRenewsAccessTokenOverPing(t *testing.T) { + f := newSecurityFixture(t) + commitState(f.app) + server := httptest.NewServer(f.app.Handler()) + t.Cleanup(server.Close) + owner := f.users["owner"] + renewed := connectCallTeacher(t, server.URL, shortToken(f.app, owner, 2*time.Second)) + stale := connectCallTeacher(t, server.URL, shortToken(f.app, owner, 2*time.Second)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := wsjson.Write(ctx, renewed, map[string]any{"t": "ping", "d": map[string]string{"token": f.token("owner")}}); err != nil { + t.Fatal(err) + } + rtRead(t, renewed, "pong") + time.Sleep(2500 * time.Millisecond) + for _, conn := range []*websocket.Conn{renewed, stale} { + if err := wsjson.Write(ctx, conn, map[string]any{"t": "ping", "d": map[string]any{}}); err != nil { + t.Fatal(err) + } + } + rtRead(t, renewed, "pong") + var frame map[string]any + if err := wsjson.Read(ctx, stale, &frame); err == nil { + t.Fatalf("expired socket stayed open: %v", frame) + } + // Another account's token never takes over the socket. + other := connectCallTeacher(t, server.URL, shortToken(f.app, owner, time.Minute)) + if err := wsjson.Write(ctx, other, map[string]any{"t": "ping", "d": map[string]string{"token": f.token("other")}}); err != nil { + t.Fatal(err) + } + rtRead(t, other, "pong") + f.app.hub.mu.Lock() + defer f.app.hub.mu.Unlock() + for p := range f.app.hub.teachers["owner_a"] { + if p.teacherSession.Subject != "owner_a" { + t.Fatal("socket adopted another account") + } + } + if len(f.app.hub.teachers["owner_b"]) != 0 { + t.Fatal("socket moved to another account") + } +} diff --git a/server/internal/app/rollback_test.go b/server/internal/app/rollback_test.go index e019045..abf9e13 100644 --- a/server/internal/app/rollback_test.go +++ b/server/internal/app/rollback_test.go @@ -90,7 +90,7 @@ func TestRollbackRefreshCookie(t *testing.T) { } restore() expectStatus(t, f.request(t, http.MethodPost, "/api/v1/auth/refresh", "", nil, cookies[0]), http.StatusOK) - invalid := f.request(t, http.MethodPost, "/api/v1/auth/refresh", "", nil, &http.Cookie{Name: "classding_refresh", Value: "invalid"}) + invalid := f.request(t, http.MethodPost, "/api/v1/auth/refresh", "", nil, &http.Cookie{Name: "classding_refresh_teacher", Value: "invalid"}) expectStatus(t, invalid, http.StatusUnauthorized) if len(invalid.Result().Cookies()) != 0 { t.Fatal("invalid stale refresh must not erase a newer browser cookie") @@ -181,6 +181,7 @@ func TestRollbackAudioFile(t *testing.T) { r.Header.Set("Content-Type", multipartWriter.FormDataContentType()) r.Header.Set("Authorization", "Bearer "+f.token("owner")) w := httptest.NewRecorder() + commitState(f.app) f.app.Handler().ServeHTTP(w, r) return w } diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go index 21a934c..735c6a8 100644 --- a/server/internal/app/routes.go +++ b/server/internal/app/routes.go @@ -137,7 +137,7 @@ func clientRelease(_ http.ResponseWriter, _ *http.Request, _ *User) (any, error) "version": Version, "url": releaseBase + "/ClassDingScreen-amd64-installer.exe", "sha256": "", - "notes": "支持只显示不朗读的通知(朗读 0 遍);SHA-256 校验文件见对应 GitHub Release。", + "notes": "v0.4.0:桌面呼叫悬浮球不再始终置顶、字号更大、颜色更醒目;主界面重新对齐;断网后更快恢复连接。SHA-256 校验文件见对应 GitHub Release。", "min_supported": "0.1.0", }, nil } diff --git a/server/internal/app/security_test.go b/server/internal/app/security_test.go index 27b6162..8127189 100644 --- a/server/internal/app/security_test.go +++ b/server/internal/app/security_test.go @@ -97,7 +97,9 @@ func (f *securityFixture) request(t *testing.T, method, path, token string, body if cookie != nil { r.AddCookie(cookie) } + commitState(f.app) w := httptest.NewRecorder() + commitState(f.app) f.app.Handler().ServeHTTP(w, r) return w } diff --git a/server/internal/app/state.go b/server/internal/app/state.go new file mode 100644 index 0000000..121d04d --- /dev/null +++ b/server/internal/app/state.go @@ -0,0 +1,50 @@ +package app + +import ( + "classding/server/internal/repo" + "encoding/json" + "fmt" +) + +// rows encodes each record on its own, so a save compares row by row without +// first marshalling and re-parsing the whole state. +func (s State) rows() (repo.Rows, error) { + out := repo.Rows{} + var err error + add := func(table string, encode func() (map[string]string, error)) { + if err == nil { + out[table], err = encode() + if err != nil { + err = fmt.Errorf("encode %s: %w", table, err) + } + } + } + add("student_calls", func() (map[string]string, error) { return tableRows(s.StudentCalls) }) + add("users", func() (map[string]string, error) { return tableRows(s.Users) }) + add("classes", func() (map[string]string, error) { return tableRows(s.Classes) }) + add("class_members", func() (map[string]string, error) { return tableRows(s.Members) }) + add("devices", func() (map[string]string, error) { return tableRows(s.Devices) }) + add("messages", func() (map[string]string, error) { return tableRows(s.Messages) }) + add("homeworks", func() (map[string]string, error) { return tableRows(s.Homeworks) }) + add("activation_codes", func() (map[string]string, error) { return tableRows(s.Codes) }) + add("collab_invites", func() (map[string]string, error) { return tableRows(s.Invites) }) + add("sessions", func() (map[string]string, error) { return tableRows(s.Sessions) }) + add("phrases", func() (map[string]string, error) { return tableRows(s.Phrases) }) + add("audio", func() (map[string]string, error) { return tableRows(s.Audio) }) + add("audit_logs", func() (map[string]string, error) { return tableRows(s.Audit) }) + add("settings", func() (map[string]string, error) { return tableRows(s.Settings) }) + add("schools", func() (map[string]string, error) { return tableRows(s.Schools) }) + return out, err +} + +func tableRows[T any](items map[string]T) (map[string]string, error) { + out := make(map[string]string, len(items)) + for id, item := range items { + data, err := json.Marshal(item) + if err != nil { + return nil, err + } + out[id] = string(data) + } + return out, nil +} diff --git a/server/internal/app/state_test.go b/server/internal/app/state_test.go new file mode 100644 index 0000000..4c264e1 --- /dev/null +++ b/server/internal/app/state_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "classding/server/internal/repo" + "reflect" + "testing" +) + +// commitState persists fixture changes tests make directly in memory. A failed +// command rolls back to the last commit, exactly as production does. +func commitState(a *App) { + a.mu.Lock() + _ = a.Save() + a.mu.Unlock() +} + +func TestStateRowsRoundTrip(t *testing.T) { + f := newSecurityFixture(t) + rows, err := f.app.state.rows() + if err != nil { + t.Fatal(err) + } + if len(rows) != reflect.TypeOf(State{}).NumField() { + t.Fatalf("rows cover %d tables, State has %d", len(rows), reflect.TypeOf(State{}).NumField()) + } + var decoded State + if err = repo.Decode(rows, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded, f.app.state) { + t.Fatal("state changed after a round trip through rows") + } +} diff --git a/server/internal/app/student_calls.go b/server/internal/app/student_calls.go index 2c85e11..e244618 100644 --- a/server/internal/app/student_calls.go +++ b/server/internal/app/student_calls.go @@ -1,15 +1,10 @@ package app import ( - "bytes" - "encoding/base64" - "encoding/json" - "io" "net/http" "regexp" "slices" "strconv" - "strings" "time" "github.com/go-chi/chi/v5" @@ -62,27 +57,13 @@ func (a *App) createStudentCall(w http.ResponseWriter, r *http.Request, _ *User) return item.StudentCall, nil } -type studentCallCursor struct { - CreatedAt string `json:"created_at"` - ID string `json:"id"` -} - type studentCallList struct { Items []StudentCall `json:"items"` UnreadCount int `json:"unread_count"` NextCursor *string `json:"next_cursor"` } -// Compare parsed times rather than RFC3339Nano strings: a whole second and a -// fractional second do not sort lexically at the boundary. -func compareStudentCalls(x, y StudentCall) int { - left, _ := parseTime(x.CreatedAt) - right, _ := parseTime(y.CreatedAt) - if order := right.Compare(left); order != 0 { - return order - } - return strings.Compare(y.ID, x.ID) -} +func (c StudentCall) key() pageKey { return pageKey{c.CreatedAt, c.ID} } func (a *App) listStudentCalls(_ http.ResponseWriter, r *http.Request, u *User) (any, error) { query := r.URL.Query() @@ -103,29 +84,9 @@ func (a *App) listStudentCalls(_ http.ResponseWriter, r *http.Request, u *User) return nil, bad("limit 需为 1–100。") } } - var cursor *StudentCall - if raw := query.Get("cursor"); raw != "" { - if len(raw) > 1024 { - return nil, bad("分页标识无效。") - } - data, err := base64.RawURLEncoding.DecodeString(raw) - if err != nil { - return nil, bad("分页标识无效。") - } - var key studentCallCursor - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if decoder.Decode(&key) != nil || key.ID == "" || len(key.ID) > 128 { - return nil, bad("分页标识无效。") - } - var extra any - if decoder.Decode(&extra) != io.EOF { - return nil, bad("分页标识无效。") - } - if _, valid := parseTime(key.CreatedAt); !valid { - return nil, bad("分页标识无效。") - } - cursor = &StudentCall{ID: key.ID, CreatedAt: key.CreatedAt} + cursor, valid := decodeCursor(query.Get("cursor")) + if !valid { + return nil, bad("分页标识无效。") } result := studentCallList{Items: []StudentCall{}} for _, item := range a.state.StudentCalls { @@ -136,17 +97,14 @@ func (a *App) listStudentCalls(_ http.ResponseWriter, r *http.Request, u *User) if item.ReadAt == "" { result.UnreadCount++ } - if cursor == nil || compareStudentCalls(item.StudentCall, *cursor) > 0 { + if item.key().after(cursor) { result.Items = append(result.Items, item.StudentCall) } } - slices.SortFunc(result.Items, compareStudentCalls) + slices.SortFunc(result.Items, func(x, y StudentCall) int { return newestFirst(x.key(), y.key()) }) if len(result.Items) > limit { result.Items = result.Items[:limit] - last := result.Items[len(result.Items)-1] - key, _ := json.Marshal(studentCallCursor{CreatedAt: last.CreatedAt, ID: last.ID}) - encoded := base64.RawURLEncoding.EncodeToString(key) - result.NextCursor = &encoded + result.NextCursor = result.Items[len(result.Items)-1].key().encode() } return result, nil } diff --git a/server/internal/app/student_calls_realtime_test.go b/server/internal/app/student_calls_realtime_test.go index b839e60..fdad570 100644 --- a/server/internal/app/student_calls_realtime_test.go +++ b/server/internal/app/student_calls_realtime_test.go @@ -56,6 +56,7 @@ func connectCallTeacher(t *testing.T, base, token string) *websocket.Conn { func TestStudentCallsRealWebsocketAndReconnect(t *testing.T) { f := newSecurityFixture(t) + commitState(f.app) server := httptest.NewServer(f.app.Handler()) t.Cleanup(server.Close) owner := connectCallTeacher(t, server.URL, f.token("owner")) diff --git a/server/internal/app/student_calls_test.go b/server/internal/app/student_calls_test.go index 3d1ad8d..14a9cb5 100644 --- a/server/internal/app/student_calls_test.go +++ b/server/internal/app/student_calls_test.go @@ -144,6 +144,7 @@ func TestStudentCallsStrictInputBindingAndLicenses(t *testing.T) { req := httptest.NewRequest("POST", deviceCallsPath, strings.NewReader(raw)) req.Header.Set("Authorization", "Bearer token-a") w := httptest.NewRecorder() + commitState(f.app) f.app.Handler().ServeHTTP(w, req) expectStatus(t, w, 400) } @@ -329,7 +330,7 @@ func TestStudentCallsPaginationCountsAndStableTimes(t *testing.T) { } fractional := StudentCall{ID: "frac", CreatedAt: "2026-01-01T00:00:00.1Z"} whole := StudentCall{ID: "whole", CreatedAt: "2026-01-01T00:00:00Z"} - if compareStudentCalls(fractional, whole) >= 0 { + if newestFirst(fractional.key(), whole.key()) >= 0 { t.Fatal("fractional timestamp sorted before whole second") } if got := listStudentCall(t, f, "school", ""); got.UnreadCount != 0 { diff --git a/server/internal/repo/store.go b/server/internal/repo/store.go index bcdb00e..b656e8c 100644 --- a/server/internal/repo/store.go +++ b/server/internal/repo/store.go @@ -15,7 +15,7 @@ var tables = []string{"users", "classes", "class_members", "devices", "messages" type Store struct { db *sql.DB - saved map[string]map[string]string + saved Rows // Last committed rows; never mutated in place. } func Open(path string) (*Store, error) { @@ -29,7 +29,7 @@ func Open(path string) (*Store, error) { return nil, err } db.SetMaxOpenConns(1) - s := &Store{db: db, saved: map[string]map[string]string{}} + s := &Store{db: db, saved: Rows{}} for _, statement := range []string{"PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"} { if _, err = db.Exec(statement); err != nil { db.Close() @@ -53,10 +53,14 @@ func Open(path string) (*Store, error) { return s, nil } +// Rows is a whole database: table name → row ID → JSON body. +type Rows map[string]map[string]string + +// Load reads every table and decodes it into dst. func (s *Store) Load(ctx context.Context, dst any) error { - state := map[string]map[string]json.RawMessage{} + saved := Rows{} for _, table := range tables { - state[table] = map[string]json.RawMessage{} + saved[table] = map[string]string{} rows, err := s.db.QueryContext(ctx, "SELECT id,body FROM "+table) if err != nil { return err @@ -67,8 +71,7 @@ func (s *Store) Load(ctx context.Context, dst any) error { rows.Close() return err } - state[table][id] = json.RawMessage(body) - s.saved[table][id] = body + saved[table][id] = body } err = rows.Err() rows.Close() @@ -76,6 +79,23 @@ func (s *Store) Load(ctx context.Context, dst any) error { return err } } + s.saved = saved + return Decode(saved, dst) +} + +// Restore decodes the last committed state into dst without touching the +// database, which may be the reason a commit failed. +func (s *Store) Restore(dst any) error { return Decode(s.saved, dst) } + +// Decode turns rows into the structure whose JSON fields name the tables. +func Decode(rows Rows, dst any) error { + state := map[string]map[string]json.RawMessage{} + for table, items := range rows { + state[table] = make(map[string]json.RawMessage, len(items)) + for id, body := range items { + state[table][id] = json.RawMessage(body) + } + } data, err := json.Marshal(state) if err != nil { return err @@ -83,47 +103,35 @@ func (s *Store) Load(ctx context.Context, dst any) error { return json.Unmarshal(data, dst) } -func (s *Store) Save(ctx context.Context, src any) error { - return s.save(ctx, src, false) -} +// Save writes only the rows that changed since the last commit. +func (s *Store) Save(ctx context.Context, rows Rows) error { return s.save(ctx, rows, false) } // Replace atomically replaces every table, including rows whose unique keys // belong to different IDs in the previous state. -func (s *Store) Replace(ctx context.Context, src any) error { - return s.save(ctx, src, true) -} +func (s *Store) Replace(ctx context.Context, rows Rows) error { return s.save(ctx, rows, true) } -func (s *Store) save(ctx context.Context, src any, replace bool) error { - data, err := json.Marshal(src) - if err != nil { - return err - } - state := map[string]map[string]json.RawMessage{} - if err = json.Unmarshal(data, &state); err != nil { - return err - } +func (s *Store) save(ctx context.Context, rows Rows, replace bool) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() - if replace { - for _, table := range tables { + next := Rows{} + for _, table := range tables { + if replace { if _, err = tx.ExecContext(ctx, "DELETE FROM "+table); err != nil { return err } } - } - next := map[string]map[string]string{} - for _, table := range tables { - next[table] = map[string]string{} - for id, body := range state[table] { - value := string(body) - next[table][id] = value - if !replace && s.saved[table][id] == value { + next[table] = rows[table] + if next[table] == nil { + next[table] = map[string]string{} + } + for id, body := range next[table] { + if !replace && s.saved[table][id] == body { continue } - if _, err = tx.ExecContext(ctx, "INSERT INTO "+table+" (id,body) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')", id, value); err != nil { + if _, err = tx.ExecContext(ctx, "INSERT INTO "+table+" (id,body) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')", id, body); err != nil { return err } } diff --git a/web/apps/admin/package.json b/web/apps/admin/package.json index 9d4fc87..36d6823 100644 --- a/web/apps/admin/package.json +++ b/web/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@classding/admin", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/web/apps/teacher/package.json b/web/apps/teacher/package.json index 37df895..9ec789f 100644 --- a/web/apps/teacher/package.json +++ b/web/apps/teacher/package.json @@ -1,6 +1,6 @@ { "name": "@classding/teacher", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/web/apps/teacher/src/hooks.ts b/web/apps/teacher/src/hooks.ts index 22630de..44c5ecc 100644 --- a/web/apps/teacher/src/hooks.ts +++ b/web/apps/teacher/src/hooks.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { api, getAccessToken } from '@classding/api'; +import { api, getAccessToken, refreshSession, tokenExpiresSoon } from '@classding/api'; import type { Class, Homework, MessagePage, Phrase, StudentCallPage } from './models'; import type { InfiniteData } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -59,7 +59,11 @@ export function useTeacherSocket() { if (frame.t === 'welcome') { attempts = 0; setOnline(true); + // Anything may have changed while the socket was down. void cache.invalidateQueries({ queryKey: ['homeworks'] }); + void cache.invalidateQueries({ queryKey: ['messages'] }); + void cache.invalidateQueries({ queryKey: ['classes'] }); + void cache.invalidateQueries({ queryKey: ['devices'] }); if (!studentCallsUnavailable(cache.getQueryState(studentCallsKey(userId))?.error)) void cache.invalidateQueries({ queryKey: studentCallsKey(userId) }); clearInterval(ping); @@ -68,7 +72,15 @@ export function useTeacherSocket() { socket?.close(); return; } - if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ t: 'ping', d: {} })); + // Refresh shortly before the access token expires and hand the new one to the + // server, so the socket outlives the 15-minute token. + const renew = tokenExpiresSoon(getAccessToken()) + ? refreshSession().catch(() => {}) + : Promise.resolve(); + void renew.then(() => { + if (!stopped && socket === current && current.readyState === WebSocket.OPEN) + current.send(JSON.stringify({ t: 'ping', d: { token: getAccessToken() } })); + }); }, 25000); } if (frame.t === 'student_call') { diff --git a/web/packages/api/package.json b/web/packages/api/package.json index 8b5c758..f30f8dd 100644 --- a/web/packages/api/package.json +++ b/web/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@classding/api", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "exports": { diff --git a/web/packages/api/src/index.ts b/web/packages/api/src/index.ts index 5f64bd5..63e1149 100644 --- a/web/packages/api/src/index.ts +++ b/web/packages/api/src/index.ts @@ -13,15 +13,23 @@ const expiredListeners = new Set<() => void>(); // The token subject is used only to keep an existing UI session from silently // switching accounts when another tab replaces the refresh cookie. -function tokenSubject(token: string): string { +function tokenClaims(token: string): { sub?: unknown; exp?: unknown } { try { const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); - const value = JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, '='))); - return typeof value.sub === 'string' ? value.sub : ''; + return JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, '='))); } catch { - return ''; + return {}; } } +function tokenSubject(token: string): string { + const { sub } = tokenClaims(token); + return typeof sub === 'string' ? sub : ''; +} +// tokenExpiresSoon reports whether an access token is missing or expires within margin. +export function tokenExpiresSoon(token: string, margin = 2 * 60 * 1000): boolean { + const { exp } = tokenClaims(token); + return typeof exp !== 'number' || exp * 1000 - Date.now() < margin; +} export function setAccessToken(token: string | null, userId?: string) { accessToken = token ?? ''; diff --git a/web/packages/api/src/ws-types.ts b/web/packages/api/src/ws-types.ts index 0d2a68c..1fa626a 100644 --- a/web/packages/api/src/ws-types.ts +++ b/web/packages/api/src/ws-types.ts @@ -164,7 +164,9 @@ export interface components { id?: string; seq?: number; ts?: number; - d: Record; + d: { + token?: string; + }; } | { /** @constant */ t: "pong"; diff --git a/web/packages/ui/package.json b/web/packages/ui/package.json index 14a6be2..b2680af 100644 --- a/web/packages/ui/package.json +++ b/web/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@classding/ui", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "exports": {