第 2 阶段:账号与班级,作品保存在服务器
- SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品 - 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数 - 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8, 识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码, 导出带密码的名单 - 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存 - 前端加路由:登录页、实验页、班级管理页 - 部署:TEACHER_PASSWORD 必填,数据库放在数据卷 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a00622be15
commit
7cec273d58
31 files changed
+1776
-382
No files matched your search
@@ -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<string, { count: number; since: number }>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, { count: number; since: number }>()
|
||||
|
||||
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
|
||||
@@ -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<ClassInfo, []>(
|
||||
`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<Student, [number]>(`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<string>()
|
||||
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)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+7
-38
@@ -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<SocketData> }>()
|
||||
|
||||
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
|
||||
function clientIp(req: Request, server: Server<SocketData>) {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user