diff --git a/app.js b/app.js index f616da9..2cac0b0 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,8 @@ import "./utils/instrumentation.js"; // import createError from "http-errors"; import express from "express"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; +import {dirname, join} from "path"; +import {fileURLToPath} from "url"; // import cookieParser from "cookie-parser"; import logger from "morgan"; import bodyParser from "body-parser"; @@ -15,17 +15,17 @@ import deviceRouter from "./routes/device.js"; import deviceAuthRouter from "./routes/device-auth.js"; import accountsRouter from "./routes/accounts.js"; import autoAuthRouter from "./routes/auto-auth.js"; -import { register } from "./utils/metrics.js"; +import {register} from "./utils/metrics.js"; +import cors from "cors"; var app = express(); -import cors from "cors"; app.options("/{*path}", cors()); app.use( - cors({ - exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露 - maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求 - }) + cors({ + exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露 + maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求 + }) ); app.disable("x-powered-by"); @@ -36,67 +36,67 @@ const __dirname = dirname(__filename); // view engine setup app.set("views", join(__dirname, "views")); app.set("view engine", "ejs"); -app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.urlencoded({extended: true})); app.use(bodyParser.json()); app.use(logger("dev")); app.use(express.json()); -app.use(express.urlencoded({ extended: false })); +app.use(express.urlencoded({extended: false})); // app.use(cookieParser()); app.use(express.static(join(__dirname, "public"))); // 添加请求超时处理中间件 app.use((req, res, next) => { - // 设置默认请求超时时间为30秒 - const timeout = 30000; + // 设置默认请求超时时间为30秒 + const timeout = 30000; - // 设置超时回调 - const timeoutCallback = () => { - const timeoutError = errors.createError(408, "请求处理超时"); - next(timeoutError); - }; + // 设置超时回调 + const timeoutCallback = () => { + const timeoutError = errors.createError(408, "请求处理超时"); + next(timeoutError); + }; - // 设置超时 - req.setTimeout(timeout, timeoutCallback); + // 设置超时 + req.setTimeout(timeout, timeoutCallback); - // 监听响应完成事件 - res.on("finish", () => { - // 如果响应已经完成,清除超时处理 - req.setTimeout(0, timeoutCallback); - }); + // 监听响应完成事件 + res.on("finish", () => { + // 如果响应已经完成,清除超时处理 + req.setTimeout(0, timeoutCallback); + }); - next(); + next(); }); app.get("/", (req, res) => { - res.render("index.ejs"); + res.render("index.ejs"); }); app.get("/check", (req, res) => { - res.json({ - status: "success", - message: "Classworks KV is running", - time: new Date().getTime(), - }); + res.json({ + status: "success", + message: "Classworks KV is running", + time: new Date().getTime(), + }); }); // Prometheus metrics endpoint with token auth app.get("/metrics", async (req, res) => { - try { - // 检查 token 验证 - const metricsToken = process.env.METRICS_TOKEN; - if (metricsToken) { - const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token; - if (!providedToken || providedToken !== metricsToken) { - return res.status(401).json({ - error: "Unauthorized", - message: "Valid metrics token required" - }); - } - } + try { + // 检查 token 验证 + const metricsToken = process.env.METRICS_TOKEN; + if (metricsToken) { + const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token; + if (!providedToken || providedToken !== metricsToken) { + return res.status(401).json({ + error: "Unauthorized", + message: "Valid metrics token required" + }); + } + } - res.set("Content-Type", register.contentType); - res.end(await register.metrics()); - } catch (err) { - res.status(500).end(err.message); - } + res.set("Content-Type", register.contentType); + res.end(await register.metrics()); + } catch (err) { + res.status(500).end(err.message); + } }); // Mount the Apps router with API rate limiting @@ -119,8 +119,8 @@ app.use("/accounts", accountsRouter); // 兜底404路由 - 处理所有未匹配的路由 app.use((req, res, next) => { - const notFoundError = errors.createError(404, `找不到路径: ${req.path}`); - next(notFoundError); + const notFoundError = errors.createError(404, `找不到路径: ${req.path}`); + next(notFoundError); }); // 全局错误处理中间件 @@ -128,19 +128,19 @@ app.use(errorHandler); // 全局未捕获的异常处理 process.on("uncaughtException", (error) => { - console.error("未捕获的异常:", error); - // 记录错误但不退出进程 + console.error("未捕获的异常:", error); + // 记录错误但不退出进程 }); // 全局未处理的Promise拒绝处理 process.on("unhandledRejection", (reason, promise) => { - console.error("未处理的Promise拒绝:", reason); - // 记录错误但不退出进程 + console.error("未处理的Promise拒绝:", reason); + // 记录错误但不退出进程 }); // 处理 SIGTERM 信号 process.on("SIGTERM", () => { - console.log("收到 SIGTERM 信号,准备关闭服务..."); + console.log("收到 SIGTERM 信号,准备关闭服务..."); }); export default app; diff --git a/bin/www b/bin/www index 8af85ae..964c9d1 100644 --- a/bin/www +++ b/bin/www @@ -5,9 +5,9 @@ */ import app from '../app.js'; -import { createServer } from 'http'; -import { initSocket } from '../utils/socket.js'; -import { initializeMetrics } from '../utils/metrics.js'; +import {createServer} from 'http'; +import {initSocket} from '../utils/socket.js'; +import {initializeMetrics} from '../utils/metrics.js'; /** * Get port from environment and store in Express. @@ -41,19 +41,19 @@ server.on('listening', onListening); */ function normalizePort(val) { - var port = parseInt(val, 10); + var port = parseInt(val, 10); - if (isNaN(port)) { - // named pipe - return val; - } + if (isNaN(port)) { + // named pipe + return val; + } - if (port >= 0) { - // port number - return port; - } + if (port >= 0) { + // port number + return port; + } - return false; + return false; } /** @@ -61,27 +61,27 @@ function normalizePort(val) { */ function onError(error) { - if (error.syscall !== 'listen') { - throw error; - } + if (error.syscall !== 'listen') { + throw error; + } - var bind = typeof port === 'string' - ? 'Pipe ' + port - : 'Port ' + port; + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - console.error(bind + ' requires elevated privileges'); - process.exit(1); - break; - case 'EADDRINUSE': - console.error(bind + ' is already in use'); - process.exit(1); - break; - default: - throw error; - } + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } } /** @@ -89,6 +89,6 @@ function onError(error) { */ function onListening() { - var addr = server.address(); - console.log(`Server running at http://0.0.0.0:${addr.port}`); + var addr = server.address(); + console.log(`Server running at http://0.0.0.0:${addr.port}`); } diff --git a/classworks.js b/classworks.js index 23f6fb2..b32e76e 100644 --- a/classworks.js +++ b/classworks.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { execSync } from "child_process"; +import {execSync} from "child_process"; import dotenv from "dotenv"; dotenv.config(); @@ -7,78 +7,78 @@ dotenv.config(); // 🔄 执行数据库迁移函数 function runDatabaseMigration() { - try { - console.log("🔄 执行数据库迁移..."); - execSync("npx prisma migrate deploy", { stdio: "inherit" }); - console.log("✅ 数据库迁移完成"); - } catch (error) { - console.error("❌ 数据库迁移失败:", error.message); - process.exit(1); - } + try { + console.log("🔄 执行数据库迁移..."); + execSync("npx prisma migrate deploy", {stdio: "inherit"}); + console.log("✅ 数据库迁移完成"); + } catch (error) { + console.error("❌ 数据库迁移失败:", error.message); + process.exit(1); + } } // 🧱 数据库初始化函数 function setupDatabase() { - try { - // 执行数据库迁移 - runDatabaseMigration(); - } catch (error) { - console.error("❌ 数据库初始化失败:", error.message); - process.exit(1); - } + try { + // 执行数据库迁移 + runDatabaseMigration(); + } catch (error) { + console.error("❌ 数据库初始化失败:", error.message); + process.exit(1); + } } // 🔨 本地构建函数 function buildLocal() { - try { - // 确保数据库迁移已执行 - runDatabaseMigration(); - execSync("npm install", { stdio: "inherit" }); // 安装依赖 - execSync("npx prisma generate", { stdio: "inherit" }); // 生成 Prisma 客户端 - console.log("✅ 构建完成"); - } catch (error) { - console.error("❌ 构建失败:", error.message); - process.exit(1); - } + try { + // 确保数据库迁移已执行 + runDatabaseMigration(); + execSync("npm install", {stdio: "inherit"}); // 安装依赖 + execSync("npx prisma generate", {stdio: "inherit"}); // 生成 Prisma 客户端 + console.log("✅ 构建完成"); + } catch (error) { + console.error("❌ 构建失败:", error.message); + process.exit(1); + } } // 🚀 启动服务函数 function startServer() { - try { - execSync("npm run start", { stdio: "inherit" }); // 启动项目 - } catch (error) { - console.error("❌ 服务启动失败:", error.message); - process.exit(1); - } + try { + execSync("npm run start", {stdio: "inherit"}); // 启动项目 + } catch (error) { + console.error("❌ 服务启动失败:", error.message); + process.exit(1); + } } // ▶️ 执行 Prisma CLI 命令函数 function runPrismaCommand(args) { - try { - const command = `npx prisma ${args.join(" ")}`; - execSync(command, { stdio: "inherit" }); - } catch (error) { - console.error("❌ Prisma 命令执行失败:", error.message); - process.exit(1); - } + try { + const command = `npx prisma ${args.join(" ")}`; + execSync(command, {stdio: "inherit"}); + } catch (error) { + console.error("❌ Prisma 命令执行失败:", error.message); + process.exit(1); + } } // 🧠 主函数,根据命令行参数判断执行哪种流程 async function main() { - const args = process.argv.slice(2); // 获取命令行参数 - if (args[0] === "prisma") { - // 如果输入的是 prisma 命令,则执行 prisma 子命令 - runPrismaCommand(args.slice(1)); - } else { - // 否则按默认流程:初始化 → 构建 → 启动服务 - setupDatabase(); - buildLocal(); - startServer(); - } + const args = process.argv.slice(2); // 获取命令行参数 + if (args[0] === "prisma") { + // 如果输入的是 prisma 命令,则执行 prisma 子命令 + runPrismaCommand(args.slice(1)); + } else { + // 否则按默认流程:初始化 → 构建 → 启动服务 + setupDatabase(); + buildLocal(); + startServer(); + } } // 🚨 捕捉主函数异常 main().catch((error) => { - console.error("❌ 脚本执行失败:", error); - process.exit(1); + console.error("❌ 脚本执行失败:", error); + process.exit(1); }); diff --git a/cli/get-token-callback.js b/cli/get-token-callback.js index 4fc9bea..70574d8 100644 --- a/cli/get-token-callback.js +++ b/cli/get-token-callback.js @@ -13,127 +13,127 @@ import http from 'http'; import url from 'url'; -import { randomBytes } from 'crypto'; +import {randomBytes} from 'crypto'; // 配置 const CONFIG = { - // API服务器地址 - baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', - // 站点密钥 - siteKey: process.env.SITE_KEY || '', - // 应用ID - appId: process.env.APP_ID || '1', - // 授权页面地址(Classworks前端) - authPageUrl: process.env.FRONTEND_URL, - // 本地回调服务器端口 - callbackPort: process.env.CALLBACK_PORT || '8080', - // 回调路径 - callbackPath: '/callback', - // 超时时间(秒) - timeout: 300, + // API服务器地址 + baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', + // 站点密钥 + siteKey: process.env.SITE_KEY || '', + // 应用ID + appId: process.env.APP_ID || '1', + // 授权页面地址(Classworks前端) + authPageUrl: process.env.FRONTEND_URL, + // 本地回调服务器端口 + callbackPort: process.env.CALLBACK_PORT || '8080', + // 回调路径 + callbackPath: '/callback', + // 超时时间(秒) + timeout: 300, }; // 颜色输出 const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', }; function log(message, color = '') { - console.log(`${color}${message}${colors.reset}`); + console.log(`${color}${message}${colors.reset}`); } function logSuccess(message) { - log(`✓ ${message}`, colors.green); + log(`✓ ${message}`, colors.green); } function logError(message) { - log(`✗ ${message}`, colors.red); + log(`✗ ${message}`, colors.red); } function logInfo(message) { - log(`ℹ ${message}`, colors.cyan); + log(`ℹ ${message}`, colors.cyan); } function logWarning(message) { - log(`⚠ ${message}`, colors.yellow); + log(`⚠ ${message}`, colors.yellow); } // HTTP请求封装 async function request(path, options = {}) { - const requestUrl = `${CONFIG.baseUrl}${path}`; - const headers = { - 'Content-Type': 'application/json', - ...options.headers, - }; + const requestUrl = `${CONFIG.baseUrl}${path}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; - if (CONFIG.siteKey) { - headers['X-Site-Key'] = CONFIG.siteKey; - } - - try { - const response = await fetch(requestUrl, { - ...options, - headers, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || `HTTP ${response.status}`); + if (CONFIG.siteKey) { + headers['X-Site-Key'] = CONFIG.siteKey; } - return data; - } catch (error) { - if (error.message.includes('fetch')) { - throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); + try { + const response = await fetch(requestUrl, { + ...options, + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + if (error.message.includes('fetch')) { + throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); + } + throw error; } - throw error; - } } // 生成随机状态字符串 function generateState() { - return randomBytes(16).toString('hex'); + return randomBytes(16).toString('hex'); } // 获取设备UUID async function getDeviceUuid() { - try { - const deviceInfo = await request('/device/info'); - return deviceInfo.uuid; - } catch (error) { - // 如果设备不存在,生成新的UUID - const uuid = randomBytes(16).toString('hex'); - logInfo(`生成新的设备UUID: ${uuid}`); - return uuid; - } + try { + const deviceInfo = await request('/device/info'); + return deviceInfo.uuid; + } catch (error) { + // 如果设备不存在,生成新的UUID + const uuid = randomBytes(16).toString('hex'); + logInfo(`生成新的设备UUID: ${uuid}`); + return uuid; + } } // 创建回调服务器 function createCallbackServer(state) { - return new Promise((resolve, reject) => { - let server; - let resolved = false; + return new Promise((resolve, reject) => { + let server; + let resolved = false; - const handleRequest = (req, res) => { - if (resolved) return; + const handleRequest = (req, res) => { + if (resolved) return; - const parsedUrl = url.parse(req.url, true); + const parsedUrl = url.parse(req.url, true); - if (parsedUrl.pathname === CONFIG.callbackPath) { - const { token, error, state: returnedState } = parsedUrl.query; + if (parsedUrl.pathname === CONFIG.callbackPath) { + const {token, error, state: returnedState} = parsedUrl.query; - // 验证状态参数 - if (returnedState !== state) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` + // 验证状态参数 + if (returnedState !== state) { + res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'}); + res.end(` @@ -151,15 +151,15 @@ function createCallbackServer(state) { `); - resolved = true; - server.close(); - reject(new Error('状态参数不匹配')); - return; - } + resolved = true; + server.close(); + reject(new Error('状态参数不匹配')); + return; + } - if (error) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` + if (error) { + res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'}); + res.end(` @@ -177,15 +177,15 @@ function createCallbackServer(state) { `); - resolved = true; - server.close(); - reject(new Error(error)); - return; - } + resolved = true; + server.close(); + reject(new Error(error)); + return; + } - if (token) { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` + if (token) { + res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); + res.end(` @@ -205,15 +205,15 @@ function createCallbackServer(state) { `); - resolved = true; - server.close(); - resolve(token); - return; - } + resolved = true; + server.close(); + resolve(token); + return; + } - // 如果没有token和error参数 - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` + // 如果没有token和error参数 + res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'}); + res.end(` @@ -231,191 +231,191 @@ function createCallbackServer(state) { `); - resolved = true; - server.close(); - reject(new Error('缺少必要的参数')); - } else { - // 404 for other paths - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('Not Found'); - } - }; + resolved = true; + server.close(); + reject(new Error('缺少必要的参数')); + } else { + // 404 for other paths + res.writeHead(404, {'Content-Type': 'text/plain'}); + res.end('Not Found'); + } + }; - server = http.createServer(handleRequest); + server = http.createServer(handleRequest); - server.listen(CONFIG.callbackPort, (err) => { - if (err) { - reject(err); - } else { - logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); - } + server.listen(CONFIG.callbackPort, (err) => { + if (err) { + reject(err); + } else { + logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); + } + }); + + // 设置超时 + setTimeout(() => { + if (!resolved) { + resolved = true; + server.close(); + reject(new Error('授权超时')); + } + }, CONFIG.timeout * 1000); + + server.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); }); - - // 设置超时 - setTimeout(() => { - if (!resolved) { - resolved = true; - server.close(); - reject(new Error('授权超时')); - } - }, CONFIG.timeout * 1000); - - server.on('error', (err) => { - if (!resolved) { - resolved = true; - reject(err); - } - }); - }); } // 打开浏览器 async function openBrowser(url) { - const { spawn } = await import('child_process'); + const {spawn} = await import('child_process'); - let command; - let args; + let command; + let args; - if (process.platform === 'win32') { - command = 'cmd'; - args = ['/c', 'start', url]; - } else if (process.platform === 'darwin') { - command = 'open'; - args = [url]; - } else { - command = 'xdg-open'; - args = [url]; - } + if (process.platform === 'win32') { + command = 'cmd'; + args = ['/c', 'start', url]; + } else if (process.platform === 'darwin') { + command = 'open'; + args = [url]; + } else { + command = 'xdg-open'; + args = [url]; + } - try { - spawn(command, args, { detached: true, stdio: 'ignore' }); - logSuccess('已尝试打开浏览器'); - } catch (error) { - logWarning('无法自动打开浏览器,请手动打开授权链接'); - } + try { + spawn(command, args, {detached: true, stdio: 'ignore'}); + logSuccess('已尝试打开浏览器'); + } catch (error) { + logWarning('无法自动打开浏览器,请手动打开授权链接'); + } } // 显示授权信息 function displayAuthInfo(authUrl, deviceUuid, state) { - console.log('\n' + '='.repeat(60)); - log(` 请访问以下地址完成授权:`, colors.bright); - console.log(''); - log(` ${authUrl}`, colors.cyan + colors.bright); - console.log(''); - log(` 设备UUID: ${deviceUuid}`, colors.green); - log(` 状态参数: ${state}`, colors.dim); - console.log('='.repeat(60)); - logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); - logInfo(`API服务器: ${CONFIG.baseUrl}`); - logInfo(`超时时间: ${CONFIG.timeout} 秒`); - console.log(''); + console.log('\n' + '='.repeat(60)); + log(` 请访问以下地址完成授权:`, colors.bright); + console.log(''); + log(` ${authUrl}`, colors.cyan + colors.bright); + console.log(''); + log(` 设备UUID: ${deviceUuid}`, colors.green); + log(` 状态参数: ${state}`, colors.dim); + console.log('='.repeat(60)); + logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); + logInfo(`API服务器: ${CONFIG.baseUrl}`); + logInfo(`超时时间: ${CONFIG.timeout} 秒`); + console.log(''); } // 保存令牌到文件 async function saveToken(token) { - const fs = await import('fs'); - const path = await import('path'); - const os = await import('os'); + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); - const tokenDir = path.join(os.homedir(), '.classworks'); - const tokenFile = path.join(tokenDir, 'token-callback.txt'); + const tokenDir = path.join(os.homedir(), '.classworks'); + const tokenFile = path.join(tokenDir, 'token-callback.txt'); - try { - // 确保目录存在 - if (!fs.existsSync(tokenDir)) { - fs.mkdirSync(tokenDir, { recursive: true }); + try { + // 确保目录存在 + if (!fs.existsSync(tokenDir)) { + fs.mkdirSync(tokenDir, {recursive: true}); + } + + // 写入令牌 + fs.writeFileSync(tokenFile, token, 'utf8'); + logSuccess(`令牌已保存到: ${tokenFile}`); + } catch (error) { + logWarning(`无法保存令牌到文件: ${error.message}`); + logInfo('您可以手动保存令牌'); } - - // 写入令牌 - fs.writeFileSync(tokenFile, token, 'utf8'); - logSuccess(`令牌已保存到: ${tokenFile}`); - } catch (error) { - logWarning(`无法保存令牌到文件: ${error.message}`); - logInfo('您可以手动保存令牌'); - } } // 主函数 async function main() { - console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n'); + console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n'); - try { - // 检查配置 - if (!CONFIG.siteKey) { - logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); - logInfo('设置方法: export SITE_KEY=your-site-key'); - console.log(''); + try { + // 检查配置 + if (!CONFIG.siteKey) { + logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); + logInfo('设置方法: export SITE_KEY=your-site-key'); + console.log(''); + } + + // 1. 获取设备UUID + logInfo('正在获取设备UUID...'); + const deviceUuid = await getDeviceUuid(); + logSuccess(`设备UUID: ${deviceUuid}`); + + // 2. 生成状态参数 + const state = generateState(); + + // 3. 构建回调URL + const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`; + + // 4. 构建授权URL + const authUrl = new URL(CONFIG.authPageUrl); + authUrl.searchParams.set('app_id', CONFIG.appId); + authUrl.searchParams.set('mode', 'callback'); + authUrl.searchParams.set('callback_url', callbackUrl); + authUrl.searchParams.set('state', state); + + // 5. 显示授权信息 + displayAuthInfo(authUrl.toString(), deviceUuid, state); + + // 6. 启动回调服务器 + logInfo('正在启动回调服务器...'); + const serverPromise = createCallbackServer(state); + + // 7. 打开浏览器 + logInfo('正在尝试打开浏览器...'); + await openBrowser(authUrl.toString()); + + // 8. 等待授权完成 + logInfo('等待授权完成...\n'); + const token = await serverPromise; + + // 9. 显示令牌 + console.log('\n' + '='.repeat(50)); + logSuccess('授权成功!令牌获取完成'); + console.log('='.repeat(50)); + console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset); + log(token, colors.green); + console.log(''); + + // 10. 保存令牌 + await saveToken(token); + + // 11. 使用示例 + console.log('\n' + colors.bright + '使用示例:' + colors.reset); + console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`); + console.log(''); + + process.exit(0); + } catch (error) { + console.log(''); + logError(`错误: ${error.message}`); + + // 提供一些常见问题的解决方案 + if (error.message.includes('EADDRINUSE')) { + logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`); + logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`); + } else if (error.message.includes('无法连接到服务器')) { + logInfo('请检查API服务器是否正在运行'); + logInfo(`当前API地址: ${CONFIG.baseUrl}`); + } else if (error.message.includes('授权超时')) { + logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`); + logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js'); + } + + console.log(''); + process.exit(1); } - - // 1. 获取设备UUID - logInfo('正在获取设备UUID...'); - const deviceUuid = await getDeviceUuid(); - logSuccess(`设备UUID: ${deviceUuid}`); - - // 2. 生成状态参数 - const state = generateState(); - - // 3. 构建回调URL - const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`; - - // 4. 构建授权URL - const authUrl = new URL(CONFIG.authPageUrl); - authUrl.searchParams.set('app_id', CONFIG.appId); - authUrl.searchParams.set('mode', 'callback'); - authUrl.searchParams.set('callback_url', callbackUrl); - authUrl.searchParams.set('state', state); - - // 5. 显示授权信息 - displayAuthInfo(authUrl.toString(), deviceUuid, state); - - // 6. 启动回调服务器 - logInfo('正在启动回调服务器...'); - const serverPromise = createCallbackServer(state); - - // 7. 打开浏览器 - logInfo('正在尝试打开浏览器...'); - await openBrowser(authUrl.toString()); - - // 8. 等待授权完成 - logInfo('等待授权完成...\n'); - const token = await serverPromise; - - // 9. 显示令牌 - console.log('\n' + '='.repeat(50)); - logSuccess('授权成功!令牌获取完成'); - console.log('='.repeat(50)); - console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset); - log(token, colors.green); - console.log(''); - - // 10. 保存令牌 - await saveToken(token); - - // 11. 使用示例 - console.log('\n' + colors.bright + '使用示例:' + colors.reset); - console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`); - console.log(''); - - process.exit(0); - } catch (error) { - console.log(''); - logError(`错误: ${error.message}`); - - // 提供一些常见问题的解决方案 - if (error.message.includes('EADDRINUSE')) { - logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`); - logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`); - } else if (error.message.includes('无法连接到服务器')) { - logInfo('请检查API服务器是否正在运行'); - logInfo(`当前API地址: ${CONFIG.baseUrl}`); - } else if (error.message.includes('授权超时')) { - logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`); - logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js'); - } - - console.log(''); - process.exit(1); - } } // 运行 diff --git a/cli/get-token.js b/cli/get-token.js index 786eeed..16ed4f2 100644 --- a/cli/get-token.js +++ b/cli/get-token.js @@ -10,223 +10,221 @@ * 或配置为可执行:chmod +x cli/get-token.js && ./cli/get-token.js */ -import readline from 'readline'; - // 配置 const CONFIG = { - // API服务器地址 - baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', - // 站点密钥 - siteKey: process.env.SITE_KEY || '', - // 应用ID - appId: process.env.APP_ID || '1', - // 授权页面地址(Classworks前端) - authPageUrl: process.env.FRONTEND_URL, - // 轮询间隔(秒) - pollInterval: 3, - // 最大轮询次数 - maxPolls: 100, + // API服务器地址 + baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', + // 站点密钥 + siteKey: process.env.SITE_KEY || '', + // 应用ID + appId: process.env.APP_ID || '1', + // 授权页面地址(Classworks前端) + authPageUrl: process.env.FRONTEND_URL, + // 轮询间隔(秒) + pollInterval: 3, + // 最大轮询次数 + maxPolls: 100, }; // 颜色输出 const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', }; function log(message, color = '') { - console.log(`${color}${message}${colors.reset}`); + console.log(`${color}${message}${colors.reset}`); } function logSuccess(message) { - log(`✓ ${message}`, colors.green); + log(`✓ ${message}`, colors.green); } function logError(message) { - log(`✗ ${message}`, colors.red); + log(`✗ ${message}`, colors.red); } function logInfo(message) { - log(`ℹ ${message}`, colors.cyan); + log(`ℹ ${message}`, colors.cyan); } function logWarning(message) { - log(`⚠ ${message}`, colors.yellow); + log(`⚠ ${message}`, colors.yellow); } // HTTP请求封装 async function request(path, options = {}) { - const url = `${CONFIG.baseUrl}${path}`; - const headers = { - 'Content-Type': 'application/json', - ...options.headers, - }; + const url = `${CONFIG.baseUrl}${path}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; - if (CONFIG.siteKey) { - headers['X-Site-Key'] = CONFIG.siteKey; - } - - try { - const response = await fetch(url, { - ...options, - headers, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || `HTTP ${response.status}`); + if (CONFIG.siteKey) { + headers['X-Site-Key'] = CONFIG.siteKey; } - return data; - } catch (error) { - if (error.message.includes('fetch')) { - throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); + try { + const response = await fetch(url, { + ...options, + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + if (error.message.includes('fetch')) { + throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); + } + throw error; } - throw error; - } } // 生成设备代码 async function generateDeviceCode() { - logInfo('正在生成设备授权码...'); - const data = await request('/auth/device/code', { - method: 'POST', - }); - return data; + logInfo('正在生成设备授权码...'); + const data = await request('/auth/device/code', { + method: 'POST', + }); + return data; } // 轮询获取令牌 async function pollForToken(deviceCode) { - let polls = 0; + let polls = 0; - return new Promise((resolve, reject) => { - const poll = async () => { - polls++; + return new Promise((resolve, reject) => { + const poll = async () => { + polls++; - if (polls > CONFIG.maxPolls) { - reject(new Error('轮询超时,请重试')); - return; - } + if (polls > CONFIG.maxPolls) { + reject(new Error('轮询超时,请重试')); + return; + } - try { - const data = await request(`/auth/device/token?device_code=${deviceCode}`); + try { + const data = await request(`/auth/device/token?device_code=${deviceCode}`); - if (data.status === 'success') { - resolve(data.token); - } else if (data.status === 'expired') { - reject(new Error('设备代码已过期')); - } else if (data.status === 'pending') { - // 继续轮询 - log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim); - setTimeout(poll, CONFIG.pollInterval * 1000); - } - } catch (error) { - reject(error); - } - }; + if (data.status === 'success') { + resolve(data.token); + } else if (data.status === 'expired') { + reject(new Error('设备代码已过期')); + } else if (data.status === 'pending') { + // 继续轮询 + log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim); + setTimeout(poll, CONFIG.pollInterval * 1000); + } + } catch (error) { + reject(error); + } + }; - // 开始轮询 - poll(); - }); + // 开始轮询 + poll(); + }); } // 显示设备代码和授权链接 function displayDeviceCode(deviceCode, expiresIn) { - console.log('\n' + '='.repeat(60)); - log(` 请访问以下地址完成授权:`, colors.bright); - console.log(''); + console.log('\n' + '='.repeat(60)); + log(` 请访问以下地址完成授权:`, colors.bright); + console.log(''); - // 构建授权URL - const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`; - log(` ${authUrl}`, colors.cyan + colors.bright); - console.log(''); - log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright); - console.log('='.repeat(60)); - logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`); - logInfo(`API服务器: ${CONFIG.baseUrl}`); - console.log(''); + // 构建授权URL + const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`; + log(` ${authUrl}`, colors.cyan + colors.bright); + console.log(''); + log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright); + console.log('='.repeat(60)); + logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`); + logInfo(`API服务器: ${CONFIG.baseUrl}`); + console.log(''); } // 保存令牌到文件 async function saveToken(token) { - const fs = await import('fs'); - const path = await import('path'); - const os = await import('os'); + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); - const tokenDir = path.join(os.homedir(), '.classworks'); - const tokenFile = path.join(tokenDir, 'token.txt'); + const tokenDir = path.join(os.homedir(), '.classworks'); + const tokenFile = path.join(tokenDir, 'token.txt'); - try { - // 确保目录存在 - if (!fs.existsSync(tokenDir)) { - fs.mkdirSync(tokenDir, { recursive: true }); + try { + // 确保目录存在 + if (!fs.existsSync(tokenDir)) { + fs.mkdirSync(tokenDir, {recursive: true}); + } + + // 写入令牌 + fs.writeFileSync(tokenFile, token, 'utf8'); + logSuccess(`令牌已保存到: ${tokenFile}`); + } catch (error) { + logWarning(`无法保存令牌到文件: ${error.message}`); + logInfo('您可以手动保存令牌'); } - - // 写入令牌 - fs.writeFileSync(tokenFile, token, 'utf8'); - logSuccess(`令牌已保存到: ${tokenFile}`); - } catch (error) { - logWarning(`无法保存令牌到文件: ${error.message}`); - logInfo('您可以手动保存令牌'); - } } // 主函数 async function main() { - console.log('\n' + colors.cyan + colors.bright + '设备授权流程 - 令牌获取工具' + colors.reset + '\n'); + console.log('\n' + colors.cyan + colors.bright + '设备授权流程 - 令牌获取工具' + colors.reset + '\n'); - try { - // 检查配置 - if (!CONFIG.siteKey) { - logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); - logInfo('设置方法: export SITE_KEY=your-site-key'); - console.log(''); + try { + // 检查配置 + if (!CONFIG.siteKey) { + logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); + logInfo('设置方法: export SITE_KEY=your-site-key'); + console.log(''); + } + + // 1. 生成设备代码 + const {device_code, expires_in} = await generateDeviceCode(); + logSuccess('设备授权码生成成功!'); + + // 2. 显示设备代码和授权链接 + displayDeviceCode(device_code, expires_in); + + // 3. 提示用户授权 + logInfo('请在浏览器中打开上述地址,或在授权页面手动输入设备代码'); + logInfo('等待授权中...\n'); + + // 4. 轮询获取令牌 + const token = await pollForToken(device_code); + + // 5. 显示令牌 + console.log('\n' + '='.repeat(50)); + logSuccess('授权成功!令牌获取完成'); + console.log('='.repeat(50)); + console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset); + log(token, colors.green); + console.log(''); + + // 6. 保存令牌 + await saveToken(token); + + // 7. 使用示例 + console.log('\n' + colors.bright + '使用示例:' + colors.reset); + console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`); + console.log(''); + + process.exit(0); + } catch (error) { + console.log(''); + logError(`错误: ${error.message}`); + console.log(''); + process.exit(1); } - - // 1. 生成设备代码 - const { device_code, expires_in } = await generateDeviceCode(); - logSuccess('设备授权码生成成功!'); - - // 2. 显示设备代码和授权链接 - displayDeviceCode(device_code, expires_in); - - // 3. 提示用户授权 - logInfo('请在浏览器中打开上述地址,或在授权页面手动输入设备代码'); - logInfo('等待授权中...\n'); - - // 4. 轮询获取令牌 - const token = await pollForToken(device_code); - - // 5. 显示令牌 - console.log('\n' + '='.repeat(50)); - logSuccess('授权成功!令牌获取完成'); - console.log('='.repeat(50)); - console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset); - log(token, colors.green); - console.log(''); - - // 6. 保存令牌 - await saveToken(token); - - // 7. 使用示例 - console.log('\n' + colors.bright + '使用示例:' + colors.reset); - console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`); - console.log(''); - - process.exit(0); - } catch (error) { - console.log(''); - logError(`错误: ${error.message}`); - console.log(''); - process.exit(1); - } } // 运行 diff --git a/config/oauth.js b/config/oauth.js index 2735a12..53175a4 100644 --- a/config/oauth.js +++ b/config/oauth.js @@ -1,82 +1,82 @@ // OAuth 提供者配置 export const oauthProviders = { - github: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - authorizationURL: "https://github.com/login/oauth/authorize", - tokenURL: "https://github.com/login/oauth/access_token", - userInfoURL: "https://api.github.com/user", - scope: "read:user user:email", - // 展示相关 - name: "GitHub", - displayName: "GitHub", - icon: "github", - color: "#24292e", - description: "使用 GitHub 账号登录", - website: "https://github.com", - }, - zerocat: { - clientId: process.env.ZEROCAT_CLIENT_ID, - clientSecret: process.env.ZEROCAT_CLIENT_SECRET, - authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize", - tokenURL: "https://zerocat-api.houlangs.com/oauth/token", - userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo", - scope: "user:basic user:email", - // 展示相关 - name: "ZeroCat", - displayName: "ZeroCat", - icon: "zerocat", - color: "#415f91", - description: "使用 ZeroCat 账号登录", - website: "https://zerocat.dev", - }, - stcn: { - // STCN(Casdoor)- 标准 OIDC Provider - clientId: process.env.STCN_CLIENT_ID, - clientSecret: process.env.STCN_CLIENT_SECRET, - // Casdoor 标准端点 - authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize", - tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token", - userInfoURL: "https://auth.smart-teach.cn/api/userinfo", - scope: "openid profile email offline_access", - // 展示相关 - name: "stcn", - displayName: "智教联盟账户", - icon: "casdoor", - color: "#1068af", - description: "使用智教联盟账户登录", - website: "https://auth.smart-teach.cn", - tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交 - }, - hly: { - // 厚浪云(Logto) - OIDC Provider - clientId: process.env.HLY_CLIENT_ID, - clientSecret: process.env.HLY_CLIENT_SECRET, - authorizationURL: "https://oauth.houlang.cloud/oidc/auth", - tokenURL: "https://oauth.houlang.cloud/oidc/token", - userInfoURL: "https://oauth.houlang.cloud/oidc/me", - scope: "openid profile email offline_access", - // 展示相关 - name: "厚浪云", - displayName: "厚浪云", - icon: "logto", - color: "#2d53f8", - textColor: "#ffffff", - order: 40, - description: "使用厚浪云账号登录", - website: "https://houlang.cloud", - pkce: true, // 启用PKCE支持 - }, + github: { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + authorizationURL: "https://github.com/login/oauth/authorize", + tokenURL: "https://github.com/login/oauth/access_token", + userInfoURL: "https://api.github.com/user", + scope: "read:user user:email", + // 展示相关 + name: "GitHub", + displayName: "GitHub", + icon: "github", + color: "#24292e", + description: "使用 GitHub 账号登录", + website: "https://github.com", + }, + zerocat: { + clientId: process.env.ZEROCAT_CLIENT_ID, + clientSecret: process.env.ZEROCAT_CLIENT_SECRET, + authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize", + tokenURL: "https://zerocat-api.houlangs.com/oauth/token", + userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo", + scope: "user:basic user:email", + // 展示相关 + name: "ZeroCat", + displayName: "ZeroCat", + icon: "zerocat", + color: "#415f91", + description: "使用 ZeroCat 账号登录", + website: "https://zerocat.dev", + }, + stcn: { + // STCN(Casdoor)- 标准 OIDC Provider + clientId: process.env.STCN_CLIENT_ID, + clientSecret: process.env.STCN_CLIENT_SECRET, + // Casdoor 标准端点 + authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize", + tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token", + userInfoURL: "https://auth.smart-teach.cn/api/userinfo", + scope: "openid profile email offline_access", + // 展示相关 + name: "stcn", + displayName: "智教联盟账户", + icon: "casdoor", + color: "#1068af", + description: "使用智教联盟账户登录", + website: "https://auth.smart-teach.cn", + tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交 + }, + hly: { + // 厚浪云(Logto) - OIDC Provider + clientId: process.env.HLY_CLIENT_ID, + clientSecret: process.env.HLY_CLIENT_SECRET, + authorizationURL: "https://oauth.houlang.cloud/oidc/auth", + tokenURL: "https://oauth.houlang.cloud/oidc/token", + userInfoURL: "https://oauth.houlang.cloud/oidc/me", + scope: "openid profile email offline_access", + // 展示相关 + name: "厚浪云", + displayName: "厚浪云", + icon: "logto", + color: "#2d53f8", + textColor: "#ffffff", + order: 40, + description: "使用厚浪云账号登录", + website: "https://houlang.cloud", + pkce: true, // 启用PKCE支持 + }, }; // 获取OAuth回调URL export function getCallbackURL(provider) { - const baseUrl = process.env.BASE_URL; - return `${baseUrl}/accounts/oauth/${provider}/callback`; + const baseUrl = process.env.BASE_URL; + return `${baseUrl}/accounts/oauth/${provider}/callback`; } // 生成随机state参数 export function generateState() { - return Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); } \ No newline at end of file diff --git a/middleware/device.js b/middleware/device.js index 89545f8..2a8c474 100644 --- a/middleware/device.js +++ b/middleware/device.js @@ -7,9 +7,9 @@ * 3. passwordMiddleware - 验证设备密码 */ -import { PrismaClient } from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; -import { verifyDevicePassword } from "../utils/crypto.js"; +import {verifyDevicePassword} from "../utils/crypto.js"; const prisma = new PrismaClient(); @@ -18,20 +18,20 @@ const prisma = new PrismaClient(); * @param {number} deviceId - 设备ID */ async function createDefaultAutoAuth(deviceId) { - try { - // 创建默认的自动授权配置:不需要密码、类型是classroom(一体机) - await prisma.autoAuth.create({ - data: { - deviceId: deviceId, - password: null, // 无密码 - deviceType: "classroom", // 一体机类型 - isReadOnly: false, // 非只读 - }, - }); - } catch (error) { - console.error('创建默认自动登录配置失败:', error); - // 这里不抛出错误,避免影响设备创建流程 - } + try { + // 创建默认的自动授权配置:不需要密码、类型是classroom(一体机) + await prisma.autoAuth.create({ + data: { + deviceId: deviceId, + password: null, // 无密码 + deviceType: "classroom", // 一体机类型 + isReadOnly: false, // 非只读 + }, + }); + } catch (error) { + console.error('创建默认自动登录配置失败:', error); + // 这里不抛出错误,避免影响设备创建流程 + } } /** @@ -46,36 +46,36 @@ async function createDefaultAutoAuth(deviceId) { * router.get('/path/:deviceUuid', deviceMiddleware, handler) */ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { - const deviceUuid = req.params.deviceUuid || req.body.deviceUuid; + const deviceUuid = req.params.deviceUuid || req.body.deviceUuid; - if (!deviceUuid) { - return next(errors.createError(400, "缺少设备UUID")); - } + if (!deviceUuid) { + return next(errors.createError(400, "缺少设备UUID")); + } - // 查找或创建设备 - let device = await prisma.device.findUnique({ - where: { uuid: deviceUuid }, - }); - - if (!device) { - // 设备不存在,自动创建 - device = await prisma.device.create({ - data: { - uuid: deviceUuid, - name: null, - password: null, - passwordHint: null, - accountId: null, - }, + // 查找或创建设备 + let device = await prisma.device.findUnique({ + where: {uuid: deviceUuid}, }); - // 为新创建的设备添加默认的自动登录配置 - await createDefaultAutoAuth(device.id); - } + if (!device) { + // 设备不存在,自动创建 + device = await prisma.device.create({ + data: { + uuid: deviceUuid, + name: null, + password: null, + passwordHint: null, + accountId: null, + }, + }); - // 将设备信息存储到res.locals - res.locals.device = device; - next(); + // 为新创建的设备添加默认的自动登录配置 + await createDefaultAutoAuth(device.id); + } + + // 将设备信息存储到res.locals + res.locals.device = device; + next(); }); /** @@ -89,24 +89,24 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { * router.get('/path/:deviceUuid', deviceInfoMiddleware, handler) */ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => { - const deviceUuid = req.params.deviceUuid ; + const deviceUuid = req.params.deviceUuid; - if (!deviceUuid) { - return next(errors.createError(400, "缺少设备UUID")); - } + if (!deviceUuid) { + return next(errors.createError(400, "缺少设备UUID")); + } - // 查找设备 - const device = await prisma.device.findUnique({ - where: { uuid: deviceUuid }, - }); + // 查找设备 + const device = await prisma.device.findUnique({ + where: {uuid: deviceUuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 将设备信息存储到res.locals - res.locals.device = device; - next(); + // 将设备信息存储到res.locals + res.locals.device = device; + next(); }); /** @@ -122,29 +122,29 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => * router.post('/path', deviceMiddleware, passwordMiddleware, handler) */ export const passwordMiddleware = errors.catchAsync(async (req, res, next) => { - const device = res.locals.device; - const { password } = req.body; + const device = res.locals.device; + const {password} = req.body; - if (!device) { - return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware")); - } - - // 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证 - if (device.accountId && req.account && req.account.id === device.accountId) { - return next(); - } - - // 如果设备有密码,验证密码 - if (device.password) { - if (!password) { - return next(errors.createError(401, "设备需要密码")); + if (!device) { + return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware")); } - const isValid = await verifyDevicePassword(password, device.password); - if (!isValid) { - return next(errors.createError(401, "密码错误")); + // 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证 + if (device.accountId && req.account && req.account.id === device.accountId) { + return next(); } - } - next(); + // 如果设备有密码,验证密码 + if (device.password) { + if (!password) { + return next(errors.createError(401, "设备需要密码")); + } + + const isValid = await verifyDevicePassword(password, device.password); + if (!isValid) { + return next(errors.createError(401, "密码错误")); + } + } + + next(); }); \ No newline at end of file diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js index 5129cbd..2b09430 100644 --- a/middleware/errorHandler.js +++ b/middleware/errorHandler.js @@ -1,51 +1,51 @@ -import { isDevelopment } from "../utils/config.js"; +import {isDevelopment} from "../utils/config.js"; const errorHandler = (err, req, res, next) => { - // 判断响应是否已经发送 - if (res.headersSent) { - return next(err); - } - console.error(err); - - try { - if (isDevelopment) { - // 输出错误信息到控制台 - console.error("Error occurred:"); - console.error(err); + // 判断响应是否已经发送 + if (res.headersSent) { + return next(err); } - // 提取错误信息 - const statusCode = err.statusCode || err.status || 500; - const message = err.message || "服务器错误"; - const details = err.details || null; - const code = err.code || undefined; + console.error(err); - // 返回统一格式的错误响应 - return res.status(statusCode).json({ - success: false, - message: message, - code: code, - details: details, - error: - process.env.NODE_ENV === "production" - ? undefined - : { - stack: err.stack, - originalError: err.originalError - ? err.originalError.message - : null, - }, - }); - } catch (handlerError) { - // 处理器本身出错的兜底方案 - console.error("Error in error handler:", handlerError); + try { + if (isDevelopment) { + // 输出错误信息到控制台 + console.error("Error occurred:"); + console.error(err); + } + // 提取错误信息 + const statusCode = err.statusCode || err.status || 500; + const message = err.message || "服务器错误"; + const details = err.details || null; + const code = err.code || undefined; - // 确保能返回响应 - return res.status(500).json({ - success: false, - message: "服务器错误", - details: "服务器处理错误时出现问题", - }); - } + // 返回统一格式的错误响应 + return res.status(statusCode).json({ + success: false, + message: message, + code: code, + details: details, + error: + process.env.NODE_ENV === "production" + ? undefined + : { + stack: err.stack, + originalError: err.originalError + ? err.originalError.message + : null, + }, + }); + } catch (handlerError) { + // 处理器本身出错的兜底方案 + console.error("Error in error handler:", handlerError); + + // 确保能返回响应 + return res.status(500).json({ + success: false, + message: "服务器错误", + details: "服务器处理错误时出现问题", + }); + } }; export default errorHandler; diff --git a/middleware/jwt-auth.js b/middleware/jwt-auth.js index 5d4a6e2..0f48f2b 100644 --- a/middleware/jwt-auth.js +++ b/middleware/jwt-auth.js @@ -6,9 +6,9 @@ * 适用于只需要账户验证的接口 */ -import { verifyAccessToken, validateAccountToken, generateAccessToken } from "../utils/tokenManager.js"; -import { verifyToken } from "../utils/jwt.js"; -import { PrismaClient } from "@prisma/client"; +import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js"; +import {verifyToken} from "../utils/jwt.js"; +import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; const prisma = new PrismaClient(); @@ -17,77 +17,77 @@ const prisma = new PrismaClient(); * 新的JWT认证中间件(支持refresh token系统) */ export const jwtAuth = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return next(errors.createError(401, "需要提供有效的JWT token")); - } - - const token = authHeader.substring(7); - try { - // 尝试使用新的token验证系统 - const decoded = verifyAccessToken(token); + const authHeader = req.headers.authorization; - // 验证账户并检查token版本 - const account = await validateAccountToken(decoded); - - // 将账户信息存储到res.locals - res.locals.account = account; - res.locals.tokenDecoded = decoded; - - // 检查token是否即将过期(剩余时间少于5分钟) - const now = Math.floor(Date.now() / 1000); - const timeUntilExpiry = decoded.exp - now; - - if (timeUntilExpiry < 300) { // 5分钟 = 300秒 - // 生成新的access token - const newAccessToken = generateAccessToken(account); - res.set('X-New-Access-Token', newAccessToken); - res.set('X-Token-Refreshed', 'true'); - } - - next(); - } catch (newTokenError) { - // 如果新token系统验证失败,尝试旧的验证方式(向后兼容) - try { - const decoded = verifyToken(token); - - // 从数据库获取账户信息 - const account = await prisma.account.findUnique({ - where: { id: decoded.accountId }, - }); - - if (!account) { - return next(errors.createError(401, "账户不存在")); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return next(errors.createError(401, "需要提供有效的JWT token")); } - // 将账户信息存储到res.locals - res.locals.account = account; - res.locals.tokenDecoded = decoded; - res.locals.isLegacyToken = true; // 标记为旧版token + const token = authHeader.substring(7); - next(); - } catch (legacyTokenError) { - // 两种验证方式都失败 - if (newTokenError.name === 'JsonWebTokenError' || legacyTokenError.name === 'JsonWebTokenError') { - return next(errors.createError(401, "无效的JWT token")); + try { + // 尝试使用新的token验证系统 + const decoded = verifyAccessToken(token); + + // 验证账户并检查token版本 + const account = await validateAccountToken(decoded); + + // 将账户信息存储到res.locals + res.locals.account = account; + res.locals.tokenDecoded = decoded; + + // 检查token是否即将过期(剩余时间少于5分钟) + const now = Math.floor(Date.now() / 1000); + const timeUntilExpiry = decoded.exp - now; + + if (timeUntilExpiry < 300) { // 5分钟 = 300秒 + // 生成新的access token + const newAccessToken = generateAccessToken(account); + res.set('X-New-Access-Token', newAccessToken); + res.set('X-Token-Refreshed', 'true'); + } + + next(); + } catch (newTokenError) { + // 如果新token系统验证失败,尝试旧的验证方式(向后兼容) + try { + const decoded = verifyToken(token); + + // 从数据库获取账户信息 + const account = await prisma.account.findUnique({ + where: {id: decoded.accountId}, + }); + + if (!account) { + return next(errors.createError(401, "账户不存在")); + } + + // 将账户信息存储到res.locals + res.locals.account = account; + res.locals.tokenDecoded = decoded; + res.locals.isLegacyToken = true; // 标记为旧版token + + next(); + } catch (legacyTokenError) { + // 两种验证方式都失败 + if (newTokenError.name === 'JsonWebTokenError' || legacyTokenError.name === 'JsonWebTokenError') { + return next(errors.createError(401, "无效的JWT token")); + } + + if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') { + // 统一的账户JWT过期返回 + // message: JWT_EXPIRED(用于客户端稳定识别) + // code: AUTH_JWT_EXPIRED(业务错误码) + return next(errors.createError(401, "JWT_EXPIRED", null, "AUTH_JWT_EXPIRED")); + } + + return next(errors.createError(401, "token验证失败")); + } } - - if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') { - // 统一的账户JWT过期返回 - // message: JWT_EXPIRED(用于客户端稳定识别) - // code: AUTH_JWT_EXPIRED(业务错误码) - return next(errors.createError(401, "JWT_EXPIRED", null, "AUTH_JWT_EXPIRED")); - } - - return next(errors.createError(401, "token验证失败")); - } + } catch (error) { + return next(errors.createError(500, "认证过程出错")); } - } catch (error) { - return next(errors.createError(500, "认证过程出错")); - } }; /** @@ -95,13 +95,13 @@ export const jwtAuth = async (req, res, next) => { * 如果提供了token则验证,没有提供则跳过 */ export const optionalJwtAuth = async (req, res, next) => { - const authHeader = req.headers.authorization; + const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - // 没有提供token,跳过认证 - return next(); - } + if (!authHeader || !authHeader.startsWith("Bearer ")) { + // 没有提供token,跳过认证 + return next(); + } - // 有token则进行验证 - return jwtAuth(req, res, next); + // 有token则进行验证 + return jwtAuth(req, res, next); }; \ No newline at end of file diff --git a/middleware/kvTokenAuth.js b/middleware/kvTokenAuth.js index e8e06be..d3efcae 100644 --- a/middleware/kvTokenAuth.js +++ b/middleware/kvTokenAuth.js @@ -5,7 +5,7 @@ * 适用于所有KV相关的接口 */ -import { PrismaClient } from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; const prisma = new PrismaClient(); @@ -15,35 +15,35 @@ const prisma = new PrismaClient(); * 从请求中提取token(支持多种方式),验证后将设备和应用信息注入到res.locals */ export const kvTokenAuth = async (req, res, next) => { - try { - // 从多种途径获取token - const token = extractToken(req); + try { + // 从多种途径获取token + const token = extractToken(req); - if (!token) { - return next(errors.createError(401, "需要提供有效的token")); + if (!token) { + return next(errors.createError(401, "需要提供有效的token")); + } + + // 查找token对应的应用安装信息 + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + include: { + device: true, + }, + }); + + if (!appInstall) { + return next(errors.createError(401, "无效的token")); + } + + // 将信息存储到res.locals供后续使用 + res.locals.device = appInstall.device; + res.locals.appInstall = appInstall; + res.locals.deviceId = appInstall.device.id; + res.locals.token = token; + next(); + } catch (error) { + next(error); } - - // 查找token对应的应用安装信息 - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { - device: true, - }, - }); - - if (!appInstall) { - return next(errors.createError(401, "无效的token")); - } - - // 将信息存储到res.locals供后续使用 - res.locals.device = appInstall.device; - res.locals.appInstall = appInstall; - res.locals.deviceId = appInstall.device.id; - res.locals.token = token; - next(); - } catch (error) { - next(error); - } }; /** @@ -54,18 +54,18 @@ export const kvTokenAuth = async (req, res, next) => { * 3. Body: token 或 apptoken */ function extractToken(req) { - // 优先从 Authorization header 提取 Bearer token(支持大小写) - const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization); - if (authHeader) { - const m = authHeader.match(/^Bearer\s+(.+)$/i); - if (m) return m[1]; - } + // 优先从 Authorization header 提取 Bearer token(支持大小写) + const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization); + if (authHeader) { + const m = authHeader.match(/^Bearer\s+(.+)$/i); + if (m) return m[1]; + } - return ( - req.headers["x-app-token"] || - req.query.token || - req.query.apptoken || - (req.body && req.body.token) || - (req.body && req.body.apptoken) - ); + return ( + req.headers["x-app-token"] || + req.query.token || + req.query.apptoken || + (req.body && req.body.token) || + (req.body && req.body.apptoken) + ); } \ No newline at end of file diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js index 2d7342f..c7da85b 100644 --- a/middleware/rateLimiter.js +++ b/middleware/rateLimiter.js @@ -2,119 +2,118 @@ import rateLimit from "express-rate-limit"; // 获取客户端真实IP的函数 export const getClientIp = (req) => { - return ( - req.headers["x-forwarded-for"] || - req.connection.remoteAddress || - req.socket.remoteAddress || - req.connection.socket?.remoteAddress || - "0.0.0.0" - ); + return ( + req.headers["x-forwarded-for"] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket?.remoteAddress || + "0.0.0.0" + ); }; // 从请求中提取Token的函数 const extractToken = (req) => { - return ( - req.headers["x-app-token"] || - req.query.apptoken || - req.body?.apptoken || - null - ); + return ( + req.headers["x-app-token"] || + req.query.apptoken || + req.body?.apptoken || + null + ); }; // 获取限速键:优先使用token,没有token则使用IP export const getRateLimitKey = (req) => { - const token = extractToken(req); - if (token) { - return `token:${token}`; - } - return `ip:${getClientIp(req)}`; + const token = extractToken(req); + if (token) { + return `token:${token}`; + } + return `ip:${getClientIp(req)}`; }; // 纯基于Token的keyGenerator,用于KV Token专用路由 // 这个函数假设token已经通过中间件设置在req对象上 export const getTokenOnlyKey = (req) => { - // 尝试从多个位置获取token - const token = - req.locals?.token || // 如果token被设置在req.locals中 - req.res?.locals?.token || // 如果token在res.locals中 - extractToken(req); // 从headers/query/body提取 + // 尝试从多个位置获取token + const token = + req.locals?.token || // 如果token被设置在req.locals中 + req.res?.locals?.token || // 如果token在res.locals中 + extractToken(req); // 从headers/query/body提取 - if (!token) { - // 如果没有token,返回一个特殊键用于统一限制 - return "no-token"; - } - return `token:${token}`; + if (!token) { + // 如果没有token,返回一个特殊键用于统一限制 + return "no-token"; + } + return `token:${token}`; }; // 创建一个中间件来将res.locals.token复制到req.locals.token,以便限速器使用 export const prepareTokenForRateLimit = (req, res, next) => { - if (res.locals.token) { - req.locals = req.locals || {}; - req.locals.token = res.locals.token; - } - next(); + if (res.locals.token) { + req.locals = req.locals || {}; + req.locals.token = res.locals.token; + } + next(); }; - // 认证相关路由限速器(防止暴力破解) export const authLimiter = rateLimit({ - windowMs: 30 * 60 * 1000, // 30分钟 - limit: 5, // 每个IP在windowMs时间内最多允许5次认证尝试 - standardHeaders: "draft-7", - legacyHeaders: false, - message: "认证请求过于频繁,请30分钟后再试", - keyGenerator: getClientIp, - skipSuccessfulRequests: true, // 成功的认证不计入限制 - skipFailedRequests: false, // 失败的认证计入限制 + windowMs: 30 * 60 * 1000, // 30分钟 + limit: 5, // 每个IP在windowMs时间内最多允许5次认证尝试 + standardHeaders: "draft-7", + legacyHeaders: false, + message: "认证请求过于频繁,请30分钟后再试", + keyGenerator: getClientIp, + skipSuccessfulRequests: true, // 成功的认证不计入限制 + skipFailedRequests: false, // 失败的认证计入限制 }); // === Token 专用限速器(更宽松的限制,纯基于Token) === // Token 读操作限速器 export const tokenReadLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1分钟 - limit: 1024, // 每个token在1分钟内最多1024次读操作 - standardHeaders: "draft-7", - legacyHeaders: false, - message: "读操作请求过于频繁,请稍后再试", - keyGenerator: getTokenOnlyKey, - skipSuccessfulRequests: false, - skipFailedRequests: false, + windowMs: 1 * 60 * 1000, // 1分钟 + limit: 1024, // 每个token在1分钟内最多1024次读操作 + standardHeaders: "draft-7", + legacyHeaders: false, + message: "读操作请求过于频繁,请稍后再试", + keyGenerator: getTokenOnlyKey, + skipSuccessfulRequests: false, + skipFailedRequests: false, }); // Token 写操作限速器 export const tokenWriteLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1分钟 - limit: 512, // 每个token在1分钟内最多512次写操作 - standardHeaders: "draft-7", - legacyHeaders: false, - message: "写操作请求过于频繁,请稍后再试", - keyGenerator: getTokenOnlyKey, - skipSuccessfulRequests: false, - skipFailedRequests: false, + windowMs: 1 * 60 * 1000, // 1分钟 + limit: 512, // 每个token在1分钟内最多512次写操作 + standardHeaders: "draft-7", + legacyHeaders: false, + message: "写操作请求过于频繁,请稍后再试", + keyGenerator: getTokenOnlyKey, + skipSuccessfulRequests: false, + skipFailedRequests: false, }); // Token 删除操作限速器 export const tokenDeleteLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1分钟 - limit: 256, // 每个token在1分钟内最多256次删除操作 - standardHeaders: "draft-7", - legacyHeaders: false, - message: "删除操作请求过于频繁,请稍后再试", - keyGenerator: getTokenOnlyKey, - skipSuccessfulRequests: false, - skipFailedRequests: false, + windowMs: 1 * 60 * 1000, // 1分钟 + limit: 256, // 每个token在1分钟内最多256次删除操作 + standardHeaders: "draft-7", + legacyHeaders: false, + message: "删除操作请求过于频繁,请稍后再试", + keyGenerator: getTokenOnlyKey, + skipSuccessfulRequests: false, + skipFailedRequests: false, }); // Token 批量操作限速器 export const tokenBatchLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1分钟 - limit: 128, // 每个token在1分钟内最多128次批量操作 - standardHeaders: "draft-7", - legacyHeaders: false, - message: "批量操作请求过于频繁,请稍后再试", - keyGenerator: getTokenOnlyKey, - skipSuccessfulRequests: false, - skipFailedRequests: false, + windowMs: 1 * 60 * 1000, // 1分钟 + limit: 128, // 每个token在1分钟内最多128次批量操作 + standardHeaders: "draft-7", + legacyHeaders: false, + message: "批量操作请求过于频繁,请稍后再试", + keyGenerator: getTokenOnlyKey, + skipSuccessfulRequests: false, + skipFailedRequests: false, }); diff --git a/middleware/uuidAuth.js b/middleware/uuidAuth.js index 7ccaeaf..e8d95e9 100644 --- a/middleware/uuidAuth.js +++ b/middleware/uuidAuth.js @@ -6,10 +6,10 @@ * 3. 适用于需要设备上下文的接口 */ -import { PrismaClient } from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; -import { verifyToken as verifyAccountJWT } from "../utils/jwt.js"; -import { verifyDevicePassword } from "../utils/crypto.js"; +import {verifyToken as verifyAccountJWT} from "../utils/jwt.js"; +import {verifyDevicePassword} from "../utils/crypto.js"; const prisma = new PrismaClient(); @@ -17,130 +17,131 @@ const prisma = new PrismaClient(); * UUID+密码/JWT混合认证中间件 */ export const uuidAuth = async (req, res, next) => { - try { - // 1. 获取UUID(必需) - const uuid = extractUuid(req); - if (!uuid) { - return next(errors.createError(400, "需要提供设备UUID")); - } + try { + // 1. 获取UUID(必需) + const uuid = extractUuid(req); + if (!uuid) { + return next(errors.createError(400, "需要提供设备UUID")); + } - // 2. 查找设备并存储到locals - const device = await prisma.device.findUnique({ - where: { uuid }, - }); - - if (!device) { - return next(errors.createError(404, "设备不存在")); - } - - // 存储设备信息到locals - res.locals.device = device; - res.locals.deviceId = device.id; - - // 3. 验证密码或JWT(二选一) - const password = extractPassword(req); - const jwt = extractJWT(req); - - if (jwt) { - // 验证账户JWT - try { - const accountPayload = await verifyAccountJWT(jwt); - const account = await prisma.account.findUnique({ - where: { id: accountPayload.accountId }, - include: { - devices: { - where: { uuid }, - select: { id: true } - } - } + // 2. 查找设备并存储到locals + const device = await prisma.device.findUnique({ + where: {uuid}, }); - if (!account) { - return next(errors.createError(401, "账户不存在")); + if (!device) { + return next(errors.createError(404, "设备不存在")); } - // 检查设备是否绑定到此账户 - if (account.devices.length === 0) { - return next(errors.createError(403, "设备未绑定到此账户")); + // 存储设备信息到locals + res.locals.device = device; + res.locals.deviceId = device.id; + + // 3. 验证密码或JWT(二选一) + const password = extractPassword(req); + const jwt = extractJWT(req); + + if (jwt) { + // 验证账户JWT + try { + const accountPayload = await verifyAccountJWT(jwt); + const account = await prisma.account.findUnique({ + where: {id: accountPayload.accountId}, + include: { + devices: { + where: {uuid}, + select: {id: true} + } + } + }); + + if (!account) { + return next(errors.createError(401, "账户不存在")); + } + + // 检查设备是否绑定到此账户 + if (account.devices.length === 0) { + return next(errors.createError(403, "设备未绑定到此账户")); + } + + res.locals.account = account; + res.locals.isAccountOwner = true; // 标记为账户拥有者 + return next(); + } catch (error) { + return next(errors.createError(401, "无效的JWT token")); + } + } else if (password) { + // 验证设备密码 + if (!device.password) { + return next(); // 如果设备未设置密码,允许无密码访问 + } + + const isValid = await verifyDevicePassword(password, device.password); + if (!isValid) { + return next(errors.createError(401, "密码错误")); + } + + return next(); + } else { + // 如果设备未设置密码,允许无密码访问 + if (!device.password) { + return next(); + } + return next(errors.createError(401, "需要提供密码或JWT token")); } - - res.locals.account = account; - res.locals.isAccountOwner = true; // 标记为账户拥有者 - return next(); - } catch (error) { - return next(errors.createError(401, "无效的JWT token")); - } - } else if (password) { - // 验证设备密码 - if (!device.password) { - return next(); // 如果设备未设置密码,允许无密码访问 - } - - const isValid = await verifyDevicePassword(password, device.password); - if (!isValid) { - return next(errors.createError(401, "密码错误")); - } - - return next(); - } else { - // 如果设备未设置密码,允许无密码访问 - if (!device.password) { - return next(); - } - return next(errors.createError(401, "需要提供密码或JWT token")); + } catch (error) { + next(error); } - } catch (error) { - next(error); - } }; -export const extractDeviceInfo = async (req,res,next) => { - var uuid= extractUuid(req); +export const extractDeviceInfo = async (req, res, next) => { + var uuid = extractUuid(req); - if (!uuid) { - throw errors.createError(400, "需要提供设备UUID"); - } - const device = await prisma.device.findUnique({ - where: { uuid }, - }); - if (!device) { - throw errors.createError(404, "设备不存在"); - } - res.locals.device = device; - res.locals.deviceId = device.id; - next(); + if (!uuid) { + throw errors.createError(400, "需要提供设备UUID"); + } + const device = await prisma.device.findUnique({ + where: {uuid}, + }); + if (!device) { + throw errors.createError(404, "设备不存在"); + } + res.locals.device = device; + res.locals.deviceId = device.id; + next(); } + /** * 从请求中提取UUID */ function extractUuid(req) { - return ( - req.headers["x-device-uuid"] || - req.query.uuid || - req.params.uuid || - req.params.deviceUuid || - (req.body && req.body.uuid) || - (req.body && req.body.deviceUuid) - ); + return ( + req.headers["x-device-uuid"] || + req.query.uuid || + req.params.uuid || + req.params.deviceUuid || + (req.body && req.body.uuid) || + (req.body && req.body.deviceUuid) + ); } /** * 从请求中提取密码 */ function extractPassword(req) { - return ( - req.headers["x-device-password"] || - req.query.password || - req.query.currentPassword - ); + return ( + req.headers["x-device-password"] || + req.query.password || + req.query.currentPassword + ); } /** * 从请求中提取JWT */ function extractJWT(req) { - const authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith("Bearer ")) { - return authHeader.substring(7); - } - return null; + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; } \ No newline at end of file diff --git a/public/auth-error.html b/public/auth-error.html index 484c20a..13438a9 100644 --- a/public/auth-error.html +++ b/public/auth-error.html @@ -1,166 +1,172 @@ - - - 登录失败 - + .help-text { + color: #6b7280; + font-size: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; + } + -
+
- - - + + +

登录失败

-
认证过程中出现错误
-
+
认证过程中出现错误
+
- 返回重试 + 返回重试
- 如果问题持续存在,请检查:
- • OAuth应用配置是否正确
- • 回调URL是否已添加到OAuth应用中
- • 环境变量是否配置正确 + 如果问题持续存在,请检查:
+ • OAuth应用配置是否正确
+ • 回调URL是否已添加到OAuth应用中
+ • 环境变量是否配置正确
-
+
- + \ No newline at end of file diff --git a/public/auth-success.html b/public/auth-success.html index 43edce7..f81bd9c 100644 --- a/public/auth-success.html +++ b/public/auth-success.html @@ -1,160 +1,160 @@ - - - 登录成功 - + .countdown { + color: #4f46e5; + font-weight: bold; + } + -
+
- - - + + +

登录成功

OAuth Provider

-
访问令牌
-
加载中...
+
访问令牌
+
加载中...
- 窗口将在 10 秒后自动关闭 + 窗口将在 10 秒后自动关闭
-
+
- + \ No newline at end of file diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css index 9453385..b993201 100644 --- a/public/stylesheets/style.css +++ b/public/stylesheets/style.css @@ -1,8 +1,8 @@ body { - padding: 50px; - font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; + padding: 50px; + font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; } a { - color: #00B7FF; + color: #00B7FF; } diff --git a/routes/accounts.js b/routes/accounts.js index 505546a..395a414 100644 --- a/routes/accounts.js +++ b/routes/accounts.js @@ -1,9 +1,9 @@ -import { Router } from "express"; -import { PrismaClient } from "@prisma/client"; +import {Router} from "express"; +import {PrismaClient} from "@prisma/client"; import crypto from "crypto"; -import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js"; -import { generateAccountToken, generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken } from "../utils/jwt.js"; -import { jwtAuth } from "../middleware/jwt-auth.js"; +import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js"; +import {generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken} from "../utils/jwt.js"; +import {jwtAuth} from "../middleware/jwt-auth.js"; import errors from "../utils/errors.js"; const router = Router(); @@ -14,27 +14,27 @@ const oauthStates = new Map(); // 生成PKCE code_verifier 和 code_challenge function generatePkcePair() { - const codeVerifier = crypto - .randomBytes(32) - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - const challenge = crypto - .createHash("sha256") - .update(codeVerifier) - .digest("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - return { codeVerifier, codeChallenge: challenge }; + const codeVerifier = crypto + .randomBytes(32) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const challenge = crypto + .createHash("sha256") + .update(codeVerifier) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return {codeVerifier, codeChallenge: challenge}; } /** * 生成安全的访问令牌 */ function generateAccessToken() { - return crypto.randomBytes(32).toString("hex"); + return crypto.randomBytes(32).toString("hex"); } /** @@ -42,35 +42,35 @@ function generateAccessToken() { * GET /accounts/oauth/providers */ router.get("/oauth/providers", (req, res) => { - let providers = []; + let providers = []; - for (const [key, config] of Object.entries(oauthProviders)) { - // 只返回已配置的提供者 - const pkceAllowed = !!config.pkce; - if (config.clientId && (config.clientSecret || pkceAllowed)) { - providers.push({ - id: key, - name: config.name, - displayName: config.displayName || config.name, - icon: config.icon, - color: config.color, // 向后兼容 - brandColor: config.brandColor || config.color, - textColor: config.textColor || "#ffffff", - description: config.description, - order: typeof config.order === 'number' ? config.order : 9999, - authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL - website: config.website, - }); + for (const [key, config] of Object.entries(oauthProviders)) { + // 只返回已配置的提供者 + const pkceAllowed = !!config.pkce; + if (config.clientId && (config.clientSecret || pkceAllowed)) { + providers.push({ + id: key, + name: config.name, + displayName: config.displayName || config.name, + icon: config.icon, + color: config.color, // 向后兼容 + brandColor: config.brandColor || config.color, + textColor: config.textColor || "#ffffff", + description: config.description, + order: typeof config.order === 'number' ? config.order : 9999, + authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL + website: config.website, + }); + } } - } - // 按 order 排序(从小到大) - providers = providers.sort((a, b) => a.order - b.order); + // 按 order 排序(从小到大) + providers = providers.sort((a, b) => a.order - b.order); - res.json({ - success: true, - data: providers, - }); + res.json({ + success: true, + data: providers, + }); }); /** @@ -81,75 +81,75 @@ router.get("/oauth/providers", (req, res) => { * - redirect_uri: 前端回调地址(可选) */ router.get("/oauth/:provider", (req, res) => { - const { provider } = req.params; - const { redirect_uri } = req.query; + const {provider} = req.params; + const {redirect_uri} = req.query; - const providerConfig = oauthProviders[provider]; - if (!providerConfig) { - return res.status(400).json({ - success: false, - message: `不支持的OAuth提供者: ${provider}`, - }); - } - - const pkceAllowed = !!providerConfig.pkce; - if (!providerConfig.clientId || (!providerConfig.clientSecret && !pkceAllowed)) { - return res.status(500).json({ - success: false, - message: `OAuth提供者 ${provider} 未配置`, - }); - } - - // 生成state参数 - const state = generateState(); - - // PKCE: 若启用,为此次会话生成code_verifier/challenge - let codeChallenge, codeVerifier; - if (pkceAllowed) { - const pair = generatePkcePair(); - codeVerifier = pair.codeVerifier; - codeChallenge = pair.codeChallenge; - } - - // 保存state和redirect_uri(5分钟过期) - oauthStates.set(state, { - provider, - redirect_uri, - timestamp: Date.now(), - codeVerifier, - }); - - // 清理过期的state(超过5分钟) - for (const [key, value] of oauthStates.entries()) { - if (Date.now() - value.timestamp > 5 * 60 * 1000) { - oauthStates.delete(key); + const providerConfig = oauthProviders[provider]; + if (!providerConfig) { + return res.status(400).json({ + success: false, + message: `不支持的OAuth提供者: ${provider}`, + }); } - } - // 构建授权URL - const params = new URLSearchParams({ - client_id: providerConfig.clientId, - redirect_uri: getCallbackURL(provider), - scope: providerConfig.scope, - state: state, - response_type: "code", - }); + const pkceAllowed = !!providerConfig.pkce; + if (!providerConfig.clientId || (!providerConfig.clientSecret && !pkceAllowed)) { + return res.status(500).json({ + success: false, + message: `OAuth提供者 ${provider} 未配置`, + }); + } - // Google需要额外的参数 - if (provider === "google") { - params.append("access_type", "offline"); - params.append("prompt", "consent"); - } + // 生成state参数 + const state = generateState(); - if (pkceAllowed && codeChallenge) { - params.append("code_challenge", codeChallenge); - params.append("code_challenge_method", "S256"); - } + // PKCE: 若启用,为此次会话生成code_verifier/challenge + let codeChallenge, codeVerifier; + if (pkceAllowed) { + const pair = generatePkcePair(); + codeVerifier = pair.codeVerifier; + codeChallenge = pair.codeChallenge; + } - const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`; + // 保存state和redirect_uri(5分钟过期) + oauthStates.set(state, { + provider, + redirect_uri, + timestamp: Date.now(), + codeVerifier, + }); - // 重定向到OAuth提供者 - res.redirect(authUrl); + // 清理过期的state(超过5分钟) + for (const [key, value] of oauthStates.entries()) { + if (Date.now() - value.timestamp > 5 * 60 * 1000) { + oauthStates.delete(key); + } + } + + // 构建授权URL + const params = new URLSearchParams({ + client_id: providerConfig.clientId, + redirect_uri: getCallbackURL(provider), + scope: providerConfig.scope, + state: state, + response_type: "code", + }); + + // Google需要额外的参数 + if (provider === "google") { + params.append("access_type", "offline"); + params.append("prompt", "consent"); + } + + if (pkceAllowed && codeChallenge) { + params.append("code_challenge", codeChallenge); + params.append("code_challenge_method", "S256"); + } + + const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`; + + // 重定向到OAuth提供者 + res.redirect(authUrl); }); /** @@ -157,213 +157,213 @@ router.get("/oauth/:provider", (req, res) => { * GET /accounts/oauth/:provider/callback */ router.get("/oauth/:provider/callback", async (req, res) => { - const { provider } = req.params; - const { code, state, error } = req.query; + const {provider} = req.params; + const {code, state, error} = req.query; - // 如果OAuth提供者返回错误 - if (error) { - const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const errorUrl = new URL(frontendBaseUrl); - errorUrl.searchParams.append("error", error); - errorUrl.searchParams.append("provider", provider); - errorUrl.searchParams.append("success", "false"); - return res.redirect(errorUrl.toString()); - } - - // 验证state - const stateData = oauthStates.get(state); - if (!stateData || stateData.provider !== provider) { - const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const errorUrl = new URL(frontendBaseUrl); - errorUrl.searchParams.append("error", "invalid_state"); - errorUrl.searchParams.append("provider", provider); - errorUrl.searchParams.append("success", "false"); - return res.redirect(errorUrl.toString()); - } - - // 删除已使用的state - oauthStates.delete(state); - - const providerConfig = oauthProviders[provider]; - - try { - // 1. 使用授权码换取访问令牌 - let tokenResponse; - if (providerConfig.tokenRequestFormat === 'json') { - tokenResponse = await fetch(providerConfig.tokenURL, { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: providerConfig.clientId, - ...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}), - code: code, - grant_type: "authorization_code", - redirect_uri: getCallbackURL(provider), - // PKCE: 携带code_verifier - ...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}), - }), - }); - } else { - tokenResponse = await fetch(providerConfig.tokenURL, { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - ...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}), - code: code, - grant_type: "authorization_code", - redirect_uri: getCallbackURL(provider), - // PKCE: 携带code_verifier - ...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}), - }), - }); + // 如果OAuth提供者返回错误 + if (error) { + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const errorUrl = new URL(frontendBaseUrl); + errorUrl.searchParams.append("error", error); + errorUrl.searchParams.append("provider", provider); + errorUrl.searchParams.append("success", "false"); + return res.redirect(errorUrl.toString()); } - const tokenData = await tokenResponse.json(); - - if (!tokenData.access_token) { - throw new Error("获取访问令牌失败"); + // 验证state + const stateData = oauthStates.get(state); + if (!stateData || stateData.provider !== provider) { + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const errorUrl = new URL(frontendBaseUrl); + errorUrl.searchParams.append("error", "invalid_state"); + errorUrl.searchParams.append("provider", provider); + errorUrl.searchParams.append("success", "false"); + return res.redirect(errorUrl.toString()); } - // 2. 使用访问令牌获取用户信息 - let userResponse; - // Casdoor 支持两种方式:Authorization Bearer 或 accessToken 查询参数 - if (provider === 'stcn') { - const url = new URL(providerConfig.userInfoURL); - url.searchParams.set('accessToken', tokenData.access_token); - userResponse = await fetch(url, { headers: { "Accept": "application/json" } }); - } else { - userResponse = await fetch(providerConfig.userInfoURL, { - headers: { - "Authorization": `Bearer ${tokenData.access_token}`, - "Accept": "application/json", - }, - }); + // 删除已使用的state + oauthStates.delete(state); + + const providerConfig = oauthProviders[provider]; + + try { + // 1. 使用授权码换取访问令牌 + let tokenResponse; + if (providerConfig.tokenRequestFormat === 'json') { + tokenResponse = await fetch(providerConfig.tokenURL, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: providerConfig.clientId, + ...(providerConfig.clientSecret ? {client_secret: providerConfig.clientSecret} : {}), + code: code, + grant_type: "authorization_code", + redirect_uri: getCallbackURL(provider), + // PKCE: 携带code_verifier + ...(stateData?.codeVerifier ? {code_verifier: stateData.codeVerifier} : {}), + }), + }); + } else { + tokenResponse = await fetch(providerConfig.tokenURL, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: providerConfig.clientId, + ...(providerConfig.clientSecret ? {client_secret: providerConfig.clientSecret} : {}), + code: code, + grant_type: "authorization_code", + redirect_uri: getCallbackURL(provider), + // PKCE: 携带code_verifier + ...(stateData?.codeVerifier ? {code_verifier: stateData.codeVerifier} : {}), + }), + }); + } + + const tokenData = await tokenResponse.json(); + + if (!tokenData.access_token) { + throw new Error("获取访问令牌失败"); + } + + // 2. 使用访问令牌获取用户信息 + let userResponse; + // Casdoor 支持两种方式:Authorization Bearer 或 accessToken 查询参数 + if (provider === 'stcn') { + const url = new URL(providerConfig.userInfoURL); + url.searchParams.set('accessToken', tokenData.access_token); + userResponse = await fetch(url, {headers: {"Accept": "application/json"}}); + } else { + userResponse = await fetch(providerConfig.userInfoURL, { + headers: { + "Authorization": `Bearer ${tokenData.access_token}`, + "Accept": "application/json", + }, + }); + } + + const userData = await userResponse.json(); + + // 3. 标准化用户数据(不同提供者返回的字段不同) + let normalizedUser = {}; + + if (provider === "github") { + normalizedUser = { + providerId: String(userData.id), + email: userData.email, + name: userData.name || userData.login, + avatarUrl: userData.avatar_url, + }; + } else if (provider === "zerocat") { + normalizedUser = { + providerId: userData.openid, + email: userData.email_verified ? userData.email : null, + name: userData.nickname || userData.username, + avatarUrl: userData.avatar, + }; + } else if (provider === "hly") { + // 厚浪云(Logto)标准OIDC用户信息 + normalizedUser = { + providerId: userData.sub, + email: userData.email_verified ? userData.email : null, + name: userData.name || userData.preferred_username || userData.nickname, + avatarUrl: userData.picture, + }; + } else if (provider === "stcn") { + // STCN(Casdoor)标准OIDC用户信息 + normalizedUser = { + providerId: userData.sub, + email: userData.email_verified ? userData.email : userData.email || null, + name: userData.name || userData.preferred_username || userData.nickname, + avatarUrl: userData.picture, + }; + } + + // 名称为空时,用邮箱@前部分回填(若邮箱可用) + if ((!normalizedUser.name || normalizedUser.name.trim() === "") && normalizedUser.email) { + const at = normalizedUser.email.indexOf("@"); + if (at > 0) { + normalizedUser.name = normalizedUser.email.substring(0, at); + } + } + + // 4. 查找或创建账户 + let account = await prisma.account.findUnique({ + where: { + provider_providerId: { + provider, + providerId: normalizedUser.providerId, + }, + }, + }); + + if (account) { + // 更新账户信息 + account = await prisma.account.update({ + where: {id: account.id}, + data: { + email: normalizedUser.email || account.email, + name: normalizedUser.name || account.name, + avatarUrl: normalizedUser.avatarUrl || account.avatarUrl, + providerData: userData, + //refreshToken: tokenData.refresh_token || account.refreshToken, + updatedAt: new Date(), + }, + }); + } else { + // 创建新账户 + const accessToken = generateAccessToken(); + account = await prisma.account.create({ + data: { + provider, + providerId: normalizedUser.providerId, + email: normalizedUser.email, + name: normalizedUser.name, + avatarUrl: normalizedUser.avatarUrl, + providerData: userData, + accessToken, + //refreshToken: tokenData.refresh_token, + }, + }); + } + + // 5. 生成令牌对(访问令牌 + 刷新令牌) + const tokens = await generateTokenPair(account); + + // 6. 重定向到前端根路径,携带JWT token + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const callbackUrl = new URL(frontendBaseUrl); + callbackUrl.searchParams.append("access_token", tokens.accessToken); + callbackUrl.searchParams.append("refresh_token", tokens.refreshToken); + callbackUrl.searchParams.append("expires_in", tokens.accessTokenExpiresIn); + callbackUrl.searchParams.append("provider", provider); + // 附带展示信息,便于前端显示品牌与名称 + const pconf = oauthProviders[provider] || {}; + callbackUrl.searchParams.append("providerName", pconf.displayName || pconf.name || provider); + if (pconf.brandColor || pconf.color) { + callbackUrl.searchParams.append("providerColor", pconf.brandColor || pconf.color); + } + callbackUrl.searchParams.append("success", "true"); + + res.redirect(callbackUrl.toString()); + + } catch (error) { + console.error(`OAuth回调处理失败 [${provider}]:`, error); + + // 重定向到前端根路径,携带错误信息 + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const errorUrl = new URL(frontendBaseUrl); + errorUrl.searchParams.append("error", error.message); + errorUrl.searchParams.append("provider", provider); + errorUrl.searchParams.append("success", "false"); + + res.redirect(errorUrl.toString()); } - - const userData = await userResponse.json(); - - // 3. 标准化用户数据(不同提供者返回的字段不同) - let normalizedUser = {}; - - if (provider === "github") { - normalizedUser = { - providerId: String(userData.id), - email: userData.email, - name: userData.name || userData.login, - avatarUrl: userData.avatar_url, - }; - } else if (provider === "zerocat") { - normalizedUser = { - providerId: userData.openid, - email: userData.email_verified ? userData.email : null, - name: userData.nickname || userData.username, - avatarUrl: userData.avatar, - }; - } else if (provider === "hly") { - // 厚浪云(Logto)标准OIDC用户信息 - normalizedUser = { - providerId: userData.sub, - email: userData.email_verified ? userData.email : null, - name: userData.name || userData.preferred_username || userData.nickname, - avatarUrl: userData.picture, - }; - } else if (provider === "stcn") { - // STCN(Casdoor)标准OIDC用户信息 - normalizedUser = { - providerId: userData.sub, - email: userData.email_verified ? userData.email : userData.email || null, - name: userData.name || userData.preferred_username || userData.nickname, - avatarUrl: userData.picture, - }; - } - - // 名称为空时,用邮箱@前部分回填(若邮箱可用) - if ((!normalizedUser.name || normalizedUser.name.trim() === "") && normalizedUser.email) { - const at = normalizedUser.email.indexOf("@"); - if (at > 0) { - normalizedUser.name = normalizedUser.email.substring(0, at); - } - } - - // 4. 查找或创建账户 - let account = await prisma.account.findUnique({ - where: { - provider_providerId: { - provider, - providerId: normalizedUser.providerId, - }, - }, - }); - - if (account) { - // 更新账户信息 - account = await prisma.account.update({ - where: { id: account.id }, - data: { - email: normalizedUser.email || account.email, - name: normalizedUser.name || account.name, - avatarUrl: normalizedUser.avatarUrl || account.avatarUrl, - providerData: userData, - //refreshToken: tokenData.refresh_token || account.refreshToken, - updatedAt: new Date(), - }, - }); - } else { - // 创建新账户 - const accessToken = generateAccessToken(); - account = await prisma.account.create({ - data: { - provider, - providerId: normalizedUser.providerId, - email: normalizedUser.email, - name: normalizedUser.name, - avatarUrl: normalizedUser.avatarUrl, - providerData: userData, - accessToken, - //refreshToken: tokenData.refresh_token, - }, - }); - } - - // 5. 生成令牌对(访问令牌 + 刷新令牌) - const tokens = await generateTokenPair(account); - - // 6. 重定向到前端根路径,携带JWT token - const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const callbackUrl = new URL(frontendBaseUrl); - callbackUrl.searchParams.append("access_token", tokens.accessToken); - callbackUrl.searchParams.append("refresh_token", tokens.refreshToken); - callbackUrl.searchParams.append("expires_in", tokens.accessTokenExpiresIn); - callbackUrl.searchParams.append("provider", provider); - // 附带展示信息,便于前端显示品牌与名称 - const pconf = oauthProviders[provider] || {}; - callbackUrl.searchParams.append("providerName", pconf.displayName || pconf.name || provider); - if (pconf.brandColor || pconf.color) { - callbackUrl.searchParams.append("providerColor", pconf.brandColor || pconf.color); - } - callbackUrl.searchParams.append("success", "true"); - - res.redirect(callbackUrl.toString()); - - } catch (error) { - console.error(`OAuth回调处理失败 [${provider}]:`, error); - - // 重定向到前端根路径,携带错误信息 - const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const errorUrl = new URL(frontendBaseUrl); - errorUrl.searchParams.append("error", error.message); - errorUrl.searchParams.append("provider", provider); - errorUrl.searchParams.append("success", "false"); - - res.redirect(errorUrl.toString()); - } }); /** @@ -374,54 +374,54 @@ router.get("/oauth/:provider/callback", async (req, res) => { * Authorization: Bearer */ router.get("/profile", jwtAuth, async (req, res, next) => { - try { - const accountContext = res.locals.account; + try { + const accountContext = res.locals.account; - const account = await prisma.account.findUnique({ - where: { id: accountContext.id }, - include: { - devices: { - select: { - id: true, - uuid: true, - name: true, - createdAt: true, - }, - }, - }, - }); + const account = await prisma.account.findUnique({ + where: {id: accountContext.id}, + include: { + devices: { + select: { + id: true, + uuid: true, + name: true, + createdAt: true, + }, + }, + }, + }); - // 组装 provider 展示信息 - const pconf = (account?.provider && oauthProviders[account.provider]) || {}; - const providerInfo = { - id: account?.provider || undefined, - name: pconf.name, - displayName: pconf.displayName || pconf.name || account?.provider, - icon: pconf.icon, - color: pconf.color, // 兼容字段 - brandColor: pconf.brandColor || pconf.color, - textColor: pconf.textColor || "#ffffff", - description: pconf.description, - order: typeof pconf.order === 'number' ? pconf.order : undefined, - website: pconf.website, - }; + // 组装 provider 展示信息 + const pconf = (account?.provider && oauthProviders[account.provider]) || {}; + const providerInfo = { + id: account?.provider || undefined, + name: pconf.name, + displayName: pconf.displayName || pconf.name || account?.provider, + icon: pconf.icon, + color: pconf.color, // 兼容字段 + brandColor: pconf.brandColor || pconf.color, + textColor: pconf.textColor || "#ffffff", + description: pconf.description, + order: typeof pconf.order === 'number' ? pconf.order : undefined, + website: pconf.website, + }; - res.json({ - success: true, - data: { - id: account.id, - provider: account.provider, - providerInfo, - email: account.email, - name: account.name, - avatarUrl: account.avatarUrl, - devices: account.devices, - createdAt: account.createdAt, - }, - }); - } catch (error) { - next(error); - } + res.json({ + success: true, + data: { + id: account.id, + provider: account.provider, + providerInfo, + email: account.email, + name: account.name, + avatarUrl: account.avatarUrl, + devices: account.devices, + createdAt: account.createdAt, + }, + }); + } catch (error) { + next(error); + } }); /** @@ -437,57 +437,57 @@ router.get("/profile", jwtAuth, async (req, res, next) => { * } */ router.post("/devices/bind", jwtAuth, async (req, res, next) => { - try { - const accountContext = res.locals.account; - const { uuid } = req.body; + try { + const accountContext = res.locals.account; + const {uuid} = req.body; - if (!uuid) { - return res.status(400).json({ - success: false, - message: "缺少设备UUID", - }); + if (!uuid) { + return res.status(400).json({ + success: false, + message: "缺少设备UUID", + }); + } + + // 查找设备 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); + + if (!device) { + return res.status(404).json({ + success: false, + message: "设备不存在 #1", + }); + } + + // 检查设备是否已绑定其他账户 + if (device.accountId && device.accountId !== accountContext.id) { + return res.status(400).json({ + success: false, + message: "设备已绑定其他账户", + }); + } + + // 绑定设备到账户 + const updatedDevice = await prisma.device.update({ + where: {uuid}, + data: { + accountId: accountContext.id, + }, + }); + + res.json({ + success: true, + message: "设备绑定成功", + data: { + deviceId: updatedDevice.id, + uuid: updatedDevice.uuid, + name: updatedDevice.name, + }, + }); + } catch (error) { + next(error); } - - // 查找设备 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); - - if (!device) { - return res.status(404).json({ - success: false, - message: "设备不存在 #1", - }); - } - - // 检查设备是否已绑定其他账户 - if (device.accountId && device.accountId !== accountContext.id) { - return res.status(400).json({ - success: false, - message: "设备已绑定其他账户", - }); - } - - // 绑定设备到账户 - const updatedDevice = await prisma.device.update({ - where: { uuid }, - data: { - accountId: accountContext.id, - }, - }); - - res.json({ - success: true, - message: "设备绑定成功", - data: { - deviceId: updatedDevice.id, - uuid: updatedDevice.uuid, - name: updatedDevice.name, - }, - }); - } catch (error) { - next(error); - } }); /** @@ -504,65 +504,65 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => { * } */ router.post("/devices/unbind", jwtAuth, async (req, res, next) => { - try { - const accountContext = res.locals.account; - const { uuid, uuids } = req.body; + try { + const accountContext = res.locals.account; + const {uuid, uuids} = req.body; - // 支持单个解绑或批量解绑 - const uuidsToUnbind = uuids || (uuid ? [uuid] : []); + // 支持单个解绑或批量解绑 + const uuidsToUnbind = uuids || (uuid ? [uuid] : []); - if (uuidsToUnbind.length === 0) { - return res.status(400).json({ - success: false, - message: "请提供要解绑的设备UUID", - }); + if (uuidsToUnbind.length === 0) { + return res.status(400).json({ + success: false, + message: "请提供要解绑的设备UUID", + }); + } + + // 查找所有设备并验证所有权 + const devices = await prisma.device.findMany({ + where: { + uuid: {in: uuidsToUnbind}, + }, + }); + + // 检查是否有不存在的设备 + if (devices.length !== uuidsToUnbind.length) { + const foundUuids = devices.map(d => d.uuid); + const notFoundUuids = uuidsToUnbind.filter(u => !foundUuids.includes(u)); + return res.status(404).json({ + success: false, + message: `以下设备不存在: ${notFoundUuids.join(', ')}`, + }); + } + + // 检查所有权 + const unauthorizedDevices = devices.filter(d => d.accountId !== accountContext.id); + if (unauthorizedDevices.length > 0) { + return res.status(403).json({ + success: false, + message: `您没有权限解绑以下设备: ${unauthorizedDevices.map(d => d.uuid).join(', ')}`, + }); + } + + // 批量解绑设备 + await prisma.device.updateMany({ + where: { + uuid: {in: uuidsToUnbind}, + accountId: accountContext.id, + }, + data: { + accountId: null, + }, + }); + + res.json({ + success: true, + message: uuidsToUnbind.length === 1 ? "设备解绑成功" : `成功解绑 ${uuidsToUnbind.length} 个设备`, + unboundCount: uuidsToUnbind.length, + }); + } catch (error) { + next(error); } - - // 查找所有设备并验证所有权 - const devices = await prisma.device.findMany({ - where: { - uuid: { in: uuidsToUnbind }, - }, - }); - - // 检查是否有不存在的设备 - if (devices.length !== uuidsToUnbind.length) { - const foundUuids = devices.map(d => d.uuid); - const notFoundUuids = uuidsToUnbind.filter(u => !foundUuids.includes(u)); - return res.status(404).json({ - success: false, - message: `以下设备不存在: ${notFoundUuids.join(', ')}`, - }); - } - - // 检查所有权 - const unauthorizedDevices = devices.filter(d => d.accountId !== accountContext.id); - if (unauthorizedDevices.length > 0) { - return res.status(403).json({ - success: false, - message: `您没有权限解绑以下设备: ${unauthorizedDevices.map(d => d.uuid).join(', ')}`, - }); - } - - // 批量解绑设备 - await prisma.device.updateMany({ - where: { - uuid: { in: uuidsToUnbind }, - accountId: accountContext.id, - }, - data: { - accountId: null, - }, - }); - - res.json({ - success: true, - message: uuidsToUnbind.length === 1 ? "设备解绑成功" : `成功解绑 ${uuidsToUnbind.length} 个设备`, - unboundCount: uuidsToUnbind.length, - }); - } catch (error) { - next(error); - } }); /** @@ -573,32 +573,32 @@ router.post("/devices/unbind", jwtAuth, async (req, res, next) => { * Authorization: Bearer */ router.get("/devices", jwtAuth, async (req, res, next) => { - try { - const accountContext = res.locals.account; - // 获取账户的设备列表 - const account = await prisma.account.findUnique({ - where: { id: accountContext.id }, - include: { - devices: { - select: { - id: true, - uuid: true, - name: true, - namespace: true, - createdAt: true, - updatedAt: true, - }, - }, - }, - }); + try { + const accountContext = res.locals.account; + // 获取账户的设备列表 + const account = await prisma.account.findUnique({ + where: {id: accountContext.id}, + include: { + devices: { + select: { + id: true, + uuid: true, + name: true, + namespace: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }); - res.json({ - success: true, - data: account.devices, - }); - } catch (error) { - next(error); - } + res.json({ + success: true, + data: account.devices, + }); + } catch (error) { + next(error); + } }); /** @@ -608,52 +608,52 @@ router.get("/devices", jwtAuth, async (req, res, next) => { * 无需认证,返回公开信息 */ router.get("/device/:uuid/account", async (req, res, next) => { - try { - const { uuid } = req.params; + try { + const {uuid} = req.params; - // 查找设备及其关联的账户 - const device = await prisma.device.findUnique({ - where: { uuid }, - include: { - account: { - select: { - id: true, - provider: true, - name: true, - avatarUrl: true, - createdAt: true, - }, - }, - }, - }); + // 查找设备及其关联的账户 + const device = await prisma.device.findUnique({ + where: {uuid}, + include: { + account: { + select: { + id: true, + provider: true, + name: true, + avatarUrl: true, + createdAt: true, + }, + }, + }, + }); - if (!device) { - return res.status(404).json({ - success: false, - message: "设备不存在 #2", - }); + if (!device) { + return res.status(404).json({ + success: false, + message: "设备不存在 #2", + }); + } + + if (!device.account) { + return res.json({ + success: true, + data: null, // 设备未绑定账户 + }); + } + + res.json({ + success: true, + data: { + id: device.account.id, + provider: device.account.provider, + name: device.account.name, + avatarUrl: device.account.avatarUrl, + bindTime: device.updatedAt, // 绑定时间 + }, + }); + } catch (error) { + next(error); } - - if (!device.account) { - return res.json({ - success: true, - data: null, // 设备未绑定账户 - }); - } - - res.json({ - success: true, - data: { - id: device.account.id, - provider: device.account.provider, - name: device.account.name, - avatarUrl: device.account.avatarUrl, - bindTime: device.updatedAt, // 绑定时间 - }, - }); - } catch (error) { - next(error); - } }); /** @@ -666,44 +666,44 @@ router.get("/device/:uuid/account", async (req, res, next) => { * } */ router.post("/refresh", async (req, res, next) => { - try { - const { refresh_token } = req.body; + try { + const {refresh_token} = req.body; - if (!refresh_token) { - return res.status(400).json({ - success: false, - message: "缺少刷新令牌", - }); - } + if (!refresh_token) { + return res.status(400).json({ + success: false, + message: "缺少刷新令牌", + }); + } - // 刷新访问令牌 - const result = await refreshAccessToken(refresh_token); + // 刷新访问令牌 + const result = await refreshAccessToken(refresh_token); - res.json({ - success: true, - message: "令牌刷新成功", - data: { - access_token: result.accessToken, - expires_in: result.accessTokenExpiresIn, - account: result.account, - }, - }); - } catch (error) { - if (error.message === 'Account not found') { - return next(errors.createError(401, "账户不存在")); - } - if (error.message === 'Invalid refresh token') { - return next(errors.createError(401, "无效的刷新令牌")); - } - if (error.message === 'Refresh token expired') { - return next(errors.createError(401, "刷新令牌已过期")); - } - if (error.message === 'Token version mismatch') { - return next(errors.createError(401, "令牌版本不匹配,请重新登录")); - } + res.json({ + success: true, + message: "令牌刷新成功", + data: { + access_token: result.accessToken, + expires_in: result.accessTokenExpiresIn, + account: result.account, + }, + }); + } catch (error) { + if (error.message === 'Account not found') { + return next(errors.createError(401, "账户不存在")); + } + if (error.message === 'Invalid refresh token') { + return next(errors.createError(401, "无效的刷新令牌")); + } + if (error.message === 'Refresh token expired') { + return next(errors.createError(401, "刷新令牌已过期")); + } + if (error.message === 'Token version mismatch') { + return next(errors.createError(401, "令牌版本不匹配,请重新登录")); + } - next(error); - } + next(error); + } }); /** @@ -714,19 +714,19 @@ router.post("/refresh", async (req, res, next) => { * Authorization: Bearer */ router.post("/logout", jwtAuth, async (req, res, next) => { - try { - const accountContext = res.locals.account; + try { + const accountContext = res.locals.account; - // 撤销当前设备的刷新令牌 - await revokeRefreshToken(accountContext.id); + // 撤销当前设备的刷新令牌 + await revokeRefreshToken(accountContext.id); - res.json({ - success: true, - message: "登出成功", - }); - } catch (error) { - next(error); - } + res.json({ + success: true, + message: "登出成功", + }); + } catch (error) { + next(error); + } }); /** @@ -737,19 +737,19 @@ router.post("/logout", jwtAuth, async (req, res, next) => { * Authorization: Bearer */ router.post("/logout-all", jwtAuth, async (req, res, next) => { - try { - const accountContext = res.locals.account; + try { + const accountContext = res.locals.account; - // 撤销所有令牌 - await revokeAllTokens(accountContext.id); + // 撤销所有令牌 + await revokeAllTokens(accountContext.id); - res.json({ - success: true, - message: "已从所有设备登出", - }); - } catch (error) { - next(error); - } + res.json({ + success: true, + message: "已从所有设备登出", + }); + } catch (error) { + next(error); + } }); /** @@ -760,32 +760,32 @@ router.post("/logout-all", jwtAuth, async (req, res, next) => { * Authorization: Bearer */ router.get("/token-info", jwtAuth, async (req, res, next) => { - try { - const decoded = res.locals.tokenDecoded; - const account = res.locals.account; + try { + const decoded = res.locals.tokenDecoded; + const account = res.locals.account; - // 计算token剩余有效时间 - const now = Math.floor(Date.now() / 1000); - const expiresIn = decoded.exp - now; + // 计算token剩余有效时间 + const now = Math.floor(Date.now() / 1000); + const expiresIn = decoded.exp - now; - res.json({ - success: true, - data: { - accountId: account.id, - tokenType: decoded.type || 'legacy', - tokenVersion: decoded.tokenVersion || account.tokenVersion, - issuedAt: new Date(decoded.iat * 1000), - expiresAt: new Date(decoded.exp * 1000), - expiresIn: expiresIn, - isExpired: expiresIn <= 0, - isLegacyToken: res.locals.isLegacyToken || false, - hasRefreshToken: !!account.refreshToken, - refreshTokenExpiry: account.refreshTokenExpiry, - }, - }); - } catch (error) { - next(error); - } + res.json({ + success: true, + data: { + accountId: account.id, + tokenType: decoded.type || 'legacy', + tokenVersion: decoded.tokenVersion || account.tokenVersion, + issuedAt: new Date(decoded.iat * 1000), + expiresAt: new Date(decoded.exp * 1000), + expiresIn: expiresIn, + isExpired: expiresIn <= 0, + isLegacyToken: res.locals.isLegacyToken || false, + hasRefreshToken: !!account.refreshToken, + refreshTokenExpiry: account.refreshTokenExpiry, + }, + }); + } catch (error) { + next(error); + } }); export default router; \ No newline at end of file diff --git a/routes/apps.js b/routes/apps.js index f6a6465..be79535 100644 --- a/routes/apps.js +++ b/routes/apps.js @@ -1,12 +1,11 @@ -import { Router } from "express"; -const router = Router(); -import { uuidAuth } from "../middleware/uuidAuth.js"; -import { jwtAuth } from "../middleware/jwt-auth.js"; -import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; -import { PrismaClient } from "@prisma/client"; +import {Router} from "express"; +import {uuidAuth} from "../middleware/uuidAuth.js"; +import {PrismaClient} from "@prisma/client"; import crypto from "crypto"; import errors from "../utils/errors.js"; -import { verifyDevicePassword } from "../utils/crypto.js"; +import {verifyDevicePassword} from "../utils/crypto.js"; + +const router = Router(); const prisma = new PrismaClient(); @@ -15,35 +14,35 @@ const prisma = new PrismaClient(); * 获取设备安装的应用列表 (公开接口,无需认证) */ router.get( - "/devices/:uuid/apps", - errors.catchAsync(async (req, res, next) => { - const { uuid } = req.params; + "/devices/:uuid/apps", + errors.catchAsync(async (req, res, next) => { + const {uuid} = req.params; - // 查找设备 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - const installations = await prisma.appInstall.findMany({ - where: { deviceId: device.id }, - }); + const installations = await prisma.appInstall.findMany({ + where: {deviceId: device.id}, + }); - const apps = installations.map(install => ({ - appId: install.appId, - token: install.token, - note: install.note, - installedAt: install.createdAt, - })); + const apps = installations.map(install => ({ + appId: install.appId, + token: install.token, + note: install.note, + installedAt: install.createdAt, + })); - return res.json({ - success: true, - apps, - }); - }) + return res.json({ + success: true, + apps, + }); + }) ); /** @@ -52,35 +51,35 @@ router.get( * appId 现在是 SHA256 hash */ router.post( - "/devices/:uuid/install/:appId", - uuidAuth, - errors.catchAsync(async (req, res, next) => { - const device = res.locals.device; - const { appId } = req.params; - const { note } = req.body; + "/devices/:uuid/install/:appId", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const device = res.locals.device; + const {appId} = req.params; + const {note} = req.body; - // 生成token - const token = crypto.randomBytes(32).toString("hex"); + // 生成token + const token = crypto.randomBytes(32).toString("hex"); - // 创建安装记录 - const installation = await prisma.appInstall.create({ - data: { - deviceId: device.id, - appId: appId, - token, - note: note || null, - }, - }); + // 创建安装记录 + const installation = await prisma.appInstall.create({ + data: { + deviceId: device.id, + appId: appId, + token, + note: note || null, + }, + }); - return res.status(201).json({ - id: installation.id, - appId: installation.appId, - token: installation.token, - note: installation.note, - name: installation.note, // 备注同时作为名称返回 - installedAt: installation.createdAt, - }); - }) + return res.status(201).json({ + id: installation.id, + appId: installation.appId, + token: installation.token, + note: installation.note, + name: installation.note, // 备注同时作为名称返回 + installedAt: installation.createdAt, + }); + }) ); /** @@ -88,31 +87,31 @@ router.post( * 卸载设备应用 (需要UUID认证) */ router.delete( - "/devices/:uuid/uninstall/:installId", - uuidAuth, - errors.catchAsync(async (req, res, next) => { - const device = res.locals.device; - const { installId } = req.params; + "/devices/:uuid/uninstall/:installId", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const device = res.locals.device; + const {installId} = req.params; - const installation = await prisma.appInstall.findUnique({ - where: { id: installId }, - }); + const installation = await prisma.appInstall.findUnique({ + where: {id: installId}, + }); - if (!installation) { - return next(errors.createError(404, "应用未安装")); - } + if (!installation) { + return next(errors.createError(404, "应用未安装")); + } - // 确保安装记录属于当前设备 - if (installation.deviceId !== device.id) { - return next(errors.createError(403, "无权操作此安装记录")); - } + // 确保安装记录属于当前设备 + if (installation.deviceId !== device.id) { + return next(errors.createError(403, "无权操作此安装记录")); + } - await prisma.appInstall.delete({ - where: { id: installation.id }, - }); + await prisma.appInstall.delete({ + where: {id: installation.id}, + }); - return res.status(204).end(); - }) + return res.status(204).end(); + }) ); /** @@ -120,44 +119,44 @@ router.delete( * 获取设备的token列表 (需要设备UUID) */ router.get( - "/tokens", - errors.catchAsync(async (req, res, next) => { - const { uuid } = req.query; + "/tokens", + errors.catchAsync(async (req, res, next) => { + const {uuid} = req.query; - if (!uuid) { - return next(errors.createError(400, "需要提供设备UUID")); - } + if (!uuid) { + return next(errors.createError(400, "需要提供设备UUID")); + } - // 查找设备 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 获取该设备的所有应用安装记录(即token) - const installations = await prisma.appInstall.findMany({ - where: { deviceId: device.id }, - orderBy: { installedAt: 'desc' }, - }); + // 获取该设备的所有应用安装记录(即token) + const installations = await prisma.appInstall.findMany({ + where: {deviceId: device.id}, + orderBy: {installedAt: 'desc'}, + }); - const tokens = installations.map(install => ({ - id: install.id, - token: install.token, - appId: install.appId, - installedAt: install.installedAt, - note: install.note, - name: install.note, // 备注同时作为名称返回 - })); + const tokens = installations.map(install => ({ + id: install.id, + token: install.token, + appId: install.appId, + installedAt: install.installedAt, + note: install.note, + name: install.note, // 备注同时作为名称返回 + })); - return res.json({ - success: true, - tokens, - deviceUuid: uuid, - }); - }) + return res.json({ + success: true, + tokens, + deviceUuid: uuid, + }); + }) ); /** @@ -166,97 +165,97 @@ router.get( * Body: { namespace: string, password: string, appId: string } */ router.post( - "/auth/token", - errors.catchAsync(async (req, res, next) => { - const { namespace, password, appId } = req.body; + "/auth/token", + errors.catchAsync(async (req, res, next) => { + const {namespace, password, appId} = req.body; - if (!namespace) { - return next(errors.createError(400, "需要提供 namespace")); - } - - if (!appId) { - return next(errors.createError(400, "需要提供 appId")); - } - - // 通过 namespace 查找设备 - const device = await prisma.device.findUnique({ - where: { namespace }, - include: { - autoAuths: true, - }, - }); - - if (!device) { - return next(errors.createError(404, "设备不存在或 namespace 不正确")); - } - - // 查找匹配的自动授权配置 - let matchedAutoAuth = null; - - // 如果提供了密码,查找匹配密码的自动授权 - if (password) { - // 首先尝试直接匹配明文密码 - matchedAutoAuth = device.autoAuths.find(auth => auth.password === password); - - // 如果没有匹配到,尝试验证哈希密码(向后兼容) - if (!matchedAutoAuth) { - for (const autoAuth of device.autoAuths) { - if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头 - try { - if (await verifyDevicePassword(password, autoAuth.password)) { - matchedAutoAuth = autoAuth; - - // 自动迁移:将哈希密码更新为明文密码 - await prisma.autoAuth.update({ - where: { id: autoAuth.id }, - data: { password: password }, // 保存明文密码 - }); - - console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`); - break; - } - } catch (err) { - // 如果验证失败,继续尝试下一个 - continue; - } - } + if (!namespace) { + return next(errors.createError(400, "需要提供 namespace")); } - } - if (!matchedAutoAuth) { - return next(errors.createError(401, "密码不正确")); - } - } else { - // 如果没有提供密码,查找密码为空的自动授权 - matchedAutoAuth = device.autoAuths.find(auth => !auth.password); + if (!appId) { + return next(errors.createError(400, "需要提供 appId")); + } - if (!matchedAutoAuth) { - return next(errors.createError(401, "需要提供密码")); - } - } + // 通过 namespace 查找设备 + const device = await prisma.device.findUnique({ + where: {namespace}, + include: { + autoAuths: true, + }, + }); - // 根据自动授权配置创建 AppInstall - const token = crypto.randomBytes(32).toString("hex"); + if (!device) { + return next(errors.createError(404, "设备不存在或 namespace 不正确")); + } - const installation = await prisma.appInstall.create({ - data: { - deviceId: device.id, - appId: appId, - token, - note: null, - isReadOnly: matchedAutoAuth.isReadOnly, - deviceType: matchedAutoAuth.deviceType, - }, - }); + // 查找匹配的自动授权配置 + let matchedAutoAuth = null; - return res.status(201).json({ - success: true, - token: installation.token, - deviceType: installation.deviceType, - isReadOnly: installation.isReadOnly, - installedAt: installation.installedAt, - }); - }) + // 如果提供了密码,查找匹配密码的自动授权 + if (password) { + // 首先尝试直接匹配明文密码 + matchedAutoAuth = device.autoAuths.find(auth => auth.password === password); + + // 如果没有匹配到,尝试验证哈希密码(向后兼容) + if (!matchedAutoAuth) { + for (const autoAuth of device.autoAuths) { + if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头 + try { + if (await verifyDevicePassword(password, autoAuth.password)) { + matchedAutoAuth = autoAuth; + + // 自动迁移:将哈希密码更新为明文密码 + await prisma.autoAuth.update({ + where: {id: autoAuth.id}, + data: {password: password}, // 保存明文密码 + }); + + console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`); + break; + } + } catch (err) { + // 如果验证失败,继续尝试下一个 + + } + } + } + } + + if (!matchedAutoAuth) { + return next(errors.createError(401, "密码不正确")); + } + } else { + // 如果没有提供密码,查找密码为空的自动授权 + matchedAutoAuth = device.autoAuths.find(auth => !auth.password); + + if (!matchedAutoAuth) { + return next(errors.createError(401, "需要提供密码")); + } + } + + // 根据自动授权配置创建 AppInstall + const token = crypto.randomBytes(32).toString("hex"); + + const installation = await prisma.appInstall.create({ + data: { + deviceId: device.id, + appId: appId, + token, + note: null, + isReadOnly: matchedAutoAuth.isReadOnly, + deviceType: matchedAutoAuth.deviceType, + }, + }); + + return res.status(201).json({ + success: true, + token: installation.token, + deviceType: installation.deviceType, + isReadOnly: installation.isReadOnly, + installedAt: installation.installedAt, + }); + }) ); /** @@ -265,78 +264,78 @@ router.post( * Body: { name: string } */ router.post( - "/tokens/:token/set-student-name", - errors.catchAsync(async (req, res, next) => { - const { token } = req.params; - const { name } = req.body; + "/tokens/:token/set-student-name", + errors.catchAsync(async (req, res, next) => { + const {token} = req.params; + const {name} = req.body; - if (!name) { - return next(errors.createError(400, "需要提供学生名称")); - } + if (!name) { + return next(errors.createError(400, "需要提供学生名称")); + } - // 查找 token 对应的应用安装记录 - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { - device: true, - }, - }); + // 查找 token 对应的应用安装记录 + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + include: { + device: true, + }, + }); - if (!appInstall) { - return next(errors.createError(404, "Token 不存在")); - } + if (!appInstall) { + return next(errors.createError(404, "Token 不存在")); + } - // 验证 token 类型是否为 student - if (!['student','parent'].includes(appInstall.deviceType)) { - return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称")); - } + // 验证 token 类型是否为 student + if (!['student', 'parent'].includes(appInstall.deviceType)) { + return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称")); + } - // 读取设备的 classworks-list-main 键值 - const kvRecord = await prisma.kVStore.findUnique({ - where: { - deviceId_key: { - deviceId: appInstall.deviceId, - key: 'classworks-list-main', - }, - }, - }); + // 读取设备的 classworks-list-main 键值 + const kvRecord = await prisma.kVStore.findUnique({ + where: { + deviceId_key: { + deviceId: appInstall.deviceId, + key: 'classworks-list-main', + }, + }, + }); - if (!kvRecord) { - return next(errors.createError(404, "设备未设置学生列表")); - } + if (!kvRecord) { + return next(errors.createError(404, "设备未设置学生列表")); + } - // 解析学生列表 - let studentList; - try { - studentList = kvRecord.value; - if (!Array.isArray(studentList)) { - return next(errors.createError(500, "学生列表格式错误")); - } - } catch (error) { - return next(errors.createError(500, "无法解析学生列表")); - } + // 解析学生列表 + let studentList; + try { + studentList = kvRecord.value; + if (!Array.isArray(studentList)) { + return next(errors.createError(500, "学生列表格式错误")); + } + } catch (error) { + return next(errors.createError(500, "无法解析学生列表")); + } - // 验证名称是否在学生列表中 - const studentExists = studentList.some(student => student.name === name); + // 验证名称是否在学生列表中 + const studentExists = studentList.some(student => student.name === name); - if (!studentExists) { - return next(errors.createError(400, "该名称不在学生列表中")); - } + if (!studentExists) { + return next(errors.createError(400, "该名称不在学生列表中")); + } - // 更新 AppInstall 的 note 字段 - const updatedInstall = await prisma.appInstall.update({ - where: { id: appInstall.id }, - data: { note: appInstall.deviceType === 'parent' ? `${name} 家长` : name }, - }); + // 更新 AppInstall 的 note 字段 + const updatedInstall = await prisma.appInstall.update({ + where: {id: appInstall.id}, + data: {note: appInstall.deviceType === 'parent' ? `${name} 家长` : name}, + }); - return res.json({ - success: true, - token: updatedInstall.token, - name: updatedInstall.note, - deviceType: updatedInstall.deviceType, - updatedAt: updatedInstall.updatedAt, - }); - }) + return res.json({ + success: true, + token: updatedInstall.token, + name: updatedInstall.note, + deviceType: updatedInstall.deviceType, + updatedAt: updatedInstall.updatedAt, + }); + }) ); /** @@ -345,33 +344,33 @@ router.post( * Body: { note: string } */ router.put( - "/tokens/:token/note", - errors.catchAsync(async (req, res, next) => { - const { token } = req.params; - const { note } = req.body; + "/tokens/:token/note", + errors.catchAsync(async (req, res, next) => { + const {token} = req.params; + const {note} = req.body; - // 查找 token 对应的应用安装记录 - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - }); + // 查找 token 对应的应用安装记录 + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + }); - if (!appInstall) { - return next(errors.createError(404, "Token 不存在")); - } + if (!appInstall) { + return next(errors.createError(404, "Token 不存在")); + } - // 更新 AppInstall 的 note 字段 - const updatedInstall = await prisma.appInstall.update({ - where: { id: appInstall.id }, - data: { note: note || null }, - }); + // 更新 AppInstall 的 note 字段 + const updatedInstall = await prisma.appInstall.update({ + where: {id: appInstall.id}, + data: {note: note || null}, + }); - return res.json({ - success: true, - token: updatedInstall.token, - note: updatedInstall.note, - updatedAt: updatedInstall.updatedAt, - }); - }) + return res.json({ + success: true, + token: updatedInstall.token, + note: updatedInstall.note, + updatedAt: updatedInstall.updatedAt, + }); + }) ); export default router; \ No newline at end of file diff --git a/routes/auto-auth.js b/routes/auto-auth.js index 68b74ed..a16d490 100644 --- a/routes/auto-auth.js +++ b/routes/auto-auth.js @@ -1,9 +1,10 @@ -import { Router } from "express"; -const router = Router(); -import { jwtAuth } from "../middleware/jwt-auth.js"; -import { PrismaClient } from "@prisma/client"; +import {Router} from "express"; +import {jwtAuth} from "../middleware/jwt-auth.js"; +import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; +const router = Router(); + const prisma = new PrismaClient(); /** @@ -11,52 +12,52 @@ const prisma = new PrismaClient(); * 获取设备的所有自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) */ router.get( - "/devices/:uuid/auth-configs", - jwtAuth, - errors.catchAsync(async (req, res, next) => { - const { uuid } = req.params; - const account = res.locals.account; + "/devices/:uuid/auth-configs", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const {uuid} = req.params; + const account = res.locals.account; - // 查找设备并验证是否属于当前账户 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 验证设备是否绑定到当前账户 - if (!device.accountId || device.accountId !== account.id) { - return next(errors.createError(403, "该设备未绑定到您的账户")); - } + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } - const autoAuths = await prisma.autoAuth.findMany({ - where: { deviceId: device.id }, - orderBy: { createdAt: 'desc' }, - }); + const autoAuths = await prisma.autoAuth.findMany({ + where: {deviceId: device.id}, + orderBy: {createdAt: 'desc'}, + }); - // 返回配置,智能处理密码显示 - const configs = autoAuths.map(auth => { - // 检查是否是 bcrypt 哈希密码 - const isHashedPassword = auth.password && auth.password.startsWith('$2'); + // 返回配置,智能处理密码显示 + const configs = autoAuths.map(auth => { + // 检查是否是 bcrypt 哈希密码 + const isHashedPassword = auth.password && auth.password.startsWith('$2'); - return { - id: auth.id, - password: isHashedPassword ? null : auth.password, // 哈希密码不返回 - isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码 - deviceType: auth.deviceType, - isReadOnly: auth.isReadOnly, - createdAt: auth.createdAt, - updatedAt: auth.updatedAt, - }; - }); + return { + id: auth.id, + password: isHashedPassword ? null : auth.password, // 哈希密码不返回 + isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码 + deviceType: auth.deviceType, + isReadOnly: auth.isReadOnly, + createdAt: auth.createdAt, + updatedAt: auth.updatedAt, + }; + }); - return res.json({ - success: true, - configs, - }); - }) + return res.json({ + success: true, + configs, + }); + }) ); /** @@ -65,163 +66,164 @@ router.get( * Body: { password?: string, deviceType?: string, isReadOnly?: boolean } */ router.post( - "/devices/:uuid/auth-configs", - jwtAuth, - errors.catchAsync(async (req, res, next) => { - const { uuid } = req.params; - const account = res.locals.account; - const { password, deviceType, isReadOnly } = req.body; + "/devices/:uuid/auth-configs", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const {uuid} = req.params; + const account = res.locals.account; + const {password, deviceType, isReadOnly} = req.body; - // 查找设备并验证是否属于当前账户 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 验证设备是否绑定到当前账户 - if (!device.accountId || device.accountId !== account.id) { - return next(errors.createError(403, "该设备未绑定到您的账户")); - } + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } - // 验证 deviceType 如果提供的话 - const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; - if (deviceType && !validDeviceTypes.includes(deviceType)) { - return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); - } + // 验证 deviceType 如果提供的话 + const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; + if (deviceType && !validDeviceTypes.includes(deviceType)) { + return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); + } - // 规范化密码:空字符串视为 null - const plainPassword = (password !== undefined && password !== '') ? password : null; + // 规范化密码:空字符串视为 null + const plainPassword = (password !== undefined && password !== '') ? password : null; - // 查询该设备的所有自动授权配置,本地检查是否存在相同密码 - const allAuths = await prisma.autoAuth.findMany({ - where: { deviceId: device.id }, - }); + // 查询该设备的所有自动授权配置,本地检查是否存在相同密码 + const allAuths = await prisma.autoAuth.findMany({ + where: {deviceId: device.id}, + }); - const existingAuth = allAuths.find(auth => auth.password === plainPassword); + const existingAuth = allAuths.find(auth => auth.password === plainPassword); - if (existingAuth) { - return next(errors.createError(400, "该密码的自动授权配置已存在")); - } + if (existingAuth) { + return next(errors.createError(400, "该密码的自动授权配置已存在")); + } - // 创建新的自动授权配置(密码明文存储) - const autoAuth = await prisma.autoAuth.create({ - data: { - deviceId: device.id, - password: plainPassword, - deviceType: deviceType || null, - isReadOnly: isReadOnly || false, - }, - }); + // 创建新的自动授权配置(密码明文存储) + const autoAuth = await prisma.autoAuth.create({ + data: { + deviceId: device.id, + password: plainPassword, + deviceType: deviceType || null, + isReadOnly: isReadOnly || false, + }, + }); - return res.status(201).json({ - success: true, - config: { - id: autoAuth.id, - password: autoAuth.password, // 返回明文密码 - deviceType: autoAuth.deviceType, - isReadOnly: autoAuth.isReadOnly, - createdAt: autoAuth.createdAt, - }, - }); - }) -);/** + return res.status(201).json({ + success: true, + config: { + id: autoAuth.id, + password: autoAuth.password, // 返回明文密码 + deviceType: autoAuth.deviceType, + isReadOnly: autoAuth.isReadOnly, + createdAt: autoAuth.createdAt, + }, + }); + }) +); +/** * PUT /auto-auth/devices/:uuid/auth-configs/:configId * 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) * Body: { password?: string, deviceType?: string, isReadOnly?: boolean } */ router.put( - "/devices/:uuid/auth-configs/:configId", - jwtAuth, - errors.catchAsync(async (req, res, next) => { - const { uuid, configId } = req.params; - const account = res.locals.account; - const { password, deviceType, isReadOnly } = req.body; + "/devices/:uuid/auth-configs/:configId", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const {uuid, configId} = req.params; + const account = res.locals.account; + const {password, deviceType, isReadOnly} = req.body; - // 查找设备并验证是否属于当前账户 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 验证设备是否绑定到当前账户 - if (!device.accountId || device.accountId !== account.id) { - return next(errors.createError(403, "该设备未绑定到您的账户")); - } + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } - // 查找自动授权配置 - const autoAuth = await prisma.autoAuth.findUnique({ - where: { id: configId }, - }); + // 查找自动授权配置 + const autoAuth = await prisma.autoAuth.findUnique({ + where: {id: configId}, + }); - if (!autoAuth) { - return next(errors.createError(404, "自动授权配置不存在")); - } + if (!autoAuth) { + return next(errors.createError(404, "自动授权配置不存在")); + } - // 确保配置属于当前设备 - if (autoAuth.deviceId !== device.id) { - return next(errors.createError(403, "无权操作此配置")); - } + // 确保配置属于当前设备 + if (autoAuth.deviceId !== device.id) { + return next(errors.createError(403, "无权操作此配置")); + } - // 验证 deviceType - const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; - if (deviceType && !validDeviceTypes.includes(deviceType)) { - return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); - } + // 验证 deviceType + const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; + if (deviceType && !validDeviceTypes.includes(deviceType)) { + return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); + } - // 准备更新数据 - const updateData = {}; + // 准备更新数据 + const updateData = {}; - if (password !== undefined) { - // 规范化密码:空字符串视为 null - const plainPassword = (password !== '') ? password : null; + if (password !== undefined) { + // 规范化密码:空字符串视为 null + const plainPassword = (password !== '') ? password : null; - // 查询该设备的所有配置,本地检查新密码是否与其他配置冲突 - const allAuths = await prisma.autoAuth.findMany({ - where: { deviceId: device.id }, - }); + // 查询该设备的所有配置,本地检查新密码是否与其他配置冲突 + const allAuths = await prisma.autoAuth.findMany({ + where: {deviceId: device.id}, + }); - const conflictAuth = allAuths.find(auth => - auth.id !== configId && auth.password === plainPassword - ); + const conflictAuth = allAuths.find(auth => + auth.id !== configId && auth.password === plainPassword + ); - if (conflictAuth) { - return next(errors.createError(400, "该密码已被其他配置使用")); - } + if (conflictAuth) { + return next(errors.createError(400, "该密码已被其他配置使用")); + } - updateData.password = plainPassword; - } + updateData.password = plainPassword; + } - if (deviceType !== undefined) { - updateData.deviceType = deviceType || null; - } + if (deviceType !== undefined) { + updateData.deviceType = deviceType || null; + } - if (isReadOnly !== undefined) { - updateData.isReadOnly = isReadOnly; - } + if (isReadOnly !== undefined) { + updateData.isReadOnly = isReadOnly; + } - // 更新配置 - const updatedAuth = await prisma.autoAuth.update({ - where: { id: configId }, - data: updateData, - }); + // 更新配置 + const updatedAuth = await prisma.autoAuth.update({ + where: {id: configId}, + data: updateData, + }); - return res.json({ - success: true, - config: { - id: updatedAuth.id, - password: updatedAuth.password, // 返回明文密码 - deviceType: updatedAuth.deviceType, - isReadOnly: updatedAuth.isReadOnly, - updatedAt: updatedAuth.updatedAt, - }, - }); - }) + return res.json({ + success: true, + config: { + id: updatedAuth.id, + password: updatedAuth.password, // 返回明文密码 + deviceType: updatedAuth.deviceType, + isReadOnly: updatedAuth.isReadOnly, + updatedAt: updatedAuth.updatedAt, + }, + }); + }) ); /** @@ -229,47 +231,47 @@ router.put( * 删除自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) */ router.delete( - "/devices/:uuid/auth-configs/:configId", - jwtAuth, - errors.catchAsync(async (req, res, next) => { - const { uuid, configId } = req.params; - const account = res.locals.account; + "/devices/:uuid/auth-configs/:configId", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const {uuid, configId} = req.params; + const account = res.locals.account; - // 查找设备并验证是否属于当前账户 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 验证设备是否绑定到当前账户 - if (!device.accountId || device.accountId !== account.id) { - return next(errors.createError(403, "该设备未绑定到您的账户")); - } + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } - // 查找自动授权配置 - const autoAuth = await prisma.autoAuth.findUnique({ - where: { id: configId }, - }); + // 查找自动授权配置 + const autoAuth = await prisma.autoAuth.findUnique({ + where: {id: configId}, + }); - if (!autoAuth) { - return next(errors.createError(404, "自动授权配置不存在")); - } + if (!autoAuth) { + return next(errors.createError(404, "自动授权配置不存在")); + } - // 确保配置属于当前设备 - if (autoAuth.deviceId !== device.id) { - return next(errors.createError(403, "无权操作此配置")); - } + // 确保配置属于当前设备 + if (autoAuth.deviceId !== device.id) { + return next(errors.createError(403, "无权操作此配置")); + } - // 删除配置 - await prisma.autoAuth.delete({ - where: { id: configId }, - }); + // 删除配置 + await prisma.autoAuth.delete({ + where: {id: configId}, + }); - return res.status(204).end(); - }) + return res.status(204).end(); + }) ); /** @@ -278,66 +280,66 @@ router.delete( * Body: { namespace: string } */ router.put( - "/devices/:uuid/namespace", - jwtAuth, - errors.catchAsync(async (req, res, next) => { - const { uuid } = req.params; - const account = res.locals.account; - const { namespace } = req.body; + "/devices/:uuid/namespace", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const {uuid} = req.params; + const account = res.locals.account; + const {namespace} = req.body; - if (!namespace) { - return next(errors.createError(400, "需要提供 namespace")); - } + if (!namespace) { + return next(errors.createError(400, "需要提供 namespace")); + } - // 规范化 namespace:去除首尾空格 - const trimmedNamespace = namespace.trim(); + // 规范化 namespace:去除首尾空格 + const trimmedNamespace = namespace.trim(); - if (!trimmedNamespace) { - return next(errors.createError(400, "namespace 不能为空")); - } + if (!trimmedNamespace) { + return next(errors.createError(400, "namespace 不能为空")); + } - // 查找设备并验证是否属于当前账户 - const device = await prisma.device.findUnique({ - where: { uuid }, - }); + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: {uuid}, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 验证设备是否绑定到当前账户 - if (!device.accountId || device.accountId !== account.id) { - return next(errors.createError(403, "该设备未绑定到您的账户")); - } + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } - // 检查新的 namespace 是否已被其他设备使用 - if (device.namespace !== trimmedNamespace) { - const existingDevice = await prisma.device.findUnique({ - where: { namespace: trimmedNamespace }, - }); + // 检查新的 namespace 是否已被其他设备使用 + if (device.namespace !== trimmedNamespace) { + const existingDevice = await prisma.device.findUnique({ + where: {namespace: trimmedNamespace}, + }); - if (existingDevice) { - return next(errors.createError(409, "该 namespace 已被其他设备使用")); - } - } + if (existingDevice) { + return next(errors.createError(409, "该 namespace 已被其他设备使用")); + } + } - // 更新设备的 namespace - const updatedDevice = await prisma.device.update({ - where: { id: device.id }, - data: { namespace: trimmedNamespace }, - }); + // 更新设备的 namespace + const updatedDevice = await prisma.device.update({ + where: {id: device.id}, + data: {namespace: trimmedNamespace}, + }); - return res.json({ - success: true, - device: { - id: updatedDevice.id, - uuid: updatedDevice.uuid, - name: updatedDevice.name, - namespace: updatedDevice.namespace, - updatedAt: updatedDevice.updatedAt, - }, - }); - }) + return res.json({ + success: true, + device: { + id: updatedDevice.id, + uuid: updatedDevice.uuid, + name: updatedDevice.name, + namespace: updatedDevice.namespace, + updatedAt: updatedDevice.updatedAt, + }, + }); + }) ); export default router; diff --git a/routes/device-auth.js b/routes/device-auth.js index 66f90a6..04aa223 100644 --- a/routes/device-auth.js +++ b/routes/device-auth.js @@ -1,13 +1,12 @@ -import { Router } from "express"; +import {Router} from "express"; import deviceCodeStore from "../utils/deviceCodeStore.js"; import errors from "../utils/errors.js"; -import { PrismaClient } from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; const router = Router(); const prisma = new PrismaClient(); - /** * POST /device/code * 生成设备授权码 @@ -21,16 +20,16 @@ const prisma = new PrismaClient(); * } */ router.post( - "/device/code", - errors.catchAsync(async (req, res) => { - const deviceCode = deviceCodeStore.create(); + "/device/code", + errors.catchAsync(async (req, res) => { + const deviceCode = deviceCodeStore.create(); - return res.json({ - device_code: deviceCode, - expires_in: 900, // 15分钟 - message: "请在前端输入此代码进行授权", - }); - }) + return res.json({ + device_code: deviceCode, + expires_in: 900, // 15分钟 + message: "请在前端输入此代码进行授权", + }); + }) ); /** @@ -53,39 +52,39 @@ router.post( * } */ router.post( - "/device/bind", - errors.catchAsync(async (req, res, next) => { - const { device_code, token } = req.body; + "/device/bind", + errors.catchAsync(async (req, res, next) => { + const {device_code, token} = req.body; - if (!device_code || !token) { - return next( - errors.createError(400, "请提供 device_code 和 token") - ); - } + if (!device_code || !token) { + return next( + errors.createError(400, "请提供 device_code 和 token") + ); + } - // 验证token是否有效(检查数据库) - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - }); + // 验证token是否有效(检查数据库) + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + }); - if (!appInstall) { - return next(errors.createError(400, "无效的令牌")); - } + if (!appInstall) { + return next(errors.createError(400, "无效的令牌")); + } - // 绑定令牌到设备代码 - const success = deviceCodeStore.bindToken(device_code, token); + // 绑定令牌到设备代码 + const success = deviceCodeStore.bindToken(device_code, token); - if (!success) { - return next( - errors.createError(400, "设备代码不存在或已过期") - ); - } + if (!success) { + return next( + errors.createError(400, "设备代码不存在或已过期") + ); + } - return res.json({ - success: true, - message: "令牌已成功绑定到设备代码", - }); - }) + return res.json({ + success: true, + message: "令牌已成功绑定到设备代码", + }); + }) ); /** @@ -117,43 +116,43 @@ router.post( * } */ router.get( - "/device/token", - errors.catchAsync(async (req, res, next) => { - const { device_code } = req.query; + "/device/token", + errors.catchAsync(async (req, res, next) => { + const {device_code} = req.query; - if (!device_code) { - return next(errors.createError(400, "请提供 device_code")); - } + if (!device_code) { + return next(errors.createError(400, "请提供 device_code")); + } - // 尝试获取并移除令牌 - const token = deviceCodeStore.getAndRemove(device_code); + // 尝试获取并移除令牌 + const token = deviceCodeStore.getAndRemove(device_code); - if (token) { - // 令牌已绑定,返回并删除 - return res.json({ - status: "success", - token, - }); - } + if (token) { + // 令牌已绑定,返回并删除 + return res.json({ + status: "success", + token, + }); + } - // 检查设备代码是否存在 - const status = deviceCodeStore.getStatus(device_code); + // 检查设备代码是否存在 + const status = deviceCodeStore.getStatus(device_code); - if (!status) { - // 设备代码不存在或已过期 - return res.json({ - status: "expired", - message: "设备代码不存在或已过期", - }); - } + if (!status) { + // 设备代码不存在或已过期 + return res.json({ + status: "expired", + message: "设备代码不存在或已过期", + }); + } - // 设备代码存在但令牌未绑定 - return res.json({ - status: "pending", - message: "等待用户授权", - expires_in: Math.floor((status.expiresAt - Date.now()) / 1000), - }); - }) + // 设备代码存在但令牌未绑定 + return res.json({ + status: "pending", + message: "等待用户授权", + expires_in: Math.floor((status.expiresAt - Date.now()) / 1000), + }); + }) ); /** @@ -172,32 +171,32 @@ router.get( * } */ router.get( - "/device/status", - errors.catchAsync(async (req, res, next) => { - const { device_code } = req.query; + "/device/status", + errors.catchAsync(async (req, res, next) => { + const {device_code} = req.query; - if (!device_code) { - return next(errors.createError(400, "请提供 device_code")); - } + if (!device_code) { + return next(errors.createError(400, "请提供 device_code")); + } - const status = deviceCodeStore.getStatus(device_code); + const status = deviceCodeStore.getStatus(device_code); - if (!status) { - return res.json({ - device_code, - exists: false, - message: "设备代码不存在或已过期", - }); - } + if (!status) { + return res.json({ + device_code, + exists: false, + message: "设备代码不存在或已过期", + }); + } - return res.json({ - device_code, - exists: true, - has_token: status.hasToken, - expires_in: Math.floor((status.expiresAt - Date.now()) / 1000), - created_at: status.createdAt, - }); - }) + return res.json({ + device_code, + exists: true, + has_token: status.hasToken, + expires_in: Math.floor((status.expiresAt - Date.now()) / 1000), + created_at: status.createdAt, + }); + }) ); export default router; diff --git a/routes/device.js b/routes/device.js index c6d09d6..db0677b 100644 --- a/routes/device.js +++ b/routes/device.js @@ -1,12 +1,11 @@ -import { Router } from "express"; -const router = Router(); -import { uuidAuth, extractDeviceInfo } from "../middleware/uuidAuth.js"; -import { PrismaClient } from "@prisma/client"; -import crypto from "crypto"; +import {Router} from "express"; +import {extractDeviceInfo} from "../middleware/uuidAuth.js"; +import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; -import { hashPassword, verifyDevicePassword } from "../utils/crypto.js"; -import { getOnlineDevices } from "../utils/socket.js"; -import { registeredDevicesTotal } from "../utils/metrics.js"; +import {getOnlineDevices} from "../utils/socket.js"; +import {registeredDevicesTotal} from "../utils/metrics.js"; + +const router = Router(); const prisma = new PrismaClient(); @@ -15,20 +14,20 @@ const prisma = new PrismaClient(); * @param {number} deviceId - 设备ID */ async function createDefaultAutoAuth(deviceId) { - try { - // 创建默认的自动授权配置:不需要密码、类型是classroom(一体机) - await prisma.autoAuth.create({ - data: { - deviceId: deviceId, - password: null, // 无密码 - deviceType: "classroom", // 一体机类型 - isReadOnly: false, // 非只读 - }, - }); - } catch (error) { - console.error('创建默认自动登录配置失败:', error); - // 这里不抛出错误,避免影响设备创建流程 - } + try { + // 创建默认的自动授权配置:不需要密码、类型是classroom(一体机) + await prisma.autoAuth.create({ + data: { + deviceId: deviceId, + password: null, // 无密码 + deviceType: "classroom", // 一体机类型 + isReadOnly: false, // 非只读 + }, + }); + } catch (error) { + console.error('创建默认自动登录配置失败:', error); + // 这里不抛出错误,避免影响设备创建流程 + } } /** @@ -36,70 +35,70 @@ async function createDefaultAutoAuth(deviceId) { * 注册新设备 */ router.post( - "/", - errors.catchAsync(async (req, res, next) => { - const { uuid, deviceName, namespace } = req.body; + "/", + errors.catchAsync(async (req, res, next) => { + const {uuid, deviceName, namespace} = req.body; - if (!uuid) { - return next(errors.createError(400, "设备UUID是必需的")); - } + if (!uuid) { + return next(errors.createError(400, "设备UUID是必需的")); + } - if (!deviceName) { - return next(errors.createError(400, "设备名称是必需的")); - } + if (!deviceName) { + return next(errors.createError(400, "设备名称是必需的")); + } - try { - // 检查UUID是否已存在 - const existingDevice = await prisma.device.findUnique({ - where: { uuid }, - }); + try { + // 检查UUID是否已存在 + const existingDevice = await prisma.device.findUnique({ + where: {uuid}, + }); - if (existingDevice) { - return next(errors.createError(409, "设备UUID已存在")); - } + if (existingDevice) { + return next(errors.createError(409, "设备UUID已存在")); + } - // 处理 namespace:如果没有提供,则使用 uuid - const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid; + // 处理 namespace:如果没有提供,则使用 uuid + const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid; - // 检查 namespace 是否已被使用 - const existingNamespace = await prisma.device.findUnique({ - where: { namespace: deviceNamespace }, - }); + // 检查 namespace 是否已被使用 + const existingNamespace = await prisma.device.findUnique({ + where: {namespace: deviceNamespace}, + }); - if (existingNamespace) { - return next(errors.createError(409, "该 namespace 已被使用")); - } + if (existingNamespace) { + return next(errors.createError(409, "该 namespace 已被使用")); + } - // 创建设备 - const device = await prisma.device.create({ - data: { - uuid, - name: deviceName, - namespace: deviceNamespace, - }, - }); + // 创建设备 + const device = await prisma.device.create({ + data: { + uuid, + name: deviceName, + namespace: deviceNamespace, + }, + }); - // 为新设备创建默认的自动登录配置 - await createDefaultAutoAuth(device.id); + // 为新设备创建默认的自动登录配置 + await createDefaultAutoAuth(device.id); - // 更新注册设备总数指标 - const totalDevices = await prisma.device.count(); - registeredDevicesTotal.set(totalDevices); + // 更新注册设备总数指标 + const totalDevices = await prisma.device.count(); + registeredDevicesTotal.set(totalDevices); - return res.status(201).json({ - success: true, - device: { - id: device.id, - uuid: device.uuid, - name: device.name, - namespace: device.namespace, - createdAt: device.createdAt, - }, - }); - } catch (error) { - throw error; - } - }) + return res.status(201).json({ + success: true, + device: { + id: device.id, + uuid: device.uuid, + name: device.name, + namespace: device.namespace, + createdAt: device.createdAt, + }, + }); + } catch (error) { + throw error; + } + }) ); /** @@ -107,111 +106,111 @@ router.post( * 获取设备信息 (公开接口,无需认证) */ router.get( - "/:uuid", - errors.catchAsync(async (req, res, next) => { - const { uuid } = req.params; + "/:uuid", + errors.catchAsync(async (req, res, next) => { + const {uuid} = req.params; - // 查找设备,包含绑定的账户信息 - const device = await prisma.device.findUnique({ - where: { uuid }, - include: { - account: { - select: { - id: true, - name: true, - email: true, - avatarUrl: true, - }, - }, - }, - }); + // 查找设备,包含绑定的账户信息 + const device = await prisma.device.findUnique({ + where: {uuid}, + include: { + account: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - return res.json({ - id: device.id, - uuid: device.uuid, - name: device.name, - hasPassword: !!device.password, - passwordHint: device.passwordHint, - createdAt: device.createdAt, - account: device.account ? { - id: device.account.id, - name: device.account.name, - email: device.account.email, - avatarUrl: device.account.avatarUrl, - } : null, - isBoundToAccount: !!device.account, - namespace: device.namespace, - }); - }) -);/** + return res.json({ + id: device.id, + uuid: device.uuid, + name: device.name, + hasPassword: !!device.password, + passwordHint: device.passwordHint, + createdAt: device.createdAt, + account: device.account ? { + id: device.account.id, + name: device.account.name, + email: device.account.email, + avatarUrl: device.account.avatarUrl, + } : null, + isBoundToAccount: !!device.account, + namespace: device.namespace, + }); + }) +); +/** * PUT /devices/:uuid/name * 设置设备名称 (需要UUID认证) */ router.put( - "/:uuid/name", - extractDeviceInfo, - errors.catchAsync(async (req, res, next) => { - const { name } = req.body; - const device = res.locals.device; + "/:uuid/name", + extractDeviceInfo, + errors.catchAsync(async (req, res, next) => { + const {name} = req.body; + const device = res.locals.device; - if (!name) { - return next(errors.createError(400, "设备名称是必需的")); - } + if (!name) { + return next(errors.createError(400, "设备名称是必需的")); + } - const updatedDevice = await prisma.device.update({ - where: { id: device.id }, - data: { name }, - }); + const updatedDevice = await prisma.device.update({ + where: {id: device.id}, + data: {name}, + }); - return res.json({ - success: true, - device: { - id: updatedDevice.id, - uuid: updatedDevice.uuid, - name: updatedDevice.name, - hasPassword: !!updatedDevice.password, - passwordHint: updatedDevice.passwordHint, - }, - }); - }) + return res.json({ + success: true, + device: { + id: updatedDevice.id, + uuid: updatedDevice.uuid, + name: updatedDevice.name, + hasPassword: !!updatedDevice.password, + passwordHint: updatedDevice.passwordHint, + }, + }); + }) ); - /** * GET /devices/online * 查询在线设备(WebSocket 已连接) * 返回:[{ uuid, connections, name? }] */ router.get( - "/online", - errors.catchAsync(async (req, res) => { - const list = getOnlineDevices(); + "/online", + errors.catchAsync(async (req, res) => { + const list = getOnlineDevices(); - if (list.length === 0) { - return res.json({ success: true, devices: [] }); - } + if (list.length === 0) { + return res.json({success: true, devices: []}); + } - // 补充设备名称 - const uuids = list.map((x) => x.uuid); - const rows = await prisma.device.findMany({ - where: { uuid: { in: uuids } }, - select: { uuid: true, name: true }, - }); - const nameMap = new Map(rows.map((r) => [r.uuid, r.name])); + // 补充设备名称 + const uuids = list.map((x) => x.uuid); + const rows = await prisma.device.findMany({ + where: {uuid: {in: uuids}}, + select: {uuid: true, name: true}, + }); + const nameMap = new Map(rows.map((r) => [r.uuid, r.name])); - const devices = list.map((x) => ({ - uuid: x.uuid, - connections: x.connections, - name: nameMap.get(x.uuid) || null, - })); + const devices = list.map((x) => ({ + uuid: x.uuid, + connections: x.connections, + name: nameMap.get(x.uuid) || null, + })); - res.json({ success: true, devices }); - }) + res.json({success: true, devices}); + }) ); export default router; diff --git a/routes/index.js b/routes/index.js index 0061319..8a964de 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,9 +1,10 @@ -import { Router } from "express"; +import {Router} from "express"; + var router = Router(); /* GET home page. */ router.get("/", function (req, res, next) { - res.render("index"); + res.render("index"); }); export default router; diff --git a/routes/kv-token.js b/routes/kv-token.js index 2cb0ea7..8090577 100644 --- a/routes/kv-token.js +++ b/routes/kv-token.js @@ -1,17 +1,18 @@ -import { Router } from "express"; -const router = Router(); +import {Router} from "express"; import kvStore from "../utils/kvStore.js"; -import { broadcastKeyChanged } from "../utils/socket.js"; -import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; +import {broadcastKeyChanged} from "../utils/socket.js"; +import {kvTokenAuth} from "../middleware/kvTokenAuth.js"; import { - tokenReadLimiter, - tokenWriteLimiter, - tokenDeleteLimiter, - tokenBatchLimiter, - prepareTokenForRateLimit + prepareTokenForRateLimit, + tokenBatchLimiter, + tokenDeleteLimiter, + tokenReadLimiter, + tokenWriteLimiter } from "../middleware/rateLimiter.js"; import errors from "../utils/errors.js"; -import { PrismaClient } from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; + +const router = Router(); const prisma = new PrismaClient(); @@ -26,52 +27,52 @@ router.use(prepareTokenForRateLimit); * 获取当前token所属设备的信息,如果关联了账号也返回账号信息 */ router.get( - "/_info", - tokenReadLimiter, - errors.catchAsync(async (req, res, next) => { - const deviceId = res.locals.deviceId; + "/_info", + tokenReadLimiter, + errors.catchAsync(async (req, res, next) => { + const deviceId = res.locals.deviceId; - // 获取设备信息,包含关联的账号 - const device = await prisma.device.findUnique({ - where: { id: deviceId }, - include: { - account: true, - }, - }); + // 获取设备信息,包含关联的账号 + const device = await prisma.device.findUnique({ + where: {id: deviceId}, + include: { + account: true, + }, + }); - if (!device) { - return next(errors.createError(404, "设备不存在")); - } + if (!device) { + return next(errors.createError(404, "设备不存在")); + } - // 构建响应对象:当设备没有关联账号时返回 uuid;若已关联账号则不返回 uuid - const response = { - device: { - id: device.id, - name: device.name, - createdAt: device.createdAt, - updatedAt: device.updatedAt, - }, - }; + // 构建响应对象:当设备没有关联账号时返回 uuid;若已关联账号则不返回 uuid + const response = { + device: { + id: device.id, + name: device.name, + createdAt: device.createdAt, + updatedAt: device.updatedAt, + }, + }; - // 仅当设备未绑定账号时,包含 uuid 字段 - if (!device.account) { - response.device.uuid = device.uuid; - } + // 仅当设备未绑定账号时,包含 uuid 字段 + if (!device.account) { + response.device.uuid = device.uuid; + } - // 标识是否已绑定账号 - response.hasAccount = !!device.account; + // 标识是否已绑定账号 + response.hasAccount = !!device.account; - // 如果关联了账号,添加账号信息 - if (device.account) { - response.account = { - id: device.account.id, - name: device.account.name, - avatarUrl: device.account.avatarUrl, - }; - } + // 如果关联了账号,添加账号信息 + if (device.account) { + response.account = { + id: device.account.id, + name: device.account.name, + avatarUrl: device.account.avatarUrl, + }; + } - return res.json(response); - }) + return res.json(response); + }) ); /** @@ -79,48 +80,48 @@ router.get( * 获取当前 KV Token 的详细信息(类型、备注等) */ router.get( - "/_token", - tokenReadLimiter, - errors.catchAsync(async (req, res, next) => { - const token = res.locals.token; - const deviceId = res.locals.deviceId; + "/_token", + tokenReadLimiter, + errors.catchAsync(async (req, res, next) => { + const token = res.locals.token; + const deviceId = res.locals.deviceId; - // 查找当前 token 对应的应用安装记录 - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { - device: { - select: { - id: true, - uuid: true, - name: true, - namespace: true, - }, - }, - }, - }); + // 查找当前 token 对应的应用安装记录 + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + include: { + device: { + select: { + id: true, + uuid: true, + name: true, + namespace: true, + }, + }, + }, + }); - if (!appInstall) { - return next(errors.createError(404, "Token 信息不存在")); - } + if (!appInstall) { + return next(errors.createError(404, "Token 信息不存在")); + } - return res.json({ - success: true, - token: appInstall.token, - appId: appInstall.appId, - deviceType: appInstall.deviceType, - isReadOnly: appInstall.isReadOnly, - note: appInstall.note, - installedAt: appInstall.installedAt, - updatedAt: appInstall.updatedAt, - device: { - id: appInstall.device.id, - uuid: appInstall.device.uuid, - name: appInstall.device.name, - namespace: appInstall.device.namespace, - }, - }); - }) + return res.json({ + success: true, + token: appInstall.token, + appId: appInstall.appId, + deviceType: appInstall.deviceType, + isReadOnly: appInstall.isReadOnly, + note: appInstall.note, + installedAt: appInstall.installedAt, + updatedAt: appInstall.updatedAt, + device: { + id: appInstall.device.id, + uuid: appInstall.device.uuid, + name: appInstall.device.name, + namespace: appInstall.device.namespace, + }, + }); + }) ); /** @@ -128,50 +129,50 @@ router.get( * 获取当前token对应设备的键名列表(分页,不包括内容) */ router.get( - "/_keys", - tokenReadLimiter, - errors.catchAsync(async (req, res) => { - const deviceId = res.locals.deviceId; - const { sortBy, sortDir, limit, skip } = req.query; + "/_keys", + tokenReadLimiter, + errors.catchAsync(async (req, res) => { + const deviceId = res.locals.deviceId; + const {sortBy, sortDir, limit, skip} = req.query; - // 构建选项 - const options = { - sortBy: sortBy || "key", - sortDir: sortDir || "asc", - limit: limit ? parseInt(limit) : 100, - skip: skip ? parseInt(skip) : 0, - }; + // 构建选项 + const options = { + sortBy: sortBy || "key", + sortDir: sortDir || "asc", + limit: limit ? parseInt(limit) : 100, + skip: skip ? parseInt(skip) : 0, + }; - const keys = await kvStore.listKeysOnly(deviceId, options); - const totalRows = keys.length; + const keys = await kvStore.listKeysOnly(deviceId, options); + const totalRows = keys.length; - // 构建响应对象 - const response = { - keys: keys, - total_rows: totalRows, - current_page: { - limit: options.limit, - skip: options.skip, - count: keys.length, - }, - }; + // 构建响应对象 + const response = { + keys: keys, + total_rows: totalRows, + current_page: { + limit: options.limit, + skip: options.skip, + count: keys.length, + }, + }; - // 如果还有更多数据,添加load_more字段 - const nextSkip = options.skip + options.limit; - if (nextSkip < totalRows) { - const baseUrl = `${req.baseUrl}/_keys`; - const queryParams = new URLSearchParams({ - sortBy: options.sortBy, - sortDir: options.sortDir, - limit: options.limit, - skip: nextSkip, - }).toString(); + // 如果还有更多数据,添加load_more字段 + const nextSkip = options.skip + options.limit; + if (nextSkip < totalRows) { + const baseUrl = `${req.baseUrl}/_keys`; + const queryParams = new URLSearchParams({ + sortBy: options.sortBy, + sortDir: options.sortDir, + limit: options.limit, + skip: nextSkip, + }).toString(); - response.load_more = `${baseUrl}?${queryParams}`; - } + response.load_more = `${baseUrl}?${queryParams}`; + } - return res.json(response); - }) + return res.json(response); + }) ); /** @@ -179,45 +180,45 @@ router.get( * 获取当前token对应设备的所有键名及元数据列表 */ router.get( - "/", - tokenReadLimiter, - errors.catchAsync(async (req, res) => { - const deviceId = res.locals.deviceId; - const { sortBy, sortDir, limit, skip } = req.query; + "/", + tokenReadLimiter, + errors.catchAsync(async (req, res) => { + const deviceId = res.locals.deviceId; + const {sortBy, sortDir, limit, skip} = req.query; - // 构建选项 - const options = { - sortBy: sortBy || "key", - sortDir: sortDir || "asc", - limit: limit ? parseInt(limit) : 100, - skip: skip ? parseInt(skip) : 0, - }; + // 构建选项 + const options = { + sortBy: sortBy || "key", + sortDir: sortDir || "asc", + limit: limit ? parseInt(limit) : 100, + skip: skip ? parseInt(skip) : 0, + }; - const keys = await kvStore.list(deviceId, options); - const totalRows = await kvStore.count(deviceId); + const keys = await kvStore.list(deviceId, options); + const totalRows = await kvStore.count(deviceId); - // 构建响应对象 - const response = { - items: keys, - total_rows: totalRows, - }; + // 构建响应对象 + const response = { + items: keys, + total_rows: totalRows, + }; - // 如果还有更多数据,添加load_more字段 - const nextSkip = options.skip + options.limit; - if (nextSkip < totalRows) { - const baseUrl = `${req.baseUrl}`; - const queryParams = new URLSearchParams({ - sortBy: options.sortBy, - sortDir: options.sortDir, - limit: options.limit, - skip: nextSkip, - }).toString(); + // 如果还有更多数据,添加load_more字段 + const nextSkip = options.skip + options.limit; + if (nextSkip < totalRows) { + const baseUrl = `${req.baseUrl}`; + const queryParams = new URLSearchParams({ + sortBy: options.sortBy, + sortDir: options.sortDir, + limit: options.limit, + skip: nextSkip, + }).toString(); - response.load_more = `${baseUrl}?${queryParams}`; - } + response.load_more = `${baseUrl}?${queryParams}`; + } - return res.json(response); - }) + return res.json(response); + }) ); /** @@ -225,22 +226,22 @@ router.get( * 通过键名获取键值 */ router.get( - "/:key", - tokenReadLimiter, - errors.catchAsync(async (req, res, next) => { - const deviceId = res.locals.deviceId; - const { key } = req.params; + "/:key", + tokenReadLimiter, + errors.catchAsync(async (req, res, next) => { + const deviceId = res.locals.deviceId; + const {key} = req.params; - const value = await kvStore.get(deviceId, key); + const value = await kvStore.get(deviceId, key); - if (value === null) { - return next( - errors.createError(404, `未找到键名为 '${key}' 的记录`) - ); - } + if (value === null) { + return next( + errors.createError(404, `未找到键名为 '${key}' 的记录`) + ); + } - return res.json(value); - }) + return res.json(value); + }) ); /** @@ -248,20 +249,20 @@ router.get( * 获取键的元数据 */ router.get( - "/:key/metadata", - tokenReadLimiter, - errors.catchAsync(async (req, res, next) => { - const deviceId = res.locals.deviceId; - const { key } = req.params; + "/:key/metadata", + tokenReadLimiter, + errors.catchAsync(async (req, res, next) => { + const deviceId = res.locals.deviceId; + const {key} = req.params; - const metadata = await kvStore.getMetadata(deviceId, key); - if (!metadata) { - return next( - errors.createError(404, `未找到键名为 '${key}' 的记录`) - ); - } - return res.json(metadata); - }) + const metadata = await kvStore.getMetadata(deviceId, key); + if (!metadata) { + return next( + errors.createError(404, `未找到键名为 '${key}' 的记录`) + ); + } + return res.json(metadata); + }) ); /** @@ -269,73 +270,73 @@ router.get( * 批量导入键值对 */ router.post( - "/_batchimport", - tokenBatchLimiter, - errors.catchAsync(async (req, res, next) => { - // 检查token是否为只读 - if (res.locals.appInstall?.isReadOnly) { - return next(errors.createError(403, "当前token为只读模式,无法修改数据")); - } - - const deviceId = res.locals.deviceId; - const data = req.body; - - if (!data || Object.keys(data).length === 0) { - return next( - errors.createError( - 400, - '请提供有效的JSON数据,格式为 {"key":{}, "key2":{}}' - ) - ); - } - - // 获取客户端IP - const creatorIp = - req.headers["x-forwarded-for"] || - req.connection.remoteAddress || - req.socket.remoteAddress || - req.connection.socket?.remoteAddress || - ""; - - const results = []; - const errorList = []; - - // 批量处理所有键值对 - for (const [key, value] of Object.entries(data)) { - try { - const result = await kvStore.upsert(deviceId, key, value, creatorIp); - results.push({ - key: result.key, - created: result.createdAt.getTime() === result.updatedAt.getTime(), - }); - // 广播每个键的变更 - const uuid = res.locals.device?.uuid; - if (uuid) { - broadcastKeyChanged(uuid, { - key: result.key, - action: "upsert", - created: result.createdAt.getTime() === result.updatedAt.getTime(), - updatedAt: result.updatedAt, - batch: true, - }); + "/_batchimport", + tokenBatchLimiter, + errors.catchAsync(async (req, res, next) => { + // 检查token是否为只读 + if (res.locals.appInstall?.isReadOnly) { + return next(errors.createError(403, "当前token为只读模式,无法修改数据")); } - } catch (error) { - errorList.push({ - key, - error: error.message, - }); - } - } - return res.status(200).json({ - deviceId, - total: Object.keys(data).length, - successful: results.length, - failed: errorList.length, - results, - errors: errorList.length > 0 ? errorList : undefined, - }); - }) + const deviceId = res.locals.deviceId; + const data = req.body; + + if (!data || Object.keys(data).length === 0) { + return next( + errors.createError( + 400, + '请提供有效的JSON数据,格式为 {"key":{}, "key2":{}}' + ) + ); + } + + // 获取客户端IP + const creatorIp = + req.headers["x-forwarded-for"] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket?.remoteAddress || + ""; + + const results = []; + const errorList = []; + + // 批量处理所有键值对 + for (const [key, value] of Object.entries(data)) { + try { + const result = await kvStore.upsert(deviceId, key, value, creatorIp); + results.push({ + key: result.key, + created: result.createdAt.getTime() === result.updatedAt.getTime(), + }); + // 广播每个键的变更 + const uuid = res.locals.device?.uuid; + if (uuid) { + broadcastKeyChanged(uuid, { + key: result.key, + action: "upsert", + created: result.createdAt.getTime() === result.updatedAt.getTime(), + updatedAt: result.updatedAt, + batch: true, + }); + } + } catch (error) { + errorList.push({ + key, + error: error.message, + }); + } + } + + return res.status(200).json({ + deviceId, + total: Object.keys(data).length, + successful: results.length, + failed: errorList.length, + results, + errors: errorList.length > 0 ? errorList : undefined, + }); + }) ); /** @@ -343,50 +344,50 @@ router.post( * 更新或创建键值 */ router.post( - "/:key", - tokenWriteLimiter, - errors.catchAsync(async (req, res, next) => { - // 检查token是否为只读 - if (res.locals.appInstall?.isReadOnly) { - return next(errors.createError(403, "当前token为只读模式,无法修改数据")); - } + "/:key", + tokenWriteLimiter, + errors.catchAsync(async (req, res, next) => { + // 检查token是否为只读 + if (res.locals.appInstall?.isReadOnly) { + return next(errors.createError(403, "当前token为只读模式,无法修改数据")); + } - const deviceId = res.locals.deviceId; - const { key } = req.params; - const value = req.body; + const deviceId = res.locals.deviceId; + const {key} = req.params; + const value = req.body; - if (!value || Object.keys(value).length === 0) { - return next(errors.createError(400, "请提供有效的JSON值")); - } + if (!value || Object.keys(value).length === 0) { + return next(errors.createError(400, "请提供有效的JSON值")); + } - // 获取客户端IP - const creatorIp = - req.headers["x-forwarded-for"] || - req.connection.remoteAddress || - req.socket.remoteAddress || - req.connection.socket?.remoteAddress || - ""; + // 获取客户端IP + const creatorIp = + req.headers["x-forwarded-for"] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket?.remoteAddress || + ""; - const result = await kvStore.upsert(deviceId, key, value, creatorIp); + const result = await kvStore.upsert(deviceId, key, value, creatorIp); - // 广播单个键的变更 - const uuid = res.locals.device?.uuid; - if (uuid) { - broadcastKeyChanged(uuid, { - key: result.key, - action: "upsert", - created: result.createdAt.getTime() === result.updatedAt.getTime(), - updatedAt: result.updatedAt, - }); - } + // 广播单个键的变更 + const uuid = res.locals.device?.uuid; + if (uuid) { + broadcastKeyChanged(uuid, { + key: result.key, + action: "upsert", + created: result.createdAt.getTime() === result.updatedAt.getTime(), + updatedAt: result.updatedAt, + }); + } - return res.status(200).json({ - deviceId: result.deviceId, - key: result.key, - created: result.createdAt.getTime() === result.updatedAt.getTime(), - updatedAt: result.updatedAt, - }); - }) + return res.status(200).json({ + deviceId: result.deviceId, + key: result.key, + created: result.createdAt.getTime() === result.updatedAt.getTime(), + updatedAt: result.updatedAt, + }); + }) ); /** @@ -394,38 +395,38 @@ router.post( * 删除键值对 */ router.delete( - "/:key", - tokenDeleteLimiter, - errors.catchAsync(async (req, res, next) => { - // 检查token是否为只读 - if (res.locals.appInstall?.isReadOnly) { - return next(errors.createError(403, "当前token为只读模式,无法修改数据")); - } + "/:key", + tokenDeleteLimiter, + errors.catchAsync(async (req, res, next) => { + // 检查token是否为只读 + if (res.locals.appInstall?.isReadOnly) { + return next(errors.createError(403, "当前token为只读模式,无法修改数据")); + } - const deviceId = res.locals.deviceId; - const { key } = req.params; + const deviceId = res.locals.deviceId; + const {key} = req.params; - const result = await kvStore.delete(deviceId, key); + const result = await kvStore.delete(deviceId, key); - if (!result) { - return next( - errors.createError(404, `未找到键名为 '${key}' 的记录`) - ); - } + if (!result) { + return next( + errors.createError(404, `未找到键名为 '${key}' 的记录`) + ); + } - // 广播删除 - const uuid = res.locals.device?.uuid; - if (uuid) { - broadcastKeyChanged(uuid, { - key, - action: "delete", - deletedAt: new Date(), - }); - } + // 广播删除 + const uuid = res.locals.device?.uuid; + if (uuid) { + broadcastKeyChanged(uuid, { + key, + action: "delete", + deletedAt: new Date(), + }); + } - // 204状态码表示成功但无内容返回 - return res.status(204).end(); - }) + // 204状态码表示成功但无内容返回 + return res.status(204).end(); + }) ); export default router; \ No newline at end of file diff --git a/utils/config.js b/utils/config.js index e563270..1a6e97b 100644 --- a/utils/config.js +++ b/utils/config.js @@ -1,4 +1,5 @@ import dotenv from "dotenv"; + dotenv.config(); export const siteKey = process.env.SITE_KEY || ""; diff --git a/utils/crypto.js b/utils/crypto.js index 99972de..74c43c2 100644 --- a/utils/crypto.js +++ b/utils/crypto.js @@ -1,5 +1,5 @@ import bcrypt from "bcrypt"; -import { Base64 } from "js-base64"; +import {Base64} from "js-base64"; const SALT_ROUNDS = 8; @@ -7,37 +7,37 @@ const SALT_ROUNDS = 8; * 从 base64 解码字符串 */ export function decodeBase64(str) { - if (!str) return null; - try { - return Base64.decode(str); - } catch (error) { - return null; - } + if (!str) return null; + try { + return Base64.decode(str); + } catch (error) { + return null; + } } /** * 对字符串进行 UTF-8 编码处理 */ function encodeUTF8(str) { - try { - return encodeURIComponent(str); - } catch (error) { - return null; - } + try { + return encodeURIComponent(str); + } catch (error) { + return null; + } } /** * 验证站点密钥 */ export function verifySiteKey(providedKey, actualKey) { - if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过 - if (!providedKey) return false; - const decodedKey = decodeBase64(providedKey); - if (!decodedKey) return false; - const encodedKey = encodeUTF8(decodedKey); - if (!encodedKey) return false; - console.debug(encodedKey); - return encodedKey === actualKey; + if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过 + if (!providedKey) return false; + const decodedKey = decodeBase64(providedKey); + if (!decodedKey) return false; + const encodedKey = encodeUTF8(decodedKey); + if (!encodedKey) return false; + console.debug(encodedKey); + return encodedKey === actualKey; } /** @@ -46,8 +46,8 @@ export function verifySiteKey(providedKey, actualKey) { * @returns {Promise} 哈希后的密码 */ export async function hashPassword(password) { - if (!password) return null; - return await bcrypt.hash(password, SALT_ROUNDS); + if (!password) return null; + return await bcrypt.hash(password, SALT_ROUNDS); } /** @@ -57,11 +57,11 @@ export async function hashPassword(password) { * @returns {Promise} 密码是否匹配 */ export async function verifyDevicePassword(providedPassword, hashedPassword) { - if (!providedPassword || !hashedPassword) return false; - try { - return await bcrypt.compare(providedPassword, hashedPassword); - } catch (error) { - console.error('密码验证错误:', error); - return false; - } + if (!providedPassword || !hashedPassword) return false; + try { + return await bcrypt.compare(providedPassword, hashedPassword); + } catch (error) { + console.error('密码验证错误:', error); + return false; + } } diff --git a/utils/deviceCodeStore.js b/utils/deviceCodeStore.js index f16795e..5e5c173 100644 --- a/utils/deviceCodeStore.js +++ b/utils/deviceCodeStore.js @@ -6,182 +6,182 @@ */ class DeviceCodeStore { - constructor() { - // 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } } - this.store = new Map(); + constructor() { + // 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } } + this.store = new Map(); - // 默认过期时间: 15分钟 - this.expirationTime = 15 * 60 * 1000; + // 默认过期时间: 15分钟 + this.expirationTime = 15 * 60 * 1000; - // 定期清理过期数据 (每5分钟) - this.cleanupInterval = setInterval(() => { - this.cleanup(); - }, 5 * 60 * 1000); - } - - /** - * 生成设备代码 (格式: 1234-ABCD) - */ - generateDeviceCode() { - const part1 = Math.floor(1000 + Math.random() * 9000).toString(); // 4位数字 - const part2 = Math.random().toString(36).substring(2, 6).toUpperCase(); // 4位字母 - return `${part1}-${part2}`; - } - - /** - * 创建新的设备代码 - * @returns {string} 生成的设备代码 - */ - create() { - let deviceCode; - - // 确保生成的代码不重复 - do { - deviceCode = this.generateDeviceCode(); - } while (this.store.has(deviceCode)); - - const now = Date.now(); - this.store.set(deviceCode, { - token: null, - expiresAt: now + this.expirationTime, - createdAt: now, - }); - - return deviceCode; - } - - /** - * 绑定令牌到设备代码 - * @param {string} deviceCode - 设备代码 - * @param {string} token - 令牌 - * @returns {boolean} 是否成功绑定 - */ - bindToken(deviceCode, token) { - const entry = this.store.get(deviceCode); - - if (!entry) { - return false; + // 定期清理过期数据 (每5分钟) + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 5 * 60 * 1000); } - // 检查是否过期 - if (Date.now() > entry.expiresAt) { - this.store.delete(deviceCode); - return false; + /** + * 生成设备代码 (格式: 1234-ABCD) + */ + generateDeviceCode() { + const part1 = Math.floor(1000 + Math.random() * 9000).toString(); // 4位数字 + const part2 = Math.random().toString(36).substring(2, 6).toUpperCase(); // 4位字母 + return `${part1}-${part2}`; } - // 绑定令牌 - entry.token = token; - return true; - } + /** + * 创建新的设备代码 + * @returns {string} 生成的设备代码 + */ + create() { + let deviceCode; - /** - * 获取设备代码对应的令牌(获取后删除) - * @param {string} deviceCode - 设备代码 - * @returns {string|null} 令牌,如果不存在或未绑定返回null - */ - getAndRemove(deviceCode) { - const entry = this.store.get(deviceCode); + // 确保生成的代码不重复 + do { + deviceCode = this.generateDeviceCode(); + } while (this.store.has(deviceCode)); - if (!entry) { - return null; + const now = Date.now(); + this.store.set(deviceCode, { + token: null, + expiresAt: now + this.expirationTime, + createdAt: now, + }); + + return deviceCode; } - // 检查是否过期 - if (Date.now() > entry.expiresAt) { - this.store.delete(deviceCode); - return null; + /** + * 绑定令牌到设备代码 + * @param {string} deviceCode - 设备代码 + * @param {string} token - 令牌 + * @returns {boolean} 是否成功绑定 + */ + bindToken(deviceCode, token) { + const entry = this.store.get(deviceCode); + + if (!entry) { + return false; + } + + // 检查是否过期 + if (Date.now() > entry.expiresAt) { + this.store.delete(deviceCode); + return false; + } + + // 绑定令牌 + entry.token = token; + return true; } - // 如果令牌未绑定,返回null但不删除代码 - if (!entry.token) { - return null; - } + /** + * 获取设备代码对应的令牌(获取后删除) + * @param {string} deviceCode - 设备代码 + * @returns {string|null} 令牌,如果不存在或未绑定返回null + */ + getAndRemove(deviceCode) { + const entry = this.store.get(deviceCode); - // 获取令牌后删除条目 - const token = entry.token; - this.store.delete(deviceCode); - return token; - } + if (!entry) { + return null; + } - /** - * 检查设备代码是否存在且未过期 - * @param {string} deviceCode - 设备代码 - * @returns {boolean} - */ - exists(deviceCode) { - const entry = this.store.get(deviceCode); + // 检查是否过期 + if (Date.now() > entry.expiresAt) { + this.store.delete(deviceCode); + return null; + } - if (!entry) { - return false; - } + // 如果令牌未绑定,返回null但不删除代码 + if (!entry.token) { + return null; + } - if (Date.now() > entry.expiresAt) { - this.store.delete(deviceCode); - return false; - } - - return true; - } - - /** - * 获取设备代码的状态信息(不删除) - * @param {string} deviceCode - 设备代码 - * @returns {object|null} 状态信息 - */ - getStatus(deviceCode) { - const entry = this.store.get(deviceCode); - - if (!entry) { - return null; - } - - if (Date.now() > entry.expiresAt) { - this.store.delete(deviceCode); - return null; - } - - return { - hasToken: !!entry.token, - expiresAt: entry.expiresAt, - createdAt: entry.createdAt, - }; - } - - /** - * 清理过期的条目 - */ - cleanup() { - const now = Date.now(); - let cleanedCount = 0; - - for (const [deviceCode, entry] of this.store.entries()) { - if (now > entry.expiresAt) { + // 获取令牌后删除条目 + const token = entry.token; this.store.delete(deviceCode); - cleanedCount++; - } + return token; } - if (cleanedCount > 0) { - console.log(`清理了 ${cleanedCount} 个过期的设备代码`); - } - } + /** + * 检查设备代码是否存在且未过期 + * @param {string} deviceCode - 设备代码 + * @returns {boolean} + */ + exists(deviceCode) { + const entry = this.store.get(deviceCode); - /** - * 获取当前存储的条目数量 - */ - size() { - return this.store.size; - } + if (!entry) { + return false; + } - /** - * 清理定时器(用于优雅关闭) - */ - destroy() { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); + if (Date.now() > entry.expiresAt) { + this.store.delete(deviceCode); + return false; + } + + return true; + } + + /** + * 获取设备代码的状态信息(不删除) + * @param {string} deviceCode - 设备代码 + * @returns {object|null} 状态信息 + */ + getStatus(deviceCode) { + const entry = this.store.get(deviceCode); + + if (!entry) { + return null; + } + + if (Date.now() > entry.expiresAt) { + this.store.delete(deviceCode); + return null; + } + + return { + hasToken: !!entry.token, + expiresAt: entry.expiresAt, + createdAt: entry.createdAt, + }; + } + + /** + * 清理过期的条目 + */ + cleanup() { + const now = Date.now(); + let cleanedCount = 0; + + for (const [deviceCode, entry] of this.store.entries()) { + if (now > entry.expiresAt) { + this.store.delete(deviceCode); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + console.log(`清理了 ${cleanedCount} 个过期的设备代码`); + } + } + + /** + * 获取当前存储的条目数量 + */ + size() { + return this.store.size; + } + + /** + * 清理定时器(用于优雅关闭) + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + this.store.clear(); } - this.store.clear(); - } } // 导出单例 @@ -189,11 +189,11 @@ const deviceCodeStore = new DeviceCodeStore(); // 优雅关闭处理 process.on('SIGTERM', () => { - deviceCodeStore.destroy(); + deviceCodeStore.destroy(); }); process.on('SIGINT', () => { - deviceCodeStore.destroy(); + deviceCodeStore.destroy(); }); export default deviceCodeStore; diff --git a/utils/errors.js b/utils/errors.js index 1e96af4..2118e97 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -7,14 +7,14 @@ * @returns {object} 标准错误对象 */ const createError = (statusCode, message, details = null, code = null) => { - // 直接返回错误对象,不抛出异常 - const error = { - statusCode: statusCode, - message: message || '服务器错误', - details: details, - code: code || undefined, - }; - return error; + // 直接返回错误对象,不抛出异常 + const error = { + statusCode: statusCode, + message: message || '服务器错误', + details: details, + code: code || undefined, + }; + return error; }; /** @@ -24,11 +24,11 @@ const createError = (statusCode, message, details = null, code = null) => { * @returns {object} 格式化的成功响应 */ const createSuccessResponse = (data, message = null) => { - return { - success: true, - message, - data, - }; + return { + success: true, + message, + data, + }; }; /** @@ -37,20 +37,20 @@ const createSuccessResponse = (data, message = null) => { * @param {Function} next - Express中间件next函数 */ const passError = (error, next) => { - // 不管是什么类型的错误,统一转换并传递 - if (error instanceof Error) { - // 如果是标准Error,则转换为HTTP错误并保留原始信息 - const httpError = { - statusCode: error.statusCode || 500, - message: error.message || '服务器错误', - details: error.details || null, - originalError: error - }; - next(httpError); - } else { - // 已经是自定义错误对象结构,直接传递 - next(error); - } + // 不管是什么类型的错误,统一转换并传递 + if (error instanceof Error) { + // 如果是标准Error,则转换为HTTP错误并保留原始信息 + const httpError = { + statusCode: error.statusCode || 500, + message: error.message || '服务器错误', + details: error.details || null, + originalError: error + }; + next(httpError); + } else { + // 已经是自定义错误对象结构,直接传递 + next(error); + } }; /** @@ -59,11 +59,11 @@ const passError = (error, next) => { * @returns {Function} 包装后的函数 */ const catchAsync = (fn) => { - return (req, res, next) => { - Promise.resolve(fn(req, res, next)).catch(error => { - passError(error, next); - }); - }; + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(error => { + passError(error, next); + }); + }; }; /** @@ -73,31 +73,31 @@ const catchAsync = (fn) => { * @returns {[error, result]} 包含错误和结果的数组 */ const trySafe = (fn, ...args) => { - try { - const result = fn(...args); - return [null, result]; - } catch (error) { - return [error, null]; - } + try { + const result = fn(...args); + return [null, result]; + } catch (error) { + return [error, null]; + } }; // 常用状态码 const HTTP_STATUS = { - OK: 200, - CREATED: 201, - NO_CONTENT: 204, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - INTERNAL_SERVER_ERROR: 500, + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, }; export default { - createError, - createSuccessResponse, - passError, - catchAsync, - trySafe, - HTTP_STATUS, + createError, + createSuccessResponse, + passError, + catchAsync, + trySafe, + HTTP_STATUS, }; diff --git a/utils/instrumentation.js b/utils/instrumentation.js index a5a9a1e..51e3c33 100644 --- a/utils/instrumentation.js +++ b/utils/instrumentation.js @@ -1,42 +1,43 @@ import "dotenv/config"; -import { NodeSDK } from "@opentelemetry/sdk-node"; -import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; -import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; -import { resourceFromAttributes } from "@opentelemetry/resources"; -import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; +import {NodeSDK} from "@opentelemetry/sdk-node"; +import {getNodeAutoInstrumentations} from "@opentelemetry/auto-instrumentations-node"; +import {OTLPTraceExporter} from "@opentelemetry/exporter-trace-otlp-proto"; +import {BatchSpanProcessor} from "@opentelemetry/sdk-trace-base"; +import {resourceFromAttributes} from "@opentelemetry/resources"; +import {SemanticResourceAttributes} from "@opentelemetry/semantic-conventions"; + if (process.env.AXIOM_TOKEN && process.env.AXIOM_DATASET) { - // Initialize OTLP trace exporter with the endpoint URL and headers - // Initialize OTLP trace exporter with the endpoint URL and headers - const traceExporter = new OTLPTraceExporter({ - url: "https://api.axiom.co/v1/traces", - headers: { - Authorization: `Bearer ${process.env.AXIOM_TOKEN}`, - "X-Axiom-Dataset": process.env.AXIOM_DATASET, - }, - }); + // Initialize OTLP trace exporter with the endpoint URL and headers + // Initialize OTLP trace exporter with the endpoint URL and headers + const traceExporter = new OTLPTraceExporter({ + url: "https://api.axiom.co/v1/traces", + headers: { + Authorization: `Bearer ${process.env.AXIOM_TOKEN}`, + "X-Axiom-Dataset": process.env.AXIOM_DATASET, + }, + }); - const resourceAttributes = { - [SemanticResourceAttributes.SERVICE_NAME]: "node traces", - }; + const resourceAttributes = { + [SemanticResourceAttributes.SERVICE_NAME]: "node traces", + }; - const resource = resourceFromAttributes(resourceAttributes); + const resource = resourceFromAttributes(resourceAttributes); - // Configuring the OpenTelemetry Node SDK - const sdk = new NodeSDK({ - // Adding a BatchSpanProcessor to batch and send traces - spanProcessor: new BatchSpanProcessor(traceExporter), + // Configuring the OpenTelemetry Node SDK + const sdk = new NodeSDK({ + // Adding a BatchSpanProcessor to batch and send traces + spanProcessor: new BatchSpanProcessor(traceExporter), - // Registering the resource to the SDK - resource: resource, + // Registering the resource to the SDK + resource: resource, - // Adding auto-instrumentations to automatically collect trace data - instrumentations: [getNodeAutoInstrumentations()], - }); + // Adding auto-instrumentations to automatically collect trace data + instrumentations: [getNodeAutoInstrumentations()], + }); - console.log("✅成功加载 Axiom 遥测"); - // Starting the OpenTelemetry SDK to begin collecting telemetry data - sdk.start(); + console.log("✅成功加载 Axiom 遥测"); + // Starting the OpenTelemetry SDK to begin collecting telemetry data + sdk.start(); } else { - console.log("❌未设置 Axiom 遥测"); + console.log("❌未设置 Axiom 遥测"); } diff --git a/utils/jwt.js b/utils/jwt.js index 4349968..ed2da2e 100644 --- a/utils/jwt.js +++ b/utils/jwt.js @@ -1,11 +1,11 @@ import jwt from 'jsonwebtoken'; import { - generateAccessToken, - verifyAccessToken, - generateTokenPair, - refreshAccessToken, - revokeAllTokens, - revokeRefreshToken, + generateAccessToken, + generateTokenPair, + refreshAccessToken, + revokeAllTokens, + revokeRefreshToken, + verifyAccessToken, } from './tokenManager.js'; // JWT 配置(支持 HS256 与 RS256) @@ -20,14 +20,14 @@ const JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY?.replace(/\\n/g, '\n'); const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY?.replace(/\\n/g, '\n'); function getSignVerifyKeys() { - if (JWT_ALG === 'RS256') { - if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) { - throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY'); + if (JWT_ALG === 'RS256') { + if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) { + throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY'); + } + return {signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY}; } - return { signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY }; - } - // 默认 HS256 - return { signKey: JWT_SECRET, verifyKey: JWT_SECRET }; + // 默认 HS256 + return {signKey: JWT_SECRET, verifyKey: JWT_SECRET}; } /** @@ -35,11 +35,11 @@ function getSignVerifyKeys() { * @deprecated 建议使用 generateAccessToken */ export function signToken(payload) { - const { signKey } = getSignVerifyKeys(); - return jwt.sign(payload, signKey, { - expiresIn: JWT_EXPIRES_IN, - algorithm: JWT_ALG, - }); + const {signKey} = getSignVerifyKeys(); + return jwt.sign(payload, signKey, { + expiresIn: JWT_EXPIRES_IN, + algorithm: JWT_ALG, + }); } /** @@ -47,8 +47,8 @@ export function signToken(payload) { * @deprecated 建议使用 verifyAccessToken */ export function verifyToken(token) { - const { verifyKey } = getSignVerifyKeys(); - return jwt.verify(token, verifyKey, { algorithms: [JWT_ALG] }); + const {verifyKey} = getSignVerifyKeys(); + return jwt.verify(token, verifyKey, {algorithms: [JWT_ALG]}); } /** @@ -56,21 +56,21 @@ export function verifyToken(token) { * @deprecated 建议使用 generateTokenPair 获取完整的令牌对 */ export function generateAccountToken(account) { - return signToken({ - accountId: account.id, - provider: account.provider, - email: account.email, - name: account.name, - avatarUrl: account.avatarUrl, - }); + return signToken({ + accountId: account.id, + provider: account.provider, + email: account.email, + name: account.name, + avatarUrl: account.avatarUrl, + }); } // 重新导出新的token管理功能 export { - generateAccessToken, - verifyAccessToken, - generateTokenPair, - refreshAccessToken, - revokeAllTokens, - revokeRefreshToken, + generateAccessToken, + verifyAccessToken, + generateTokenPair, + refreshAccessToken, + revokeAllTokens, + revokeRefreshToken, }; \ No newline at end of file diff --git a/utils/kvStore.js b/utils/kvStore.js index 8179c4b..f521f36 100644 --- a/utils/kvStore.js +++ b/utils/kvStore.js @@ -1,223 +1,224 @@ -import { PrismaClient } from "@prisma/client"; -import { keysTotal } from "./metrics.js"; +import {PrismaClient} from "@prisma/client"; +import {keysTotal} from "./metrics.js"; const prisma = new PrismaClient(); + class KVStore { - /** - * 通过设备ID和键名获取值 - * @param {number} deviceId - 设备ID - * @param {string} key - 键名 - * @returns {object|null} 键对应的值或null - */ - async get(deviceId, key) { - const item = await prisma.kVStore.findUnique({ - where: { - deviceId_key: { - deviceId: deviceId, - key: key, - }, - }, - }); - return item ? item.value : null; - } - - /** - * 获取键的完整信息(包括元数据) - * @param {number} deviceId - 设备ID - * @param {string} key - 键名 - * @returns {object|null} 键的完整信息或null - */ - async getMetadata(deviceId, key) { - const item = await prisma.kVStore.findUnique({ - where: { - deviceId_key: { - deviceId: deviceId, - key: key, - }, - }, - select: { - key: true, - deviceId: true, - creatorIp: true, - createdAt: true, - updatedAt: true, - }, - }); - - if (!item) return null; - - // 转换为更友好的格式 - return { - deviceId: item.deviceId, - key: item.key, - metadata: { - creatorIp: item.creatorIp, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }, - }; - } - - /** - * 在指定设备下创建或更新键值 - * @param {number} deviceId - 设备ID - * @param {string} key - 键名 - * @param {object} value - 键值 - * @param {string} creatorIp - 创建者IP,可选 - * @returns {object} 创建或更新的记录 - */ - async upsert(deviceId, key, value, creatorIp = "") { - const item = await prisma.kVStore.upsert({ - where: { - deviceId_key: { - deviceId: deviceId, - key: key, - }, - }, - update: { - value, - ...(creatorIp && { creatorIp }), - }, - create: { - deviceId: deviceId, - key: key, - value, - creatorIp, - }, - }); - - // 更新键总数指标 - const totalKeys = await prisma.kVStore.count(); - keysTotal.set(totalKeys); - - // 返回带有设备ID和原始键的结果 - return { - deviceId, - key, - value: item.value, - creatorIp: item.creatorIp, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }; - } - - /** - * 通过设备ID和键名删除 - * @param {number} deviceId - 设备ID - * @param {string} key - 键名 - * @returns {object|null} 删除的记录或null - */ - async delete(deviceId, key) { - try { - const item = await prisma.kVStore.delete({ - where: { - deviceId_key: { - deviceId: deviceId, - key: key, - }, - }, - }); - - // 更新键总数指标 - const totalKeys = await prisma.kVStore.count(); - keysTotal.set(totalKeys); - - return item ? { ...item, deviceId, key } : null; - } catch (error) { - // 忽略记录不存在的错误 - if (error.code === "P2025") { - return null; - } - throw error; + /** + * 通过设备ID和键名获取值 + * @param {number} deviceId - 设备ID + * @param {string} key - 键名 + * @returns {object|null} 键对应的值或null + */ + async get(deviceId, key) { + const item = await prisma.kVStore.findUnique({ + where: { + deviceId_key: { + deviceId: deviceId, + key: key, + }, + }, + }); + return item ? item.value : null; } - } - /** - * 列出指定设备下的所有键名及其元数据 - * @param {number} deviceId - 设备ID - * @param {object} options - 选项参数 - * @returns {Array} 键名和元数据数组 - */ - async list(deviceId, options = {}) { - const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options; + /** + * 获取键的完整信息(包括元数据) + * @param {number} deviceId - 设备ID + * @param {string} key - 键名 + * @returns {object|null} 键的完整信息或null + */ + async getMetadata(deviceId, key) { + const item = await prisma.kVStore.findUnique({ + where: { + deviceId_key: { + deviceId: deviceId, + key: key, + }, + }, + select: { + key: true, + deviceId: true, + creatorIp: true, + createdAt: true, + updatedAt: true, + }, + }); - // 构建排序条件 - const orderBy = {}; - orderBy[sortBy] = sortDir.toLowerCase(); + if (!item) return null; - // 查询设备的所有键 - const items = await prisma.kVStore.findMany({ - where: { - deviceId: deviceId, - }, - select: { - deviceId: true, - key: true, - creatorIp: true, - createdAt: true, - updatedAt: true, - value: false, - }, - orderBy, - take: limit, - skip: skip, - }); + // 转换为更友好的格式 + return { + deviceId: item.deviceId, + key: item.key, + metadata: { + creatorIp: item.creatorIp, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }, + }; + } - // 处理结果 - return items.map((item) => ({ - deviceId: item.deviceId, - key: item.key, - metadata: { - creatorIp: item.creatorIp, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }, - })); - } + /** + * 在指定设备下创建或更新键值 + * @param {number} deviceId - 设备ID + * @param {string} key - 键名 + * @param {object} value - 键值 + * @param {string} creatorIp - 创建者IP,可选 + * @returns {object} 创建或更新的记录 + */ + async upsert(deviceId, key, value, creatorIp = "") { + const item = await prisma.kVStore.upsert({ + where: { + deviceId_key: { + deviceId: deviceId, + key: key, + }, + }, + update: { + value, + ...(creatorIp && {creatorIp}), + }, + create: { + deviceId: deviceId, + key: key, + value, + creatorIp, + }, + }); - /** - * 获取指定设备下的键名列表(不包括内容) - * @param {number} deviceId - 设备ID - * @param {object} options - 查询选项 - * @returns {Array} 键名列表 - */ - async listKeysOnly(deviceId, options = {}) { - const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options; + // 更新键总数指标 + const totalKeys = await prisma.kVStore.count(); + keysTotal.set(totalKeys); - // 构建排序条件 - const orderBy = {}; - orderBy[sortBy] = sortDir.toLowerCase(); + // 返回带有设备ID和原始键的结果 + return { + deviceId, + key, + value: item.value, + creatorIp: item.creatorIp, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }; + } - // 查询设备的所有键,只选择键名 - const items = await prisma.kVStore.findMany({ - where: { - deviceId: deviceId, - }, - select: { - key: true, - }, - orderBy, - take: limit, - skip: skip, - }); + /** + * 通过设备ID和键名删除 + * @param {number} deviceId - 设备ID + * @param {string} key - 键名 + * @returns {object|null} 删除的记录或null + */ + async delete(deviceId, key) { + try { + const item = await prisma.kVStore.delete({ + where: { + deviceId_key: { + deviceId: deviceId, + key: key, + }, + }, + }); - // 只返回键名数组 - return items.map((item) => item.key); - } + // 更新键总数指标 + const totalKeys = await prisma.kVStore.count(); + keysTotal.set(totalKeys); - /** - * 统计指定设备下的键值对数量 - * @param {number} deviceId - 设备ID - * @returns {number} 键值对数量 - */ - async count(deviceId) { - const count = await prisma.kVStore.count({ - where: { - deviceId: deviceId, - }, - }); - return count; - } + return item ? {...item, deviceId, key} : null; + } catch (error) { + // 忽略记录不存在的错误 + if (error.code === "P2025") { + return null; + } + throw error; + } + } + + /** + * 列出指定设备下的所有键名及其元数据 + * @param {number} deviceId - 设备ID + * @param {object} options - 选项参数 + * @returns {Array} 键名和元数据数组 + */ + async list(deviceId, options = {}) { + const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options; + + // 构建排序条件 + const orderBy = {}; + orderBy[sortBy] = sortDir.toLowerCase(); + + // 查询设备的所有键 + const items = await prisma.kVStore.findMany({ + where: { + deviceId: deviceId, + }, + select: { + deviceId: true, + key: true, + creatorIp: true, + createdAt: true, + updatedAt: true, + value: false, + }, + orderBy, + take: limit, + skip: skip, + }); + + // 处理结果 + return items.map((item) => ({ + deviceId: item.deviceId, + key: item.key, + metadata: { + creatorIp: item.creatorIp, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }, + })); + } + + /** + * 获取指定设备下的键名列表(不包括内容) + * @param {number} deviceId - 设备ID + * @param {object} options - 查询选项 + * @returns {Array} 键名列表 + */ + async listKeysOnly(deviceId, options = {}) { + const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options; + + // 构建排序条件 + const orderBy = {}; + orderBy[sortBy] = sortDir.toLowerCase(); + + // 查询设备的所有键,只选择键名 + const items = await prisma.kVStore.findMany({ + where: { + deviceId: deviceId, + }, + select: { + key: true, + }, + orderBy, + take: limit, + skip: skip, + }); + + // 只返回键名数组 + return items.map((item) => item.key); + } + + /** + * 统计指定设备下的键值对数量 + * @param {number} deviceId - 设备ID + * @returns {number} 键值对数量 + */ + async count(deviceId) { + const count = await prisma.kVStore.count({ + where: { + deviceId: deviceId, + }, + }); + return count; + } } export default new KVStore(); diff --git a/utils/metrics.js b/utils/metrics.js index 8354612..104a137 100644 --- a/utils/metrics.js +++ b/utils/metrics.js @@ -5,45 +5,45 @@ const register = new client.Registry(); // 当前在线设备数(连接了 SocketIO 的设备) export const onlineDevicesGauge = new client.Gauge({ - name: 'classworks_online_devices_total', - help: 'Total number of online devices (connected via SocketIO)', - registers: [register], + name: 'classworks_online_devices_total', + help: 'Total number of online devices (connected via SocketIO)', + registers: [register], }); // 已注册设备总数 export const registeredDevicesTotal = new client.Gauge({ - name: 'classworks_registered_devices_total', - help: 'Total number of registered devices', - registers: [register], + name: 'classworks_registered_devices_total', + help: 'Total number of registered devices', + registers: [register], }); // 已创建键总数(不区分设备) export const keysTotal = new client.Gauge({ - name: 'classworks_keys_total', - help: 'Total number of keys across all devices', - registers: [register], + name: 'classworks_keys_total', + help: 'Total number of keys across all devices', + registers: [register], }); // 初始化指标数据 export async function initializeMetrics() { - try { - const { PrismaClient } = await import('@prisma/client'); - const prisma = new PrismaClient(); + try { + const {PrismaClient} = await import('@prisma/client'); + const prisma = new PrismaClient(); - // 获取已注册设备总数 - const deviceCount = await prisma.device.count(); - registeredDevicesTotal.set(deviceCount); + // 获取已注册设备总数 + const deviceCount = await prisma.device.count(); + registeredDevicesTotal.set(deviceCount); - // 获取已创建键总数 - const keyCount = await prisma.kVStore.count(); - keysTotal.set(keyCount); + // 获取已创建键总数 + const keyCount = await prisma.kVStore.count(); + keysTotal.set(keyCount); - await prisma.$disconnect(); - console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount); - } catch (error) { - console.error('Failed to initialize metrics:', error); - } + await prisma.$disconnect(); + console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount); + } catch (error) { + console.error('Failed to initialize metrics:', error); + } } // 导出注册表用于 /metrics 端点 -export { register }; \ No newline at end of file +export {register}; \ No newline at end of file diff --git a/utils/siteinfo.js b/utils/siteinfo.js index b0dab1e..c5bd628 100644 --- a/utils/siteinfo.js +++ b/utils/siteinfo.js @@ -1,4 +1,4 @@ -import { PrismaClient } from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; import kvStore from "./kvStore.js"; const prisma = new PrismaClient(); @@ -12,8 +12,8 @@ let systemDeviceId = null; // 封装默认 readme 对象 const defaultReadme = { - title: "Classworks 服务端", - readme: "暂无 Readme 内容", + title: "Classworks 服务端", + readme: "暂无 Readme 内容", }; /** @@ -21,25 +21,25 @@ const defaultReadme = { * @returns {Promise} 系统设备ID */ async function getSystemDeviceId() { - if (systemDeviceId) return systemDeviceId; + if (systemDeviceId) return systemDeviceId; - let device = await prisma.device.findUnique({ - where: { uuid: SYSTEM_DEVICE_UUID }, - select: { id: true }, - }); - - if (!device) { - device = await prisma.device.create({ - data: { - uuid: SYSTEM_DEVICE_UUID, - name: "系统设备", - }, - select: { id: true }, + let device = await prisma.device.findUnique({ + where: {uuid: SYSTEM_DEVICE_UUID}, + select: {id: true}, }); - } - systemDeviceId = device.id; - return systemDeviceId; + if (!device) { + device = await prisma.device.create({ + data: { + uuid: SYSTEM_DEVICE_UUID, + name: "系统设备", + }, + select: {id: true}, + }); + } + + systemDeviceId = device.id; + return systemDeviceId; } /** @@ -47,26 +47,26 @@ async function getSystemDeviceId() { * 在应用启动时调用此函数 */ export const initReadme = async () => { - try { - const deviceId = await getSystemDeviceId(); - const storedValue = await kvStore.get(deviceId, "info"); + try { + const deviceId = await getSystemDeviceId(); + const storedValue = await kvStore.get(deviceId, "info"); - // 合并默认值与存储值,确保结构完整 - readmeValue = { - ...defaultReadme, - ...(storedValue || {}), - }; + // 合并默认值与存储值,确保结构完整 + readmeValue = { + ...defaultReadme, + ...(storedValue || {}), + }; - console.log("✅ 站点信息初始化成功"); - } catch (error) { - console.error("❌ 站点信息初始化失败:", { - message: error?.message, - stack: error?.stack, - }); + console.log("✅ 站点信息初始化成功"); + } catch (error) { + console.error("❌ 站点信息初始化失败:", { + message: error?.message, + stack: error?.stack, + }); - // 确保在异常情况下也有默认值 - readmeValue = { ...defaultReadme }; - } + // 确保在异常情况下也有默认值 + readmeValue = {...defaultReadme}; + } }; /** @@ -74,7 +74,7 @@ export const initReadme = async () => { * @returns {Object} readme 值对象 */ export const getReadmeValue = () => { - return readmeValue || { ...defaultReadme }; + return readmeValue || {...defaultReadme}; }; /** @@ -83,19 +83,19 @@ export const getReadmeValue = () => { * @returns {Promise} */ export const updateReadmeValue = async (newValue) => { - try { - const deviceId = await getSystemDeviceId(); - await kvStore.upsert(deviceId, "info", newValue); - readmeValue = { - ...defaultReadme, - ...newValue, - }; - console.log("✅ 站点信息更新成功"); - } catch (error) { - console.error("❌ 站点信息更新失败:", { - message: error?.message, - stack: error?.stack, - }); - throw error; - } + try { + const deviceId = await getSystemDeviceId(); + await kvStore.upsert(deviceId, "info", newValue); + readmeValue = { + ...defaultReadme, + ...newValue, + }; + console.log("✅ 站点信息更新成功"); + } catch (error) { + console.error("❌ 站点信息更新失败:", { + message: error?.message, + stack: error?.stack, + }); + throw error; + } }; \ No newline at end of file diff --git a/utils/socket.js b/utils/socket.js index 3e7f503..ee6aa41 100644 --- a/utils/socket.js +++ b/utils/socket.js @@ -9,9 +9,9 @@ * - 提供广播 KV 键变更的工具方法 */ -import { Server } from "socket.io"; -import { PrismaClient } from "@prisma/client"; -import { onlineDevicesGauge } from "./metrics.js"; +import {Server} from "socket.io"; +import {PrismaClient} from "@prisma/client"; +import {onlineDevicesGauge} from "./metrics.js"; // Socket.IO 单例实例 let io = null; @@ -27,105 +27,107 @@ const prisma = new PrismaClient(); * @param {import('http').Server} server HTTP Server 实例 */ export function initSocket(server) { - if (io) return io; + if (io) return io; - const allowOrigin = process.env.FRONTEND_URL || "*"; + const allowOrigin = process.env.FRONTEND_URL || "*"; - io = new Server(server, { - cors: { - origin: allowOrigin, - methods: ["GET", "POST"], - credentials: true, - }, - }); - - io.on("connection", (socket) => { - // 初始化每个连接所加入的设备房间集合 - socket.data.deviceUuids = new Set(); - - // 仅允许通过 query.token/apptoken 加入 - const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken; - if (qToken && typeof qToken === "string") { - joinByToken(socket, qToken).catch(() => {}); - } - - // 客户端使用 KV token 加入房间 - socket.on("join-token", (payload) => { - const token = payload?.token || payload?.apptoken; - if (typeof token === "string" && token.length > 0) { - joinByToken(socket, token).catch(() => {}); - } + io = new Server(server, { + cors: { + origin: allowOrigin, + methods: ["GET", "POST"], + credentials: true, + }, }); - // 客户端使用 token 离开房间 - socket.on("leave-token", async (payload) => { - try { - const token = payload?.token || payload?.apptoken; - if (typeof token !== "string" || token.length === 0) return; - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { device: { select: { uuid: true } } }, - }); - const uuid = appInstall?.device?.uuid; - if (uuid) { - leaveDeviceRoom(socket, uuid); - // 移除 token 连接跟踪 - removeTokenConnection(token, socket.id); - if (socket.data.tokens) socket.data.tokens.delete(token); + io.on("connection", (socket) => { + // 初始化每个连接所加入的设备房间集合 + socket.data.deviceUuids = new Set(); + + // 仅允许通过 query.token/apptoken 加入 + const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken; + if (qToken && typeof qToken === "string") { + joinByToken(socket, qToken).catch(() => { + }); } - } catch { - // ignore - } - }); - // 离开所有已加入的设备房间 - socket.on("leave-all", () => { - const uuids = Array.from(socket.data.deviceUuids || []); - uuids.forEach((u) => leaveDeviceRoom(socket, u)); - }); - - // 聊天室:发送文本消息到加入的设备频道 - socket.on("chat:send", (data) => { - try { - const text = typeof data === "string" ? data : data?.text; - if (typeof text !== "string") return; - const trimmed = text.trim(); - if (!trimmed) return; - - // 限制消息最大长度,避免滥用 - const MAX_LEN = 2000; - const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed; - - const uuids = Array.from(socket.data.deviceUuids || []); - if (uuids.length === 0) return; - - const at = new Date().toISOString(); - const payload = { text: safeText, at, senderId: socket.id }; - - uuids.forEach((uuid) => { - io.to(uuid).emit("chat:message", { uuid, ...payload }); + // 客户端使用 KV token 加入房间 + socket.on("join-token", (payload) => { + const token = payload?.token || payload?.apptoken; + if (typeof token === "string" && token.length > 0) { + joinByToken(socket, token).catch(() => { + }); + } + }); + + // 客户端使用 token 离开房间 + socket.on("leave-token", async (payload) => { + try { + const token = payload?.token || payload?.apptoken; + if (typeof token !== "string" || token.length === 0) return; + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + include: {device: {select: {uuid: true}}}, + }); + const uuid = appInstall?.device?.uuid; + if (uuid) { + leaveDeviceRoom(socket, uuid); + // 移除 token 连接跟踪 + removeTokenConnection(token, socket.id); + if (socket.data.tokens) socket.data.tokens.delete(token); + } + } catch { + // ignore + } + }); + + // 离开所有已加入的设备房间 + socket.on("leave-all", () => { + const uuids = Array.from(socket.data.deviceUuids || []); + uuids.forEach((u) => leaveDeviceRoom(socket, u)); + }); + + // 聊天室:发送文本消息到加入的设备频道 + socket.on("chat:send", (data) => { + try { + const text = typeof data === "string" ? data : data?.text; + if (typeof text !== "string") return; + const trimmed = text.trim(); + if (!trimmed) return; + + // 限制消息最大长度,避免滥用 + const MAX_LEN = 2000; + const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed; + + const uuids = Array.from(socket.data.deviceUuids || []); + if (uuids.length === 0) return; + + const at = new Date().toISOString(); + const payload = {text: safeText, at, senderId: socket.id}; + + uuids.forEach((uuid) => { + io.to(uuid).emit("chat:message", {uuid, ...payload}); + }); + } catch (err) { + console.error("chat:send error:", err); + } + }); + + socket.on("disconnect", () => { + const uuids = Array.from(socket.data.deviceUuids || []); + uuids.forEach((u) => removeOnline(u, socket.id)); + + // 清理 token 连接跟踪 + const tokens = Array.from(socket.data.tokens || []); + tokens.forEach((token) => removeTokenConnection(token, socket.id)); }); - } catch (err) { - console.error("chat:send error:", err); - } }); - socket.on("disconnect", () => { - const uuids = Array.from(socket.data.deviceUuids || []); - uuids.forEach((u) => removeOnline(u, socket.id)); - - // 清理 token 连接跟踪 - const tokens = Array.from(socket.data.tokens || []); - tokens.forEach((token) => removeTokenConnection(token, socket.id)); - }); - }); - - return io; + return io; } /** 返回 Socket.IO 实例 */ export function getIO() { - return io; + return io; } /** @@ -134,15 +136,15 @@ export function getIO() { * @param {string} uuid */ function joinDeviceRoom(socket, uuid) { - socket.join(uuid); - if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set(); - socket.data.deviceUuids.add(uuid); - // 记录在线 - const set = onlineMap.get(uuid) || new Set(); - set.add(socket.id); - onlineMap.set(uuid, set); - // 可选:通知加入 - io.to(uuid).emit("device-joined", { uuid, connections: set.size }); + socket.join(uuid); + if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set(); + socket.data.deviceUuids.add(uuid); + // 记录在线 + const set = onlineMap.get(uuid) || new Set(); + set.add(socket.id); + onlineMap.set(uuid, set); + // 可选:通知加入 + io.to(uuid).emit("device-joined", {uuid, connections: set.size}); } /** @@ -151,16 +153,16 @@ function joinDeviceRoom(socket, uuid) { * @param {string} token */ function trackTokenConnection(socket, token) { - if (!socket.data.tokens) socket.data.tokens = new Set(); - socket.data.tokens.add(token); + if (!socket.data.tokens) socket.data.tokens = new Set(); + socket.data.tokens.add(token); - // 记录 token 连接 - const set = onlineTokens.get(token) || new Set(); - set.add(socket.id); - onlineTokens.set(token, set); + // 记录 token 连接 + const set = onlineTokens.get(token) || new Set(); + set.add(socket.id); + onlineTokens.set(token, set); - // 更新在线设备数指标(基于不同的 token 数量) - onlineDevicesGauge.set(onlineTokens.size); + // 更新在线设备数指标(基于不同的 token 数量) + onlineDevicesGauge.set(onlineTokens.size); } /** @@ -169,20 +171,20 @@ function trackTokenConnection(socket, token) { * @param {string} uuid */ function leaveDeviceRoom(socket, uuid) { - socket.leave(uuid); - if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid); - removeOnline(uuid, socket.id); + socket.leave(uuid); + if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid); + removeOnline(uuid, socket.id); } function removeOnline(uuid, socketId) { - const set = onlineMap.get(uuid); - if (!set) return; - set.delete(socketId); - if (set.size === 0) { - onlineMap.delete(uuid); - } else { - onlineMap.set(uuid, set); - } + const set = onlineMap.get(uuid); + if (!set) return; + set.delete(socketId); + if (set.size === 0) { + onlineMap.delete(uuid); + } else { + onlineMap.set(uuid, set); + } } /** @@ -191,16 +193,16 @@ function removeOnline(uuid, socketId) { * @param {string} socketId */ function removeTokenConnection(token, socketId) { - const set = onlineTokens.get(token); - if (!set) return; - set.delete(socketId); - if (set.size === 0) { - onlineTokens.delete(token); - } else { - onlineTokens.set(token, set); - } - // 更新在线设备数指标(基于不同的 token 数量) - onlineDevicesGauge.set(onlineTokens.size); + const set = onlineTokens.get(token); + if (!set) return; + set.delete(socketId); + if (set.size === 0) { + onlineTokens.delete(token); + } else { + onlineTokens.set(token, set); + } + // 更新在线设备数指标(基于不同的 token 数量) + onlineDevicesGauge.set(onlineTokens.size); } /** @@ -209,8 +211,8 @@ function removeTokenConnection(token, socketId) { * @param {object} payload { key, action: 'upsert'|'delete'|'batch', updatedAt?, created? } */ export function broadcastKeyChanged(uuid, payload) { - if (!io || !uuid) return; - io.to(uuid).emit("kv-key-changed", { uuid, ...payload }); + if (!io || !uuid) return; + io.to(uuid).emit("kv-key-changed", {uuid, ...payload}); } /** @@ -218,19 +220,19 @@ export function broadcastKeyChanged(uuid, payload) { * @returns {Array<{token:string, connections:number}>} */ export function getOnlineDevices() { - const list = []; - for (const [token, set] of onlineTokens.entries()) { - list.push({ token, connections: set.size }); - } - // 默认按连接数降序 - return list.sort((a, b) => b.connections - a.connections); + const list = []; + for (const [token, set] of onlineTokens.entries()) { + list.push({token, connections: set.size}); + } + // 默认按连接数降序 + return list.sort((a, b) => b.connections - a.connections); } export default { - initSocket, - getIO, - broadcastKeyChanged, - getOnlineDevices, + initSocket, + getIO, + broadcastKeyChanged, + getOnlineDevices, }; /** @@ -239,18 +241,18 @@ export default { * @param {string} token */ async function joinByToken(socket, token) { - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { device: { select: { uuid: true } } }, - }); - const uuid = appInstall?.device?.uuid; - if (uuid) { - joinDeviceRoom(socket, uuid); - // 跟踪 token 连接用于指标统计 - trackTokenConnection(socket, token); - // 可选:回执 - socket.emit("joined", { by: "token", uuid, token }); - } else { - socket.emit("join-error", { by: "token", reason: "invalid_token" }); - } + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + include: {device: {select: {uuid: true}}}, + }); + const uuid = appInstall?.device?.uuid; + if (uuid) { + joinDeviceRoom(socket, uuid); + // 跟踪 token 连接用于指标统计 + trackTokenConnection(socket, token); + // 可选:回执 + socket.emit("joined", {by: "token", uuid, token}); + } else { + socket.emit("join-error", {by: "token", reason: "invalid_token"}); + } } diff --git a/utils/tokenManager.js b/utils/tokenManager.js index 5d25495..fc1716f 100644 --- a/utils/tokenManager.js +++ b/utils/tokenManager.js @@ -1,6 +1,6 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; -import { PrismaClient } from '@prisma/client'; +import {PrismaClient} from '@prisma/client'; const prisma = new PrismaClient(); @@ -25,266 +25,271 @@ const REFRESH_TOKEN_PUBLIC_KEY = process.env.REFRESH_TOKEN_PUBLIC_KEY?.replace(/ * 获取签名和验证密钥 */ function getKeys(tokenType = 'access') { - if (JWT_ALG === 'RS256') { - const privateKey = tokenType === 'access' ? ACCESS_TOKEN_PRIVATE_KEY : REFRESH_TOKEN_PRIVATE_KEY; - const publicKey = tokenType === 'access' ? ACCESS_TOKEN_PUBLIC_KEY : REFRESH_TOKEN_PUBLIC_KEY; + if (JWT_ALG === 'RS256') { + const privateKey = tokenType === 'access' ? ACCESS_TOKEN_PRIVATE_KEY : REFRESH_TOKEN_PRIVATE_KEY; + const publicKey = tokenType === 'access' ? ACCESS_TOKEN_PUBLIC_KEY : REFRESH_TOKEN_PUBLIC_KEY; - if (!privateKey || !publicKey) { - throw new Error(`RS256 需要同时提供 ${tokenType.toUpperCase()}_TOKEN_PRIVATE_KEY 与 ${tokenType.toUpperCase()}_TOKEN_PUBLIC_KEY`); + if (!privateKey || !publicKey) { + throw new Error(`RS256 需要同时提供 ${tokenType.toUpperCase()}_TOKEN_PRIVATE_KEY 与 ${tokenType.toUpperCase()}_TOKEN_PUBLIC_KEY`); + } + return {signKey: privateKey, verifyKey: publicKey}; } - return { signKey: privateKey, verifyKey: publicKey }; - } - // 默认 HS256 - const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET; - return { signKey: secret, verifyKey: secret }; + // 默认 HS256 + const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET; + return {signKey: secret, verifyKey: secret}; } /** * 生成访问令牌 */ export function generateAccessToken(account) { - const { signKey } = getKeys('access'); + const {signKey} = getKeys('access'); - const payload = { - type: 'access', - accountId: account.id, - provider: account.provider, - email: account.email, - name: account.name, - avatarUrl: account.avatarUrl, - tokenVersion: account.tokenVersion || 1, - }; + const payload = { + type: 'access', + accountId: account.id, + provider: account.provider, + email: account.email, + name: account.name, + avatarUrl: account.avatarUrl, + tokenVersion: account.tokenVersion || 1, + }; - return jwt.sign(payload, signKey, { - expiresIn: ACCESS_TOKEN_EXPIRES_IN, - algorithm: JWT_ALG, - issuer: 'ClassworksKV', - audience: 'classworks-client', - }); + return jwt.sign(payload, signKey, { + expiresIn: ACCESS_TOKEN_EXPIRES_IN, + algorithm: JWT_ALG, + issuer: 'ClassworksKV', + audience: 'classworks-client', + }); } /** * 生成刷新令牌 */ export function generateRefreshToken(account) { - const { signKey } = getKeys('refresh'); + const {signKey} = getKeys('refresh'); - const payload = { - type: 'refresh', - accountId: account.id, - tokenVersion: account.tokenVersion || 1, - // 添加随机字符串增加安全性 - jti: crypto.randomBytes(16).toString('hex'), - }; + const payload = { + type: 'refresh', + accountId: account.id, + tokenVersion: account.tokenVersion || 1, + // 添加随机字符串增加安全性 + jti: crypto.randomBytes(16).toString('hex'), + }; - return jwt.sign(payload, signKey, { - expiresIn: REFRESH_TOKEN_EXPIRES_IN, - algorithm: JWT_ALG, - issuer: 'ClassworksKV', - audience: 'classworks-client', - }); + return jwt.sign(payload, signKey, { + expiresIn: REFRESH_TOKEN_EXPIRES_IN, + algorithm: JWT_ALG, + issuer: 'ClassworksKV', + audience: 'classworks-client', + }); } /** * 验证访问令牌 */ export function verifyAccessToken(token) { - const { verifyKey } = getKeys('access'); + const {verifyKey} = getKeys('access'); - try { - const decoded = jwt.verify(token, verifyKey, { - algorithms: [JWT_ALG], - issuer: 'ClassworksKV', - audience: 'classworks-client', - }); + try { + const decoded = jwt.verify(token, verifyKey, { + algorithms: [JWT_ALG], + issuer: 'ClassworksKV', + audience: 'classworks-client', + }); - if (decoded.type !== 'access') { - throw new Error('Invalid token type'); + if (decoded.type !== 'access') { + throw new Error('Invalid token type'); + } + + return decoded; + } catch (error) { + throw error; } - - return decoded; - } catch (error) { - throw error; - } } /** * 验证刷新令牌 */ export function verifyRefreshToken(token) { - const { verifyKey } = getKeys('refresh'); + const {verifyKey} = getKeys('refresh'); - try { - const decoded = jwt.verify(token, verifyKey, { - algorithms: [JWT_ALG], - issuer: 'ClassworksKV', - audience: 'classworks-client', - }); + try { + const decoded = jwt.verify(token, verifyKey, { + algorithms: [JWT_ALG], + issuer: 'ClassworksKV', + audience: 'classworks-client', + }); - if (decoded.type !== 'refresh') { - throw new Error('Invalid token type'); + if (decoded.type !== 'refresh') { + throw new Error('Invalid token type'); + } + + return decoded; + } catch (error) { + throw error; } - - return decoded; - } catch (error) { - throw error; - } } /** * 生成令牌对(访问令牌 + 刷新令牌) */ export async function generateTokenPair(account) { - const accessToken = generateAccessToken(account); - const refreshToken = generateRefreshToken(account); + const accessToken = generateAccessToken(account); + const refreshToken = generateRefreshToken(account); - // 计算刷新令牌过期时间 - const refreshTokenExpiry = new Date(); - const expiresInMs = parseExpirationToMs(REFRESH_TOKEN_EXPIRES_IN); - refreshTokenExpiry.setTime(refreshTokenExpiry.getTime() + expiresInMs); + // 计算刷新令牌过期时间 + const refreshTokenExpiry = new Date(); + const expiresInMs = parseExpirationToMs(REFRESH_TOKEN_EXPIRES_IN); + refreshTokenExpiry.setTime(refreshTokenExpiry.getTime() + expiresInMs); - // 更新数据库中的刷新令牌 - await prisma.account.update({ - where: { id: account.id }, - data: { - refreshToken, - refreshTokenExpiry, - updatedAt: new Date(), - }, - }); + // 更新数据库中的刷新令牌 + await prisma.account.update({ + where: {id: account.id}, + data: { + refreshToken, + refreshTokenExpiry, + updatedAt: new Date(), + }, + }); - return { - accessToken, - refreshToken, - accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN, - refreshTokenExpiresIn: REFRESH_TOKEN_EXPIRES_IN, - }; + return { + accessToken, + refreshToken, + accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN, + refreshTokenExpiresIn: REFRESH_TOKEN_EXPIRES_IN, + }; } /** * 刷新访问令牌 */ export async function refreshAccessToken(refreshToken) { - try { - // 验证刷新令牌 - const decoded = verifyRefreshToken(refreshToken); + try { + // 验证刷新令牌 + const decoded = verifyRefreshToken(refreshToken); - // 从数据库获取账户信息 - const account = await prisma.account.findUnique({ - where: { id: decoded.accountId }, - }); + // 从数据库获取账户信息 + const account = await prisma.account.findUnique({ + where: {id: decoded.accountId}, + }); - if (!account) { - throw new Error('Account not found'); + if (!account) { + throw new Error('Account not found'); + } + + // 验证刷新令牌是否匹配 + if (account.refreshToken !== refreshToken) { + throw new Error('Invalid refresh token'); + } + + // 验证刷新令牌是否过期 + if (account.refreshTokenExpiry && account.refreshTokenExpiry < new Date()) { + throw new Error('Refresh token expired'); + } + + // 验证令牌版本 + if (account.tokenVersion !== decoded.tokenVersion) { + throw new Error('Token version mismatch'); + } + + // 生成新的访问令牌 + const newAccessToken = generateAccessToken(account); + + return { + accessToken: newAccessToken, + accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN, + account: { + id: account.id, + provider: account.provider, + email: account.email, + name: account.name, + avatarUrl: account.avatarUrl, + }, + }; + } catch (error) { + throw error; } - - // 验证刷新令牌是否匹配 - if (account.refreshToken !== refreshToken) { - throw new Error('Invalid refresh token'); - } - - // 验证刷新令牌是否过期 - if (account.refreshTokenExpiry && account.refreshTokenExpiry < new Date()) { - throw new Error('Refresh token expired'); - } - - // 验证令牌版本 - if (account.tokenVersion !== decoded.tokenVersion) { - throw new Error('Token version mismatch'); - } - - // 生成新的访问令牌 - const newAccessToken = generateAccessToken(account); - - return { - accessToken: newAccessToken, - accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN, - account: { - id: account.id, - provider: account.provider, - email: account.email, - name: account.name, - avatarUrl: account.avatarUrl, - }, - }; - } catch (error) { - throw error; - } } /** * 撤销所有令牌(登出所有设备) */ export async function revokeAllTokens(accountId) { - await prisma.account.update({ - where: { id: accountId }, - data: { - tokenVersion: { increment: 1 }, - refreshToken: null, - refreshTokenExpiry: null, - updatedAt: new Date(), - }, - }); + await prisma.account.update({ + where: {id: accountId}, + data: { + tokenVersion: {increment: 1}, + refreshToken: null, + refreshTokenExpiry: null, + updatedAt: new Date(), + }, + }); } /** * 撤销当前刷新令牌(登出当前设备) */ export async function revokeRefreshToken(accountId) { - await prisma.account.update({ - where: { id: accountId }, - data: { - refreshToken: null, - refreshTokenExpiry: null, - updatedAt: new Date(), - }, - }); + await prisma.account.update({ + where: {id: accountId}, + data: { + refreshToken: null, + refreshTokenExpiry: null, + updatedAt: new Date(), + }, + }); } /** * 解析过期时间字符串为毫秒 */ function parseExpirationToMs(expiresIn) { - if (typeof expiresIn === 'number') { - return expiresIn * 1000; - } + if (typeof expiresIn === 'number') { + return expiresIn * 1000; + } - const match = expiresIn.match(/^(\d+)([smhd])$/); - if (!match) { - throw new Error('Invalid expiration format'); - } + const match = expiresIn.match(/^(\d+)([smhd])$/); + if (!match) { + throw new Error('Invalid expiration format'); + } - const value = parseInt(match[1]); - const unit = match[2]; + const value = parseInt(match[1]); + const unit = match[2]; - switch (unit) { - case 's': return value * 1000; - case 'm': return value * 60 * 1000; - case 'h': return value * 60 * 60 * 1000; - case 'd': return value * 24 * 60 * 60 * 1000; - default: throw new Error('Invalid time unit'); - } + switch (unit) { + case 's': + return value * 1000; + case 'm': + return value * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + default: + throw new Error('Invalid time unit'); + } } /** * 验证账户并检查令牌版本 */ export async function validateAccountToken(decoded) { - const account = await prisma.account.findUnique({ - where: { id: decoded.accountId }, - }); + const account = await prisma.account.findUnique({ + where: {id: decoded.accountId}, + }); - if (!account) { - throw new Error('Account not found'); - } + if (!account) { + throw new Error('Account not found'); + } - // 验证令牌版本 - if (account.tokenVersion !== decoded.tokenVersion) { - throw new Error('Token version mismatch'); - } + // 验证令牌版本 + if (account.tokenVersion !== decoded.tokenVersion) { + throw new Error('Token version mismatch'); + } - return account; + return account; } // 向后兼容的导出 diff --git a/views/index.ejs b/views/index.ejs index 4b18bd7..379cf1b 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -2,14 +2,16 @@ - - - Classworks 服务端 + + + Classworks 服务端 -

Classworks 服务端

-

服务运行中

+

Classworks 服务端

+

服务运行中

- + \ No newline at end of file