1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-12-07 13:03:09 +00:00

规范代码格式

This commit is contained in:
Sunwuyuan 2025-11-16 16:15:05 +08:00
parent 4ec10acfcf
commit c545612c9c
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
34 changed files with 3982 additions and 3965 deletions

110
app.js
View File

@ -1,8 +1,8 @@
import "./utils/instrumentation.js"; import "./utils/instrumentation.js";
// import createError from "http-errors"; // import createError from "http-errors";
import express from "express"; import express from "express";
import { join, dirname } from "path"; import {dirname, join} from "path";
import { fileURLToPath } from "url"; import {fileURLToPath} from "url";
// import cookieParser from "cookie-parser"; // import cookieParser from "cookie-parser";
import logger from "morgan"; import logger from "morgan";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
@ -15,17 +15,17 @@ import deviceRouter from "./routes/device.js";
import deviceAuthRouter from "./routes/device-auth.js"; import deviceAuthRouter from "./routes/device-auth.js";
import accountsRouter from "./routes/accounts.js"; import accountsRouter from "./routes/accounts.js";
import autoAuthRouter from "./routes/auto-auth.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(); var app = express();
import cors from "cors";
app.options("/{*path}", cors()); app.options("/{*path}", cors());
app.use( app.use(
cors({ cors({
exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露 exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露
maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求 maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求
}) })
); );
app.disable("x-powered-by"); app.disable("x-powered-by");
@ -36,67 +36,67 @@ const __dirname = dirname(__filename);
// view engine setup // view engine setup
app.set("views", join(__dirname, "views")); app.set("views", join(__dirname, "views"));
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(logger("dev")); app.use(logger("dev"));
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({extended: false}));
// app.use(cookieParser()); // app.use(cookieParser());
app.use(express.static(join(__dirname, "public"))); app.use(express.static(join(__dirname, "public")));
// 添加请求超时处理中间件 // 添加请求超时处理中间件
app.use((req, res, next) => { app.use((req, res, next) => {
// 设置默认请求超时时间为30秒 // 设置默认请求超时时间为30秒
const timeout = 30000; const timeout = 30000;
// 设置超时回调 // 设置超时回调
const timeoutCallback = () => { const timeoutCallback = () => {
const timeoutError = errors.createError(408, "请求处理超时"); const timeoutError = errors.createError(408, "请求处理超时");
next(timeoutError); next(timeoutError);
}; };
// 设置超时 // 设置超时
req.setTimeout(timeout, timeoutCallback); req.setTimeout(timeout, timeoutCallback);
// 监听响应完成事件 // 监听响应完成事件
res.on("finish", () => { res.on("finish", () => {
// 如果响应已经完成,清除超时处理 // 如果响应已经完成,清除超时处理
req.setTimeout(0, timeoutCallback); req.setTimeout(0, timeoutCallback);
}); });
next(); next();
}); });
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.render("index.ejs"); res.render("index.ejs");
}); });
app.get("/check", (req, res) => { app.get("/check", (req, res) => {
res.json({ res.json({
status: "success", status: "success",
message: "Classworks KV is running", message: "Classworks KV is running",
time: new Date().getTime(), time: new Date().getTime(),
}); });
}); });
// Prometheus metrics endpoint with token auth // Prometheus metrics endpoint with token auth
app.get("/metrics", async (req, res) => { app.get("/metrics", async (req, res) => {
try { try {
// 检查 token 验证 // 检查 token 验证
const metricsToken = process.env.METRICS_TOKEN; const metricsToken = process.env.METRICS_TOKEN;
if (metricsToken) { if (metricsToken) {
const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token; const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
if (!providedToken || providedToken !== metricsToken) { if (!providedToken || providedToken !== metricsToken) {
return res.status(401).json({ return res.status(401).json({
error: "Unauthorized", error: "Unauthorized",
message: "Valid metrics token required" message: "Valid metrics token required"
}); });
} }
} }
res.set("Content-Type", register.contentType); res.set("Content-Type", register.contentType);
res.end(await register.metrics()); res.end(await register.metrics());
} catch (err) { } catch (err) {
res.status(500).end(err.message); res.status(500).end(err.message);
} }
}); });
// Mount the Apps router with API rate limiting // Mount the Apps router with API rate limiting
@ -119,8 +119,8 @@ app.use("/accounts", accountsRouter);
// 兜底404路由 - 处理所有未匹配的路由 // 兜底404路由 - 处理所有未匹配的路由
app.use((req, res, next) => { app.use((req, res, next) => {
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`); const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);
next(notFoundError); next(notFoundError);
}); });
// 全局错误处理中间件 // 全局错误处理中间件
@ -128,19 +128,19 @@ app.use(errorHandler);
// 全局未捕获的异常处理 // 全局未捕获的异常处理
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("未捕获的异常:", error); console.error("未捕获的异常:", error);
// 记录错误但不退出进程 // 记录错误但不退出进程
}); });
// 全局未处理的Promise拒绝处理 // 全局未处理的Promise拒绝处理
process.on("unhandledRejection", (reason, promise) => { process.on("unhandledRejection", (reason, promise) => {
console.error("未处理的Promise拒绝", reason); console.error("未处理的Promise拒绝", reason);
// 记录错误但不退出进程 // 记录错误但不退出进程
}); });
// 处理 SIGTERM 信号 // 处理 SIGTERM 信号
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
console.log("收到 SIGTERM 信号,准备关闭服务..."); console.log("收到 SIGTERM 信号,准备关闭服务...");
}); });
export default app; export default app;

68
bin/www
View File

@ -5,9 +5,9 @@
*/ */
import app from '../app.js'; import app from '../app.js';
import { createServer } from 'http'; import {createServer} from 'http';
import { initSocket } from '../utils/socket.js'; import {initSocket} from '../utils/socket.js';
import { initializeMetrics } from '../utils/metrics.js'; import {initializeMetrics} from '../utils/metrics.js';
/** /**
* Get port from environment and store in Express. * Get port from environment and store in Express.
@ -41,19 +41,19 @@ server.on('listening', onListening);
*/ */
function normalizePort(val) { function normalizePort(val) {
var port = parseInt(val, 10); var port = parseInt(val, 10);
if (isNaN(port)) { if (isNaN(port)) {
// named pipe // named pipe
return val; return val;
} }
if (port >= 0) { if (port >= 0) {
// port number // port number
return port; return port;
} }
return false; return false;
} }
/** /**
@ -61,27 +61,27 @@ function normalizePort(val) {
*/ */
function onError(error) { function onError(error) {
if (error.syscall !== 'listen') { if (error.syscall !== 'listen') {
throw error; throw error;
} }
var bind = typeof port === 'string' var bind = typeof port === 'string'
? 'Pipe ' + port ? 'Pipe ' + port
: 'Port ' + port; : 'Port ' + port;
// handle specific listen errors with friendly messages // handle specific listen errors with friendly messages
switch (error.code) { switch (error.code) {
case 'EACCES': case 'EACCES':
console.error(bind + ' requires elevated privileges'); console.error(bind + ' requires elevated privileges');
process.exit(1); process.exit(1);
break; break;
case 'EADDRINUSE': case 'EADDRINUSE':
console.error(bind + ' is already in use'); console.error(bind + ' is already in use');
process.exit(1); process.exit(1);
break; break;
default: default:
throw error; throw error;
} }
} }
/** /**
@ -89,6 +89,6 @@ function onError(error) {
*/ */
function onListening() { function onListening() {
var addr = server.address(); var addr = server.address();
console.log(`Server running at http://0.0.0.0:${addr.port}`); console.log(`Server running at http://0.0.0.0:${addr.port}`);
} }

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execSync } from "child_process"; import {execSync} from "child_process";
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
@ -7,78 +7,78 @@ dotenv.config();
// 🔄 执行数据库迁移函数 // 🔄 执行数据库迁移函数
function runDatabaseMigration() { function runDatabaseMigration() {
try { try {
console.log("🔄 执行数据库迁移..."); console.log("🔄 执行数据库迁移...");
execSync("npx prisma migrate deploy", { stdio: "inherit" }); execSync("npx prisma migrate deploy", {stdio: "inherit"});
console.log("✅ 数据库迁移完成"); console.log("✅ 数据库迁移完成");
} catch (error) { } catch (error) {
console.error("❌ 数据库迁移失败:", error.message); console.error("❌ 数据库迁移失败:", error.message);
process.exit(1); process.exit(1);
} }
} }
// 🧱 数据库初始化函数 // 🧱 数据库初始化函数
function setupDatabase() { function setupDatabase() {
try { try {
// 执行数据库迁移 // 执行数据库迁移
runDatabaseMigration(); runDatabaseMigration();
} catch (error) { } catch (error) {
console.error("❌ 数据库初始化失败:", error.message); console.error("❌ 数据库初始化失败:", error.message);
process.exit(1); process.exit(1);
} }
} }
// 🔨 本地构建函数 // 🔨 本地构建函数
function buildLocal() { function buildLocal() {
try { try {
// 确保数据库迁移已执行 // 确保数据库迁移已执行
runDatabaseMigration(); runDatabaseMigration();
execSync("npm install", { stdio: "inherit" }); // 安装依赖 execSync("npm install", {stdio: "inherit"}); // 安装依赖
execSync("npx prisma generate", { stdio: "inherit" }); // 生成 Prisma 客户端 execSync("npx prisma generate", {stdio: "inherit"}); // 生成 Prisma 客户端
console.log("✅ 构建完成"); console.log("✅ 构建完成");
} catch (error) { } catch (error) {
console.error("❌ 构建失败:", error.message); console.error("❌ 构建失败:", error.message);
process.exit(1); process.exit(1);
} }
} }
// 🚀 启动服务函数 // 🚀 启动服务函数
function startServer() { function startServer() {
try { try {
execSync("npm run start", { stdio: "inherit" }); // 启动项目 execSync("npm run start", {stdio: "inherit"}); // 启动项目
} catch (error) { } catch (error) {
console.error("❌ 服务启动失败:", error.message); console.error("❌ 服务启动失败:", error.message);
process.exit(1); process.exit(1);
} }
} }
// ▶️ 执行 Prisma CLI 命令函数 // ▶️ 执行 Prisma CLI 命令函数
function runPrismaCommand(args) { function runPrismaCommand(args) {
try { try {
const command = `npx prisma ${args.join(" ")}`; const command = `npx prisma ${args.join(" ")}`;
execSync(command, { stdio: "inherit" }); execSync(command, {stdio: "inherit"});
} catch (error) { } catch (error) {
console.error("❌ Prisma 命令执行失败:", error.message); console.error("❌ Prisma 命令执行失败:", error.message);
process.exit(1); process.exit(1);
} }
} }
// 🧠 主函数,根据命令行参数判断执行哪种流程 // 🧠 主函数,根据命令行参数判断执行哪种流程
async function main() { async function main() {
const args = process.argv.slice(2); // 获取命令行参数 const args = process.argv.slice(2); // 获取命令行参数
if (args[0] === "prisma") { if (args[0] === "prisma") {
// 如果输入的是 prisma 命令,则执行 prisma 子命令 // 如果输入的是 prisma 命令,则执行 prisma 子命令
runPrismaCommand(args.slice(1)); runPrismaCommand(args.slice(1));
} else { } else {
// 否则按默认流程:初始化 → 构建 → 启动服务 // 否则按默认流程:初始化 → 构建 → 启动服务
setupDatabase(); setupDatabase();
buildLocal(); buildLocal();
startServer(); startServer();
} }
} }
// 🚨 捕捉主函数异常 // 🚨 捕捉主函数异常
main().catch((error) => { main().catch((error) => {
console.error("❌ 脚本执行失败:", error); console.error("❌ 脚本执行失败:", error);
process.exit(1); process.exit(1);
}); });

View File

@ -13,127 +13,127 @@
import http from 'http'; import http from 'http';
import url from 'url'; import url from 'url';
import { randomBytes } from 'crypto'; import {randomBytes} from 'crypto';
// 配置 // 配置
const CONFIG = { const CONFIG = {
// API服务器地址 // API服务器地址
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
// 站点密钥 // 站点密钥
siteKey: process.env.SITE_KEY || '', siteKey: process.env.SITE_KEY || '',
// 应用ID // 应用ID
appId: process.env.APP_ID || '1', appId: process.env.APP_ID || '1',
// 授权页面地址Classworks前端 // 授权页面地址Classworks前端
authPageUrl: process.env.FRONTEND_URL, authPageUrl: process.env.FRONTEND_URL,
// 本地回调服务器端口 // 本地回调服务器端口
callbackPort: process.env.CALLBACK_PORT || '8080', callbackPort: process.env.CALLBACK_PORT || '8080',
// 回调路径 // 回调路径
callbackPath: '/callback', callbackPath: '/callback',
// 超时时间(秒) // 超时时间(秒)
timeout: 300, timeout: 300,
}; };
// 颜色输出 // 颜色输出
const colors = { const colors = {
reset: '\x1b[0m', reset: '\x1b[0m',
bright: '\x1b[1m', bright: '\x1b[1m',
dim: '\x1b[2m', dim: '\x1b[2m',
red: '\x1b[31m', red: '\x1b[31m',
green: '\x1b[32m', green: '\x1b[32m',
yellow: '\x1b[33m', yellow: '\x1b[33m',
blue: '\x1b[34m', blue: '\x1b[34m',
cyan: '\x1b[36m', cyan: '\x1b[36m',
}; };
function log(message, color = '') { function log(message, color = '') {
console.log(`${color}${message}${colors.reset}`); console.log(`${color}${message}${colors.reset}`);
} }
function logSuccess(message) { function logSuccess(message) {
log(`${message}`, colors.green); log(`${message}`, colors.green);
} }
function logError(message) { function logError(message) {
log(`${message}`, colors.red); log(`${message}`, colors.red);
} }
function logInfo(message) { function logInfo(message) {
log(` ${message}`, colors.cyan); log(` ${message}`, colors.cyan);
} }
function logWarning(message) { function logWarning(message) {
log(`${message}`, colors.yellow); log(`${message}`, colors.yellow);
} }
// HTTP请求封装 // HTTP请求封装
async function request(path, options = {}) { async function request(path, options = {}) {
const requestUrl = `${CONFIG.baseUrl}${path}`; const requestUrl = `${CONFIG.baseUrl}${path}`;
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}; };
if (CONFIG.siteKey) { if (CONFIG.siteKey) {
headers['X-Site-Key'] = 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}`);
} }
return data; try {
} catch (error) { const response = await fetch(requestUrl, {
if (error.message.includes('fetch')) { ...options,
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); 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() { function generateState() {
return randomBytes(16).toString('hex'); return randomBytes(16).toString('hex');
} }
// 获取设备UUID // 获取设备UUID
async function getDeviceUuid() { async function getDeviceUuid() {
try { try {
const deviceInfo = await request('/device/info'); const deviceInfo = await request('/device/info');
return deviceInfo.uuid; return deviceInfo.uuid;
} catch (error) { } catch (error) {
// 如果设备不存在生成新的UUID // 如果设备不存在生成新的UUID
const uuid = randomBytes(16).toString('hex'); const uuid = randomBytes(16).toString('hex');
logInfo(`生成新的设备UUID: ${uuid}`); logInfo(`生成新的设备UUID: ${uuid}`);
return uuid; return uuid;
} }
} }
// 创建回调服务器 // 创建回调服务器
function createCallbackServer(state) { function createCallbackServer(state) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let server; let server;
let resolved = false; let resolved = false;
const handleRequest = (req, res) => { const handleRequest = (req, res) => {
if (resolved) return; if (resolved) return;
const parsedUrl = url.parse(req.url, true); const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === CONFIG.callbackPath) { if (parsedUrl.pathname === CONFIG.callbackPath) {
const { token, error, state: returnedState } = parsedUrl.query; const {token, error, state: returnedState} = parsedUrl.query;
// 验证状态参数 // 验证状态参数
if (returnedState !== state) { if (returnedState !== state) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
res.end(` res.end(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -151,15 +151,15 @@ function createCallbackServer(state) {
</body> </body>
</html> </html>
`); `);
resolved = true; resolved = true;
server.close(); server.close();
reject(new Error('状态参数不匹配')); reject(new Error('状态参数不匹配'));
return; return;
} }
if (error) { if (error) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
res.end(` res.end(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -177,15 +177,15 @@ function createCallbackServer(state) {
</body> </body>
</html> </html>
`); `);
resolved = true; resolved = true;
server.close(); server.close();
reject(new Error(error)); reject(new Error(error));
return; return;
} }
if (token) { if (token) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
res.end(` res.end(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -205,15 +205,15 @@ function createCallbackServer(state) {
</body> </body>
</html> </html>
`); `);
resolved = true; resolved = true;
server.close(); server.close();
resolve(token); resolve(token);
return; return;
} }
// 如果没有token和error参数 // 如果没有token和error参数
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
res.end(` res.end(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -231,191 +231,191 @@ function createCallbackServer(state) {
</body> </body>
</html> </html>
`); `);
resolved = true; resolved = true;
server.close(); server.close();
reject(new Error('缺少必要的参数')); reject(new Error('缺少必要的参数'));
} else { } else {
// 404 for other paths // 404 for other paths
res.writeHead(404, { 'Content-Type': 'text/plain' }); res.writeHead(404, {'Content-Type': 'text/plain'});
res.end('Not Found'); res.end('Not Found');
} }
}; };
server = http.createServer(handleRequest); server = http.createServer(handleRequest);
server.listen(CONFIG.callbackPort, (err) => { server.listen(CONFIG.callbackPort, (err) => {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); 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) { async function openBrowser(url) {
const { spawn } = await import('child_process'); const {spawn} = await import('child_process');
let command; let command;
let args; let args;
if (process.platform === 'win32') { if (process.platform === 'win32') {
command = 'cmd'; command = 'cmd';
args = ['/c', 'start', url]; args = ['/c', 'start', url];
} else if (process.platform === 'darwin') { } else if (process.platform === 'darwin') {
command = 'open'; command = 'open';
args = [url]; args = [url];
} else { } else {
command = 'xdg-open'; command = 'xdg-open';
args = [url]; args = [url];
} }
try { try {
spawn(command, args, { detached: true, stdio: 'ignore' }); spawn(command, args, {detached: true, stdio: 'ignore'});
logSuccess('已尝试打开浏览器'); logSuccess('已尝试打开浏览器');
} catch (error) { } catch (error) {
logWarning('无法自动打开浏览器,请手动打开授权链接'); logWarning('无法自动打开浏览器,请手动打开授权链接');
} }
} }
// 显示授权信息 // 显示授权信息
function displayAuthInfo(authUrl, deviceUuid, state) { function displayAuthInfo(authUrl, deviceUuid, state) {
console.log('\n' + '='.repeat(60)); console.log('\n' + '='.repeat(60));
log(` 请访问以下地址完成授权:`, colors.bright); log(` 请访问以下地址完成授权:`, colors.bright);
console.log(''); console.log('');
log(` ${authUrl}`, colors.cyan + colors.bright); log(` ${authUrl}`, colors.cyan + colors.bright);
console.log(''); console.log('');
log(` 设备UUID: ${deviceUuid}`, colors.green); log(` 设备UUID: ${deviceUuid}`, colors.green);
log(` 状态参数: ${state}`, colors.dim); log(` 状态参数: ${state}`, colors.dim);
console.log('='.repeat(60)); console.log('='.repeat(60));
logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
logInfo(`API服务器: ${CONFIG.baseUrl}`); logInfo(`API服务器: ${CONFIG.baseUrl}`);
logInfo(`超时时间: ${CONFIG.timeout}`); logInfo(`超时时间: ${CONFIG.timeout}`);
console.log(''); console.log('');
} }
// 保存令牌到文件 // 保存令牌到文件
async function saveToken(token) { async function saveToken(token) {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
const os = await import('os'); const os = await import('os');
const tokenDir = path.join(os.homedir(), '.classworks'); const tokenDir = path.join(os.homedir(), '.classworks');
const tokenFile = path.join(tokenDir, 'token-callback.txt'); const tokenFile = path.join(tokenDir, 'token-callback.txt');
try { try {
// 确保目录存在 // 确保目录存在
if (!fs.existsSync(tokenDir)) { if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, { recursive: true }); 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() { async function main() {
console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n'); console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n');
try { try {
// 检查配置 // 检查配置
if (!CONFIG.siteKey) { if (!CONFIG.siteKey) {
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
logInfo('设置方法: export SITE_KEY=your-site-key'); logInfo('设置方法: export SITE_KEY=your-site-key');
console.log(''); 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);
}
} }
// 运行 // 运行

View File

@ -10,223 +10,221 @@
* 或配置为可执行chmod +x cli/get-token.js && ./cli/get-token.js * 或配置为可执行chmod +x cli/get-token.js && ./cli/get-token.js
*/ */
import readline from 'readline';
// 配置 // 配置
const CONFIG = { const CONFIG = {
// API服务器地址 // API服务器地址
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
// 站点密钥 // 站点密钥
siteKey: process.env.SITE_KEY || '', siteKey: process.env.SITE_KEY || '',
// 应用ID // 应用ID
appId: process.env.APP_ID || '1', appId: process.env.APP_ID || '1',
// 授权页面地址Classworks前端 // 授权页面地址Classworks前端
authPageUrl: process.env.FRONTEND_URL, authPageUrl: process.env.FRONTEND_URL,
// 轮询间隔(秒) // 轮询间隔(秒)
pollInterval: 3, pollInterval: 3,
// 最大轮询次数 // 最大轮询次数
maxPolls: 100, maxPolls: 100,
}; };
// 颜色输出 // 颜色输出
const colors = { const colors = {
reset: '\x1b[0m', reset: '\x1b[0m',
bright: '\x1b[1m', bright: '\x1b[1m',
dim: '\x1b[2m', dim: '\x1b[2m',
red: '\x1b[31m', red: '\x1b[31m',
green: '\x1b[32m', green: '\x1b[32m',
yellow: '\x1b[33m', yellow: '\x1b[33m',
blue: '\x1b[34m', blue: '\x1b[34m',
cyan: '\x1b[36m', cyan: '\x1b[36m',
}; };
function log(message, color = '') { function log(message, color = '') {
console.log(`${color}${message}${colors.reset}`); console.log(`${color}${message}${colors.reset}`);
} }
function logSuccess(message) { function logSuccess(message) {
log(`${message}`, colors.green); log(`${message}`, colors.green);
} }
function logError(message) { function logError(message) {
log(`${message}`, colors.red); log(`${message}`, colors.red);
} }
function logInfo(message) { function logInfo(message) {
log(` ${message}`, colors.cyan); log(` ${message}`, colors.cyan);
} }
function logWarning(message) { function logWarning(message) {
log(`${message}`, colors.yellow); log(`${message}`, colors.yellow);
} }
// HTTP请求封装 // HTTP请求封装
async function request(path, options = {}) { async function request(path, options = {}) {
const url = `${CONFIG.baseUrl}${path}`; const url = `${CONFIG.baseUrl}${path}`;
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}; };
if (CONFIG.siteKey) { if (CONFIG.siteKey) {
headers['X-Site-Key'] = 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}`);
} }
return data; try {
} catch (error) { const response = await fetch(url, {
if (error.message.includes('fetch')) { ...options,
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); 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() { async function generateDeviceCode() {
logInfo('正在生成设备授权码...'); logInfo('正在生成设备授权码...');
const data = await request('/auth/device/code', { const data = await request('/auth/device/code', {
method: 'POST', method: 'POST',
}); });
return data; return data;
} }
// 轮询获取令牌 // 轮询获取令牌
async function pollForToken(deviceCode) { async function pollForToken(deviceCode) {
let polls = 0; let polls = 0;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const poll = async () => { const poll = async () => {
polls++; polls++;
if (polls > CONFIG.maxPolls) { if (polls > CONFIG.maxPolls) {
reject(new Error('轮询超时,请重试')); reject(new Error('轮询超时,请重试'));
return; return;
} }
try { try {
const data = await request(`/auth/device/token?device_code=${deviceCode}`); const data = await request(`/auth/device/token?device_code=${deviceCode}`);
if (data.status === 'success') { if (data.status === 'success') {
resolve(data.token); resolve(data.token);
} else if (data.status === 'expired') { } else if (data.status === 'expired') {
reject(new Error('设备代码已过期')); reject(new Error('设备代码已过期'));
} else if (data.status === 'pending') { } else if (data.status === 'pending') {
// 继续轮询 // 继续轮询
log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim); log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim);
setTimeout(poll, CONFIG.pollInterval * 1000); setTimeout(poll, CONFIG.pollInterval * 1000);
} }
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}; };
// 开始轮询 // 开始轮询
poll(); poll();
}); });
} }
// 显示设备代码和授权链接 // 显示设备代码和授权链接
function displayDeviceCode(deviceCode, expiresIn) { function displayDeviceCode(deviceCode, expiresIn) {
console.log('\n' + '='.repeat(60)); console.log('\n' + '='.repeat(60));
log(` 请访问以下地址完成授权:`, colors.bright); log(` 请访问以下地址完成授权:`, colors.bright);
console.log(''); console.log('');
// 构建授权URL // 构建授权URL
const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`; const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`;
log(` ${authUrl}`, colors.cyan + colors.bright); log(` ${authUrl}`, colors.cyan + colors.bright);
console.log(''); console.log('');
log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright); log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright);
console.log('='.repeat(60)); console.log('='.repeat(60));
logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`); logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`);
logInfo(`API服务器: ${CONFIG.baseUrl}`); logInfo(`API服务器: ${CONFIG.baseUrl}`);
console.log(''); console.log('');
} }
// 保存令牌到文件 // 保存令牌到文件
async function saveToken(token) { async function saveToken(token) {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
const os = await import('os'); const os = await import('os');
const tokenDir = path.join(os.homedir(), '.classworks'); const tokenDir = path.join(os.homedir(), '.classworks');
const tokenFile = path.join(tokenDir, 'token.txt'); const tokenFile = path.join(tokenDir, 'token.txt');
try { try {
// 确保目录存在 // 确保目录存在
if (!fs.existsSync(tokenDir)) { if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, { recursive: true }); 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() { async function main() {
console.log('\n' + colors.cyan + colors.bright + '设备授权流程 - 令牌获取工具' + colors.reset + '\n'); console.log('\n' + colors.cyan + colors.bright + '设备授权流程 - 令牌获取工具' + colors.reset + '\n');
try { try {
// 检查配置 // 检查配置
if (!CONFIG.siteKey) { if (!CONFIG.siteKey) {
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
logInfo('设置方法: export SITE_KEY=your-site-key'); logInfo('设置方法: export SITE_KEY=your-site-key');
console.log(''); 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);
}
} }
// 运行 // 运行

View File

@ -1,82 +1,82 @@
// OAuth 提供者配置 // OAuth 提供者配置
export const oauthProviders = { export const oauthProviders = {
github: { github: {
clientId: process.env.GITHUB_CLIENT_ID, clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET, clientSecret: process.env.GITHUB_CLIENT_SECRET,
authorizationURL: "https://github.com/login/oauth/authorize", authorizationURL: "https://github.com/login/oauth/authorize",
tokenURL: "https://github.com/login/oauth/access_token", tokenURL: "https://github.com/login/oauth/access_token",
userInfoURL: "https://api.github.com/user", userInfoURL: "https://api.github.com/user",
scope: "read:user user:email", scope: "read:user user:email",
// 展示相关 // 展示相关
name: "GitHub", name: "GitHub",
displayName: "GitHub", displayName: "GitHub",
icon: "github", icon: "github",
color: "#24292e", color: "#24292e",
description: "使用 GitHub 账号登录", description: "使用 GitHub 账号登录",
website: "https://github.com", website: "https://github.com",
}, },
zerocat: { zerocat: {
clientId: process.env.ZEROCAT_CLIENT_ID, clientId: process.env.ZEROCAT_CLIENT_ID,
clientSecret: process.env.ZEROCAT_CLIENT_SECRET, clientSecret: process.env.ZEROCAT_CLIENT_SECRET,
authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize", authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize",
tokenURL: "https://zerocat-api.houlangs.com/oauth/token", tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo", userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
scope: "user:basic user:email", scope: "user:basic user:email",
// 展示相关 // 展示相关
name: "ZeroCat", name: "ZeroCat",
displayName: "ZeroCat", displayName: "ZeroCat",
icon: "zerocat", icon: "zerocat",
color: "#415f91", color: "#415f91",
description: "使用 ZeroCat 账号登录", description: "使用 ZeroCat 账号登录",
website: "https://zerocat.dev", website: "https://zerocat.dev",
}, },
stcn: { stcn: {
// STCNCasdoor- 标准 OIDC Provider // STCNCasdoor- 标准 OIDC Provider
clientId: process.env.STCN_CLIENT_ID, clientId: process.env.STCN_CLIENT_ID,
clientSecret: process.env.STCN_CLIENT_SECRET, clientSecret: process.env.STCN_CLIENT_SECRET,
// Casdoor 标准端点 // Casdoor 标准端点
authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize", authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize",
tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token", tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token",
userInfoURL: "https://auth.smart-teach.cn/api/userinfo", userInfoURL: "https://auth.smart-teach.cn/api/userinfo",
scope: "openid profile email offline_access", scope: "openid profile email offline_access",
// 展示相关 // 展示相关
name: "stcn", name: "stcn",
displayName: "智教联盟账户", displayName: "智教联盟账户",
icon: "casdoor", icon: "casdoor",
color: "#1068af", color: "#1068af",
description: "使用智教联盟账户登录", description: "使用智教联盟账户登录",
website: "https://auth.smart-teach.cn", website: "https://auth.smart-teach.cn",
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交 tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
}, },
hly: { hly: {
// 厚浪云Logto - OIDC Provider // 厚浪云Logto - OIDC Provider
clientId: process.env.HLY_CLIENT_ID, clientId: process.env.HLY_CLIENT_ID,
clientSecret: process.env.HLY_CLIENT_SECRET, clientSecret: process.env.HLY_CLIENT_SECRET,
authorizationURL: "https://oauth.houlang.cloud/oidc/auth", authorizationURL: "https://oauth.houlang.cloud/oidc/auth",
tokenURL: "https://oauth.houlang.cloud/oidc/token", tokenURL: "https://oauth.houlang.cloud/oidc/token",
userInfoURL: "https://oauth.houlang.cloud/oidc/me", userInfoURL: "https://oauth.houlang.cloud/oidc/me",
scope: "openid profile email offline_access", scope: "openid profile email offline_access",
// 展示相关 // 展示相关
name: "厚浪云", name: "厚浪云",
displayName: "厚浪云", displayName: "厚浪云",
icon: "logto", icon: "logto",
color: "#2d53f8", color: "#2d53f8",
textColor: "#ffffff", textColor: "#ffffff",
order: 40, order: 40,
description: "使用厚浪云账号登录", description: "使用厚浪云账号登录",
website: "https://houlang.cloud", website: "https://houlang.cloud",
pkce: true, // 启用PKCE支持 pkce: true, // 启用PKCE支持
}, },
}; };
// 获取OAuth回调URL // 获取OAuth回调URL
export function getCallbackURL(provider) { export function getCallbackURL(provider) {
const baseUrl = process.env.BASE_URL; const baseUrl = process.env.BASE_URL;
return `${baseUrl}/accounts/oauth/${provider}/callback`; return `${baseUrl}/accounts/oauth/${provider}/callback`;
} }
// 生成随机state参数 // 生成随机state参数
export function generateState() { export function generateState() {
return Math.random().toString(36).substring(2, 15) + return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15); Math.random().toString(36).substring(2, 15);
} }

View File

@ -7,9 +7,9 @@
* 3. passwordMiddleware - 验证设备密码 * 3. passwordMiddleware - 验证设备密码
*/ */
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { verifyDevicePassword } from "../utils/crypto.js"; import {verifyDevicePassword} from "../utils/crypto.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -18,20 +18,20 @@ const prisma = new PrismaClient();
* @param {number} deviceId - 设备ID * @param {number} deviceId - 设备ID
*/ */
async function createDefaultAutoAuth(deviceId) { async function createDefaultAutoAuth(deviceId) {
try { try {
// 创建默认的自动授权配置不需要密码、类型是classroom一体机 // 创建默认的自动授权配置不需要密码、类型是classroom一体机
await prisma.autoAuth.create({ await prisma.autoAuth.create({
data: { data: {
deviceId: deviceId, deviceId: deviceId,
password: null, // 无密码 password: null, // 无密码
deviceType: "classroom", // 一体机类型 deviceType: "classroom", // 一体机类型
isReadOnly: false, // 非只读 isReadOnly: false, // 非只读
}, },
}); });
} catch (error) { } catch (error) {
console.error('创建默认自动登录配置失败:', error); console.error('创建默认自动登录配置失败:', error);
// 这里不抛出错误,避免影响设备创建流程 // 这里不抛出错误,避免影响设备创建流程
} }
} }
/** /**
@ -46,36 +46,36 @@ async function createDefaultAutoAuth(deviceId) {
* router.get('/path/:deviceUuid', deviceMiddleware, handler) * router.get('/path/:deviceUuid', deviceMiddleware, handler)
*/ */
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { 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) { if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID")); return next(errors.createError(400, "缺少设备UUID"));
} }
// 查找或创建设备 // 查找或创建设备
let device = await prisma.device.findUnique({ let device = await prisma.device.findUnique({
where: { uuid: deviceUuid }, where: {uuid: deviceUuid},
});
if (!device) {
// 设备不存在,自动创建
device = await prisma.device.create({
data: {
uuid: deviceUuid,
name: null,
password: null,
passwordHint: null,
accountId: null,
},
}); });
// 为新创建的设备添加默认的自动登录配置 if (!device) {
await createDefaultAutoAuth(device.id); // 设备不存在,自动创建
} device = await prisma.device.create({
data: {
uuid: deviceUuid,
name: null,
password: null,
passwordHint: null,
accountId: null,
},
});
// 将设备信息存储到res.locals // 为新创建的设备添加默认的自动登录配置
res.locals.device = device; await createDefaultAutoAuth(device.id);
next(); }
// 将设备信息存储到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) * router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
*/ */
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => { export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
const deviceUuid = req.params.deviceUuid ; const deviceUuid = req.params.deviceUuid;
if (!deviceUuid) { if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID")); return next(errors.createError(400, "缺少设备UUID"));
} }
// 查找设备 // 查找设备
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid: deviceUuid }, where: {uuid: deviceUuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 将设备信息存储到res.locals // 将设备信息存储到res.locals
res.locals.device = device; res.locals.device = device;
next(); next();
}); });
/** /**
@ -122,29 +122,29 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
* router.post('/path', deviceMiddleware, passwordMiddleware, handler) * router.post('/path', deviceMiddleware, passwordMiddleware, handler)
*/ */
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => { export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
const device = res.locals.device; const device = res.locals.device;
const { password } = req.body; const {password} = req.body;
if (!device) { if (!device) {
return next(errors.createError(500, "设备信息未加载请先使用deviceMiddleware")); 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, "设备需要密码"));
} }
const isValid = await verifyDevicePassword(password, device.password); // 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
if (!isValid) { if (device.accountId && req.account && req.account.id === device.accountId) {
return next(errors.createError(401, "密码错误")); 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();
}); });

View File

@ -1,51 +1,51 @@
import { isDevelopment } from "../utils/config.js"; import {isDevelopment} from "../utils/config.js";
const errorHandler = (err, req, res, next) => { const errorHandler = (err, req, res, next) => {
// 判断响应是否已经发送 // 判断响应是否已经发送
if (res.headersSent) { if (res.headersSent) {
return next(err); return next(err);
}
console.error(err);
try {
if (isDevelopment) {
// 输出错误信息到控制台
console.error("Error occurred:");
console.error(err);
} }
// 提取错误信息 console.error(err);
const statusCode = err.statusCode || err.status || 500;
const message = err.message || "服务器错误";
const details = err.details || null;
const code = err.code || undefined;
// 返回统一格式的错误响应 try {
return res.status(statusCode).json({ if (isDevelopment) {
success: false, // 输出错误信息到控制台
message: message, console.error("Error occurred:");
code: code, console.error(err);
details: details, }
error: // 提取错误信息
process.env.NODE_ENV === "production" const statusCode = err.statusCode || err.status || 500;
? undefined const message = err.message || "服务器错误";
: { const details = err.details || null;
stack: err.stack, const code = err.code || undefined;
originalError: err.originalError
? err.originalError.message
: null,
},
});
} catch (handlerError) {
// 处理器本身出错的兜底方案
console.error("Error in error handler:", handlerError);
// 确保能返回响应 // 返回统一格式的错误响应
return res.status(500).json({ return res.status(statusCode).json({
success: false, success: false,
message: "服务器错误", message: message,
details: "服务器处理错误时出现问题", 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; export default errorHandler;

View File

@ -6,9 +6,9 @@
* 适用于只需要账户验证的接口 * 适用于只需要账户验证的接口
*/ */
import { verifyAccessToken, validateAccountToken, generateAccessToken } from "../utils/tokenManager.js"; import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js";
import { verifyToken } from "../utils/jwt.js"; import {verifyToken} from "../utils/jwt.js";
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -17,77 +17,77 @@ const prisma = new PrismaClient();
* 新的JWT认证中间件支持refresh token系统 * 新的JWT认证中间件支持refresh token系统
*/ */
export const jwtAuth = async (req, res, next) => { 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 { try {
// 尝试使用新的token验证系统 const authHeader = req.headers.authorization;
const decoded = verifyAccessToken(token);
// 验证账户并检查token版本 if (!authHeader || !authHeader.startsWith("Bearer ")) {
const account = await validateAccountToken(decoded); return next(errors.createError(401, "需要提供有效的JWT token"));
// 将账户信息存储到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 const token = authHeader.substring(7);
res.locals.account = account;
res.locals.tokenDecoded = decoded;
res.locals.isLegacyToken = true; // 标记为旧版token
next(); try {
} catch (legacyTokenError) { // 尝试使用新的token验证系统
// 两种验证方式都失败 const decoded = verifyAccessToken(token);
if (newTokenError.name === 'JsonWebTokenError' || legacyTokenError.name === 'JsonWebTokenError') {
return next(errors.createError(401, "无效的JWT 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验证失败"));
}
} }
} catch (error) {
if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') { return next(errors.createError(500, "认证过程出错"));
// 统一的账户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, "认证过程出错"));
}
}; };
/** /**
@ -95,13 +95,13 @@ export const jwtAuth = async (req, res, next) => {
* 如果提供了token则验证没有提供则跳过 * 如果提供了token则验证没有提供则跳过
*/ */
export const optionalJwtAuth = async (req, res, next) => { export const optionalJwtAuth = async (req, res, next) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) { if (!authHeader || !authHeader.startsWith("Bearer ")) {
// 没有提供token跳过认证 // 没有提供token跳过认证
return next(); return next();
} }
// 有token则进行验证 // 有token则进行验证
return jwtAuth(req, res, next); return jwtAuth(req, res, next);
}; };

View File

@ -5,7 +5,7 @@
* 适用于所有KV相关的接口 * 适用于所有KV相关的接口
*/ */
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -15,35 +15,35 @@ const prisma = new PrismaClient();
* 从请求中提取token支持多种方式验证后将设备和应用信息注入到res.locals * 从请求中提取token支持多种方式验证后将设备和应用信息注入到res.locals
*/ */
export const kvTokenAuth = async (req, res, next) => { export const kvTokenAuth = async (req, res, next) => {
try { try {
// 从多种途径获取token // 从多种途径获取token
const token = extractToken(req); const token = extractToken(req);
if (!token) { if (!token) {
return next(errors.createError(401, "需要提供有效的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 * 3. Body: token apptoken
*/ */
function extractToken(req) { function extractToken(req) {
// 优先从 Authorization header 提取 Bearer token支持大小写 // 优先从 Authorization header 提取 Bearer token支持大小写
const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization); const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization);
if (authHeader) { if (authHeader) {
const m = authHeader.match(/^Bearer\s+(.+)$/i); const m = authHeader.match(/^Bearer\s+(.+)$/i);
if (m) return m[1]; if (m) return m[1];
} }
return ( return (
req.headers["x-app-token"] || req.headers["x-app-token"] ||
req.query.token || req.query.token ||
req.query.apptoken || req.query.apptoken ||
(req.body && req.body.token) || (req.body && req.body.token) ||
(req.body && req.body.apptoken) (req.body && req.body.apptoken)
); );
} }

View File

@ -2,119 +2,118 @@ import rateLimit from "express-rate-limit";
// 获取客户端真实IP的函数 // 获取客户端真实IP的函数
export const getClientIp = (req) => { export const getClientIp = (req) => {
return ( return (
req.headers["x-forwarded-for"] || req.headers["x-forwarded-for"] ||
req.connection.remoteAddress || req.connection.remoteAddress ||
req.socket.remoteAddress || req.socket.remoteAddress ||
req.connection.socket?.remoteAddress || req.connection.socket?.remoteAddress ||
"0.0.0.0" "0.0.0.0"
); );
}; };
// 从请求中提取Token的函数 // 从请求中提取Token的函数
const extractToken = (req) => { const extractToken = (req) => {
return ( return (
req.headers["x-app-token"] || req.headers["x-app-token"] ||
req.query.apptoken || req.query.apptoken ||
req.body?.apptoken || req.body?.apptoken ||
null null
); );
}; };
// 获取限速键优先使用token没有token则使用IP // 获取限速键优先使用token没有token则使用IP
export const getRateLimitKey = (req) => { export const getRateLimitKey = (req) => {
const token = extractToken(req); const token = extractToken(req);
if (token) { if (token) {
return `token:${token}`; return `token:${token}`;
} }
return `ip:${getClientIp(req)}`; return `ip:${getClientIp(req)}`;
}; };
// 纯基于Token的keyGenerator用于KV Token专用路由 // 纯基于Token的keyGenerator用于KV Token专用路由
// 这个函数假设token已经通过中间件设置在req对象上 // 这个函数假设token已经通过中间件设置在req对象上
export const getTokenOnlyKey = (req) => { export const getTokenOnlyKey = (req) => {
// 尝试从多个位置获取token // 尝试从多个位置获取token
const token = const token =
req.locals?.token || // 如果token被设置在req.locals中 req.locals?.token || // 如果token被设置在req.locals中
req.res?.locals?.token || // 如果token在res.locals中 req.res?.locals?.token || // 如果token在res.locals中
extractToken(req); // 从headers/query/body提取 extractToken(req); // 从headers/query/body提取
if (!token) { if (!token) {
// 如果没有token返回一个特殊键用于统一限制 // 如果没有token返回一个特殊键用于统一限制
return "no-token"; return "no-token";
} }
return `token:${token}`; return `token:${token}`;
}; };
// 创建一个中间件来将res.locals.token复制到req.locals.token以便限速器使用 // 创建一个中间件来将res.locals.token复制到req.locals.token以便限速器使用
export const prepareTokenForRateLimit = (req, res, next) => { export const prepareTokenForRateLimit = (req, res, next) => {
if (res.locals.token) { if (res.locals.token) {
req.locals = req.locals || {}; req.locals = req.locals || {};
req.locals.token = res.locals.token; req.locals.token = res.locals.token;
} }
next(); next();
}; };
// 认证相关路由限速器(防止暴力破解) // 认证相关路由限速器(防止暴力破解)
export const authLimiter = rateLimit({ export const authLimiter = rateLimit({
windowMs: 30 * 60 * 1000, // 30分钟 windowMs: 30 * 60 * 1000, // 30分钟
limit: 5, // 每个IP在windowMs时间内最多允许5次认证尝试 limit: 5, // 每个IP在windowMs时间内最多允许5次认证尝试
standardHeaders: "draft-7", standardHeaders: "draft-7",
legacyHeaders: false, legacyHeaders: false,
message: "认证请求过于频繁请30分钟后再试", message: "认证请求过于频繁请30分钟后再试",
keyGenerator: getClientIp, keyGenerator: getClientIp,
skipSuccessfulRequests: true, // 成功的认证不计入限制 skipSuccessfulRequests: true, // 成功的认证不计入限制
skipFailedRequests: false, // 失败的认证计入限制 skipFailedRequests: false, // 失败的认证计入限制
}); });
// === Token 专用限速器更宽松的限制纯基于Token === // === Token 专用限速器更宽松的限制纯基于Token ===
// Token 读操作限速器 // Token 读操作限速器
export const tokenReadLimiter = rateLimit({ export const tokenReadLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟 windowMs: 1 * 60 * 1000, // 1分钟
limit: 1024, // 每个token在1分钟内最多1024次读操作 limit: 1024, // 每个token在1分钟内最多1024次读操作
standardHeaders: "draft-7", standardHeaders: "draft-7",
legacyHeaders: false, legacyHeaders: false,
message: "读操作请求过于频繁,请稍后再试", message: "读操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey, keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false, skipSuccessfulRequests: false,
skipFailedRequests: false, skipFailedRequests: false,
}); });
// Token 写操作限速器 // Token 写操作限速器
export const tokenWriteLimiter = rateLimit({ export const tokenWriteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟 windowMs: 1 * 60 * 1000, // 1分钟
limit: 512, // 每个token在1分钟内最多512次写操作 limit: 512, // 每个token在1分钟内最多512次写操作
standardHeaders: "draft-7", standardHeaders: "draft-7",
legacyHeaders: false, legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试", message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey, keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false, skipSuccessfulRequests: false,
skipFailedRequests: false, skipFailedRequests: false,
}); });
// Token 删除操作限速器 // Token 删除操作限速器
export const tokenDeleteLimiter = rateLimit({ export const tokenDeleteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟 windowMs: 1 * 60 * 1000, // 1分钟
limit: 256, // 每个token在1分钟内最多256次删除操作 limit: 256, // 每个token在1分钟内最多256次删除操作
standardHeaders: "draft-7", standardHeaders: "draft-7",
legacyHeaders: false, legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试", message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey, keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false, skipSuccessfulRequests: false,
skipFailedRequests: false, skipFailedRequests: false,
}); });
// Token 批量操作限速器 // Token 批量操作限速器
export const tokenBatchLimiter = rateLimit({ export const tokenBatchLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟 windowMs: 1 * 60 * 1000, // 1分钟
limit: 128, // 每个token在1分钟内最多128次批量操作 limit: 128, // 每个token在1分钟内最多128次批量操作
standardHeaders: "draft-7", standardHeaders: "draft-7",
legacyHeaders: false, legacyHeaders: false,
message: "批量操作请求过于频繁,请稍后再试", message: "批量操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey, keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false, skipSuccessfulRequests: false,
skipFailedRequests: false, skipFailedRequests: false,
}); });

View File

@ -6,10 +6,10 @@
* 3. 适用于需要设备上下文的接口 * 3. 适用于需要设备上下文的接口
*/ */
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { verifyToken as verifyAccountJWT } from "../utils/jwt.js"; import {verifyToken as verifyAccountJWT} from "../utils/jwt.js";
import { verifyDevicePassword } from "../utils/crypto.js"; import {verifyDevicePassword} from "../utils/crypto.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -17,130 +17,131 @@ const prisma = new PrismaClient();
* UUID+密码/JWT混合认证中间件 * UUID+密码/JWT混合认证中间件
*/ */
export const uuidAuth = async (req, res, next) => { export const uuidAuth = async (req, res, next) => {
try { try {
// 1. 获取UUID必需 // 1. 获取UUID必需
const uuid = extractUuid(req); const uuid = extractUuid(req);
if (!uuid) { if (!uuid) {
return next(errors.createError(400, "需要提供设备UUID")); return next(errors.createError(400, "需要提供设备UUID"));
} }
// 2. 查找设备并存储到locals // 2. 查找设备并存储到locals
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, 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 }
}
}
}); });
if (!account) { if (!device) {
return next(errors.createError(401, "账户不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 检查设备是否绑定到此账户 // 存储设备信息到locals
if (account.devices.length === 0) { res.locals.device = device;
return next(errors.createError(403, "设备未绑定到此账户")); 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"));
} }
} catch (error) {
res.locals.account = account; next(error);
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);
}
}; };
export const extractDeviceInfo = async (req,res,next) => { export const extractDeviceInfo = async (req, res, next) => {
var uuid= extractUuid(req); var uuid = extractUuid(req);
if (!uuid) { if (!uuid) {
throw errors.createError(400, "需要提供设备UUID"); throw errors.createError(400, "需要提供设备UUID");
} }
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
throw errors.createError(404, "设备不存在"); throw errors.createError(404, "设备不存在");
} }
res.locals.device = device; res.locals.device = device;
res.locals.deviceId = device.id; res.locals.deviceId = device.id;
next(); next();
} }
/** /**
* 从请求中提取UUID * 从请求中提取UUID
*/ */
function extractUuid(req) { function extractUuid(req) {
return ( return (
req.headers["x-device-uuid"] || req.headers["x-device-uuid"] ||
req.query.uuid || req.query.uuid ||
req.params.uuid || req.params.uuid ||
req.params.deviceUuid || req.params.deviceUuid ||
(req.body && req.body.uuid) || (req.body && req.body.uuid) ||
(req.body && req.body.deviceUuid) (req.body && req.body.deviceUuid)
); );
} }
/** /**
* 从请求中提取密码 * 从请求中提取密码
*/ */
function extractPassword(req) { function extractPassword(req) {
return ( return (
req.headers["x-device-password"] || req.headers["x-device-password"] ||
req.query.password || req.query.password ||
req.query.currentPassword req.query.currentPassword
); );
} }
/** /**
* 从请求中提取JWT * 从请求中提取JWT
*/ */
function extractJWT(req) { function extractJWT(req) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) { if (authHeader && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7); return authHeader.substring(7);
} }
return null; return null;
} }

View File

@ -1,166 +1,172 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>登录失败</title> <title>登录失败</title>
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
height: 100vh; height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.container { .container {
background: white; background: white;
padding: 2rem; padding: 2rem;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 400px; max-width: 400px;
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.error-icon { .error-icon {
width: 80px; width: 80px;
height: 80px; height: 80px;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
background: #ef4444; background: #ef4444;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: shake 0.5s ease; animation: shake 0.5s ease;
} }
.error-icon svg { .error-icon svg {
width: 40px; width: 40px;
height: 40px; height: 40px;
stroke: white; stroke: white;
stroke-width: 3; stroke-width: 3;
} }
@keyframes shake { @keyframes shake {
0%, 100% { transform: translateX(0); } 0%, 100% {
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } transform: translateX(0);
20%, 40%, 60%, 80% { transform: translateX(5px); } }
} 10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
}
h1 { h1 {
color: #1f2937; color: #1f2937;
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.error-message { .error-message {
color: #6b7280; color: #6b7280;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 1rem; padding: 1rem;
background: #fee2e2; background: #fee2e2;
border-radius: 8px; border-radius: 8px;
color: #991b1b; color: #991b1b;
} }
.error-code { .error-code {
font-family: monospace; font-family: monospace;
font-size: 0.875rem; font-size: 0.875rem;
word-break: break-all; word-break: break-all;
} }
.retry-btn { .retry-btn {
background: #4f46e5; background: #4f46e5;
color: white; color: white;
border: none; border: none;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.retry-btn:hover { .retry-btn:hover {
background: #4338ca; background: #4338ca;
} }
.close-btn { .close-btn {
background: transparent; background: transparent;
color: #6b7280; color: #6b7280;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.close-btn:hover { .close-btn:hover {
background: #f3f4f6; background: #f3f4f6;
} }
.help-text { .help-text {
color: #6b7280; color: #6b7280;
font-size: 0.75rem; font-size: 0.75rem;
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1.5rem; padding-top: 1.5rem;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="error-icon"> <div class="error-icon">
<svg fill="none" viewBox="0 0 24 24"> <svg fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</div> </div>
<h1>登录失败</h1> <h1>登录失败</h1>
<div class="error-message"> <div class="error-message">
<div id="errorMsg">认证过程中出现错误</div> <div id="errorMsg">认证过程中出现错误</div>
<div class="error-code" id="errorCode"></div> <div class="error-code" id="errorCode"></div>
</div> </div>
<a href="javascript:history.back()" class="retry-btn">返回重试</a> <a class="retry-btn" href="javascript:history.back()">返回重试</a>
<button class="close-btn" onclick="window.close()">关闭窗口</button> <button class="close-btn" onclick="window.close()">关闭窗口</button>
<div class="help-text"> <div class="help-text">
如果问题持续存在,请检查:<br> 如果问题持续存在,请检查:<br>
• OAuth应用配置是否正确<br> • OAuth应用配置是否正确<br>
• 回调URL是否已添加到OAuth应用中<br> • 回调URL是否已添加到OAuth应用中<br>
• 环境变量是否配置正确 • 环境变量是否配置正确
</div> </div>
</div> </div>
<script> <script>
// 从URL获取错误信息 // 从URL获取错误信息
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const error = params.get('error'); const error = params.get('error');
if (error) { if (error) {
const errorMessages = { const errorMessages = {
'invalid_state': 'State验证失败可能存在CSRF攻击', 'invalid_state': 'State验证失败可能存在CSRF攻击',
'access_denied': '用户拒绝了授权请求', 'access_denied': '用户拒绝了授权请求',
'temporarily_unavailable': '服务暂时不可用,请稍后重试' 'temporarily_unavailable': '服务暂时不可用,请稍后重试'
}; };
const errorMsg = errorMessages[error] || '未知错误'; const errorMsg = errorMessages[error] || '未知错误';
document.getElementById('errorMsg').textContent = errorMsg; document.getElementById('errorMsg').textContent = errorMsg;
document.getElementById('errorCode').textContent = `错误代码: ${error}`; document.getElementById('errorCode').textContent = `错误代码: ${error}`;
} }
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,160 +1,160 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>登录成功</title> <title>登录成功</title>
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh; height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.container { .container {
background: white; background: white;
padding: 2rem; padding: 2rem;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 400px; max-width: 400px;
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.success-icon { .success-icon {
width: 80px; width: 80px;
height: 80px; height: 80px;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
background: #10b981; background: #10b981;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: scaleIn 0.5s ease; animation: scaleIn 0.5s ease;
} }
.success-icon svg { .success-icon svg {
width: 40px; width: 40px;
height: 40px; height: 40px;
stroke: white; stroke: white;
stroke-width: 3; stroke-width: 3;
} }
@keyframes scaleIn { @keyframes scaleIn {
from { from {
transform: scale(0); transform: scale(0);
opacity: 0; opacity: 0;
} }
to { to {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
} }
h1 { h1 {
color: #1f2937; color: #1f2937;
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.provider { .provider {
color: #6b7280; color: #6b7280;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.token-container { .token-container {
background: #f3f4f6; background: #f3f4f6;
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
word-break: break-all; word-break: break-all;
} }
.token-label { .token-label {
color: #6b7280; color: #6b7280;
font-size: 0.75rem; font-size: 0.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.token { .token {
color: #1f2937; color: #1f2937;
font-family: monospace; font-family: monospace;
font-size: 0.875rem; font-size: 0.875rem;
user-select: all; user-select: all;
} }
.copy-btn { .copy-btn {
background: #4f46e5; background: #4f46e5;
color: white; color: white;
border: none; border: none;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.copy-btn:hover { .copy-btn:hover {
background: #4338ca; background: #4338ca;
} }
.copy-btn:active { .copy-btn:active {
transform: scale(0.98); transform: scale(0.98);
} }
.copy-btn.copied { .copy-btn.copied {
background: #10b981; background: #10b981;
} }
.auto-close { .auto-close {
color: #6b7280; color: #6b7280;
font-size: 0.875rem; font-size: 0.875rem;
} }
.countdown { .countdown {
color: #4f46e5; color: #4f46e5;
font-weight: bold; font-weight: bold;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="success-icon"> <div class="success-icon">
<svg fill="none" viewBox="0 0 24 24"> <svg fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</div> </div>
<h1>登录成功</h1> <h1>登录成功</h1>
<p class="provider" id="provider">OAuth Provider</p> <p class="provider" id="provider">OAuth Provider</p>
<div class="token-container"> <div class="token-container">
<div class="token-label">访问令牌</div> <div class="token-label">访问令牌</div>
<div class="token" id="token">加载中...</div> <div class="token" id="token">加载中...</div>
</div> </div>
<button class="copy-btn" id="copyBtn" onclick="copyToken()">复制令牌</button> <button class="copy-btn" id="copyBtn" onclick="copyToken()">复制令牌</button>
<div class="auto-close"> <div class="auto-close">
窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭 窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭
</div> </div>
</div> </div>
<script> <script>
// 从URL获取参数 // 从URL获取参数
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const token = params.get('token'); const token = params.get('token');
@ -162,63 +162,63 @@
// 显示信息 // 显示信息
if (token) { if (token) {
document.getElementById('token').textContent = token; document.getElementById('token').textContent = token;
// 保存到localStorage前端应用可以读取 // 保存到localStorage前端应用可以读取
localStorage.setItem('auth_token', token); localStorage.setItem('auth_token', token);
localStorage.setItem('auth_provider', provider); localStorage.setItem('auth_provider', provider);
// 触发storage事件通知其他窗口 // 触发storage事件通知其他窗口
window.dispatchEvent(new StorageEvent('storage', { window.dispatchEvent(new StorageEvent('storage', {
key: 'auth_token', key: 'auth_token',
newValue: token, newValue: token,
url: window.location.href url: window.location.href
})); }));
} else { } else {
document.getElementById('token').textContent = '未获取到令牌'; document.getElementById('token').textContent = '未获取到令牌';
} }
if (provider) { if (provider) {
const providerNames = { const providerNames = {
'github': 'GitHub', 'github': 'GitHub',
'zerocat': 'ZeroCat' 'zerocat': 'ZeroCat'
}; };
document.getElementById('provider').textContent = `通过 ${providerNames[provider] || provider} 登录`; document.getElementById('provider').textContent = `通过 ${providerNames[provider] || provider} 登录`;
} }
// 复制令牌 // 复制令牌
function copyToken() { function copyToken() {
if (!token) return; if (!token) return;
navigator.clipboard.writeText(token).then(() => { navigator.clipboard.writeText(token).then(() => {
const btn = document.getElementById('copyBtn'); const btn = document.getElementById('copyBtn');
btn.textContent = '已复制'; btn.textContent = '已复制';
btn.classList.add('copied'); btn.classList.add('copied');
setTimeout(() => { setTimeout(() => {
btn.textContent = '复制令牌'; btn.textContent = '复制令牌';
btn.classList.remove('copied'); btn.classList.remove('copied');
}, 2000); }, 2000);
}).catch(() => { }).catch(() => {
// 降级方案 // 降级方案
const textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.value = token; textArea.value = token;
textArea.style.position = 'fixed'; textArea.style.position = 'fixed';
textArea.style.opacity = '0'; textArea.style.opacity = '0';
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(textArea); document.body.removeChild(textArea);
const btn = document.getElementById('copyBtn'); const btn = document.getElementById('copyBtn');
btn.textContent = '已复制'; btn.textContent = '已复制';
btn.classList.add('copied'); btn.classList.add('copied');
setTimeout(() => { setTimeout(() => {
btn.textContent = '复制令牌'; btn.textContent = '复制令牌';
btn.classList.remove('copied'); btn.classList.remove('copied');
}, 2000); }, 2000);
}); });
} }
// 倒计时关闭 // 倒计时关闭
@ -226,29 +226,29 @@
const countdownEl = document.getElementById('countdown'); const countdownEl = document.getElementById('countdown');
const timer = setInterval(() => { const timer = setInterval(() => {
countdown--; countdown--;
countdownEl.textContent = countdown; countdownEl.textContent = countdown;
if (countdown <= 0) { if (countdown <= 0) {
clearInterval(timer); clearInterval(timer);
// 尝试关闭窗口 // 尝试关闭窗口
window.close(); window.close();
// 如果无法关闭(比如不是通过脚本打开的),显示提示 // 如果无法关闭(比如不是通过脚本打开的),显示提示
setTimeout(() => { setTimeout(() => {
if (!window.closed) { if (!window.closed) {
countdownEl.parentElement.innerHTML = '您可以关闭此窗口了'; countdownEl.parentElement.innerHTML = '您可以关闭此窗口了';
} }
}, 100); }, 100);
} }
}, 1000); }, 1000);
// 如果用户有任何交互,停止自动关闭 // 如果用户有任何交互,停止自动关闭
document.addEventListener('click', () => { document.addEventListener('click', () => {
clearInterval(timer); clearInterval(timer);
document.querySelector('.auto-close').style.display = 'none'; document.querySelector('.auto-close').style.display = 'none';
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,8 +1,8 @@
body { body {
padding: 50px; padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
} }
a { a {
color: #00B7FF; color: #00B7FF;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,11 @@
import { Router } from "express"; import {Router} from "express";
const router = Router(); import {uuidAuth} from "../middleware/uuidAuth.js";
import { uuidAuth } from "../middleware/uuidAuth.js"; import {PrismaClient} from "@prisma/client";
import { jwtAuth } from "../middleware/jwt-auth.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto"; import crypto from "crypto";
import errors from "../utils/errors.js"; 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(); const prisma = new PrismaClient();
@ -15,35 +14,35 @@ const prisma = new PrismaClient();
* 获取设备安装的应用列表 (公开接口无需认证) * 获取设备安装的应用列表 (公开接口无需认证)
*/ */
router.get( router.get(
"/devices/:uuid/apps", "/devices/:uuid/apps",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params; const {uuid} = req.params;
// 查找设备 // 查找设备
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
const installations = await prisma.appInstall.findMany({ const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id }, where: {deviceId: device.id},
}); });
const apps = installations.map(install => ({ const apps = installations.map(install => ({
appId: install.appId, appId: install.appId,
token: install.token, token: install.token,
note: install.note, note: install.note,
installedAt: install.createdAt, installedAt: install.createdAt,
})); }));
return res.json({ return res.json({
success: true, success: true,
apps, apps,
}); });
}) })
); );
/** /**
@ -52,35 +51,35 @@ router.get(
* appId 现在是 SHA256 hash * appId 现在是 SHA256 hash
*/ */
router.post( router.post(
"/devices/:uuid/install/:appId", "/devices/:uuid/install/:appId",
uuidAuth, uuidAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const device = res.locals.device; const device = res.locals.device;
const { appId } = req.params; const {appId} = req.params;
const { note } = req.body; const {note} = req.body;
// 生成token // 生成token
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
// 创建安装记录 // 创建安装记录
const installation = await prisma.appInstall.create({ const installation = await prisma.appInstall.create({
data: { data: {
deviceId: device.id, deviceId: device.id,
appId: appId, appId: appId,
token, token,
note: note || null, note: note || null,
}, },
}); });
return res.status(201).json({ return res.status(201).json({
id: installation.id, id: installation.id,
appId: installation.appId, appId: installation.appId,
token: installation.token, token: installation.token,
note: installation.note, note: installation.note,
name: installation.note, // 备注同时作为名称返回 name: installation.note, // 备注同时作为名称返回
installedAt: installation.createdAt, installedAt: installation.createdAt,
}); });
}) })
); );
/** /**
@ -88,31 +87,31 @@ router.post(
* 卸载设备应用 (需要UUID认证) * 卸载设备应用 (需要UUID认证)
*/ */
router.delete( router.delete(
"/devices/:uuid/uninstall/:installId", "/devices/:uuid/uninstall/:installId",
uuidAuth, uuidAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const device = res.locals.device; const device = res.locals.device;
const { installId } = req.params; const {installId} = req.params;
const installation = await prisma.appInstall.findUnique({ const installation = await prisma.appInstall.findUnique({
where: { id: installId }, where: {id: installId},
}); });
if (!installation) { if (!installation) {
return next(errors.createError(404, "应用未安装")); return next(errors.createError(404, "应用未安装"));
} }
// 确保安装记录属于当前设备 // 确保安装记录属于当前设备
if (installation.deviceId !== device.id) { if (installation.deviceId !== device.id) {
return next(errors.createError(403, "无权操作此安装记录")); return next(errors.createError(403, "无权操作此安装记录"));
} }
await prisma.appInstall.delete({ await prisma.appInstall.delete({
where: { id: installation.id }, where: {id: installation.id},
}); });
return res.status(204).end(); return res.status(204).end();
}) })
); );
/** /**
@ -120,44 +119,44 @@ router.delete(
* 获取设备的token列表 (需要设备UUID) * 获取设备的token列表 (需要设备UUID)
*/ */
router.get( router.get(
"/tokens", "/tokens",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.query; const {uuid} = req.query;
if (!uuid) { if (!uuid) {
return next(errors.createError(400, "需要提供设备UUID")); return next(errors.createError(400, "需要提供设备UUID"));
} }
// 查找设备 // 查找设备
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 获取该设备的所有应用安装记录即token // 获取该设备的所有应用安装记录即token
const installations = await prisma.appInstall.findMany({ const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id }, where: {deviceId: device.id},
orderBy: { installedAt: 'desc' }, orderBy: {installedAt: 'desc'},
}); });
const tokens = installations.map(install => ({ const tokens = installations.map(install => ({
id: install.id, id: install.id,
token: install.token, token: install.token,
appId: install.appId, appId: install.appId,
installedAt: install.installedAt, installedAt: install.installedAt,
note: install.note, note: install.note,
name: install.note, // 备注同时作为名称返回 name: install.note, // 备注同时作为名称返回
})); }));
return res.json({ return res.json({
success: true, success: true,
tokens, tokens,
deviceUuid: uuid, deviceUuid: uuid,
}); });
}) })
); );
/** /**
@ -166,97 +165,97 @@ router.get(
* Body: { namespace: string, password: string, appId: string } * Body: { namespace: string, password: string, appId: string }
*/ */
router.post( router.post(
"/auth/token", "/auth/token",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { namespace, password, appId } = req.body; const {namespace, password, appId} = req.body;
if (!namespace) { if (!namespace) {
return next(errors.createError(400, "需要提供 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 (!matchedAutoAuth) { if (!appId) {
return next(errors.createError(401, "密码不正确")); return next(errors.createError(400, "需要提供 appId"));
} }
} else {
// 如果没有提供密码,查找密码为空的自动授权
matchedAutoAuth = device.autoAuths.find(auth => !auth.password);
if (!matchedAutoAuth) { // 通过 namespace 查找设备
return next(errors.createError(401, "需要提供密码")); const device = await prisma.device.findUnique({
} where: {namespace},
} include: {
autoAuths: true,
},
});
// 根据自动授权配置创建 AppInstall if (!device) {
const token = crypto.randomBytes(32).toString("hex"); return next(errors.createError(404, "设备不存在或 namespace 不正确"));
}
const installation = await prisma.appInstall.create({ // 查找匹配的自动授权配置
data: { let matchedAutoAuth = null;
deviceId: device.id,
appId: appId,
token,
note: null,
isReadOnly: matchedAutoAuth.isReadOnly,
deviceType: matchedAutoAuth.deviceType,
},
});
return res.status(201).json({ // 如果提供了密码,查找匹配密码的自动授权
success: true, if (password) {
token: installation.token, // 首先尝试直接匹配明文密码
deviceType: installation.deviceType, matchedAutoAuth = device.autoAuths.find(auth => auth.password === password);
isReadOnly: installation.isReadOnly,
installedAt: installation.installedAt, // 如果没有匹配到,尝试验证哈希密码(向后兼容)
}); 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 } * Body: { name: string }
*/ */
router.post( router.post(
"/tokens/:token/set-student-name", "/tokens/:token/set-student-name",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { token } = req.params; const {token} = req.params;
const { name } = req.body; const {name} = req.body;
if (!name) { if (!name) {
return next(errors.createError(400, "需要提供学生名称")); return next(errors.createError(400, "需要提供学生名称"));
} }
// 查找 token 对应的应用安装记录 // 查找 token 对应的应用安装记录
const appInstall = await prisma.appInstall.findUnique({ const appInstall = await prisma.appInstall.findUnique({
where: { token }, where: {token},
include: { include: {
device: true, device: true,
}, },
}); });
if (!appInstall) { if (!appInstall) {
return next(errors.createError(404, "Token 不存在")); return next(errors.createError(404, "Token 不存在"));
} }
// 验证 token 类型是否为 student // 验证 token 类型是否为 student
if (!['student','parent'].includes(appInstall.deviceType)) { if (!['student', 'parent'].includes(appInstall.deviceType)) {
return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称")); return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称"));
} }
// 读取设备的 classworks-list-main 键值 // 读取设备的 classworks-list-main 键值
const kvRecord = await prisma.kVStore.findUnique({ const kvRecord = await prisma.kVStore.findUnique({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: appInstall.deviceId, deviceId: appInstall.deviceId,
key: 'classworks-list-main', key: 'classworks-list-main',
}, },
}, },
}); });
if (!kvRecord) { if (!kvRecord) {
return next(errors.createError(404, "设备未设置学生列表")); return next(errors.createError(404, "设备未设置学生列表"));
} }
// 解析学生列表 // 解析学生列表
let studentList; let studentList;
try { try {
studentList = kvRecord.value; studentList = kvRecord.value;
if (!Array.isArray(studentList)) { if (!Array.isArray(studentList)) {
return next(errors.createError(500, "学生列表格式错误")); return next(errors.createError(500, "学生列表格式错误"));
} }
} catch (error) { } catch (error) {
return next(errors.createError(500, "无法解析学生列表")); return next(errors.createError(500, "无法解析学生列表"));
} }
// 验证名称是否在学生列表中 // 验证名称是否在学生列表中
const studentExists = studentList.some(student => student.name === name); const studentExists = studentList.some(student => student.name === name);
if (!studentExists) { if (!studentExists) {
return next(errors.createError(400, "该名称不在学生列表中")); return next(errors.createError(400, "该名称不在学生列表中"));
} }
// 更新 AppInstall 的 note 字段 // 更新 AppInstall 的 note 字段
const updatedInstall = await prisma.appInstall.update({ const updatedInstall = await prisma.appInstall.update({
where: { id: appInstall.id }, where: {id: appInstall.id},
data: { note: appInstall.deviceType === 'parent' ? `${name} 家长` : name }, data: {note: appInstall.deviceType === 'parent' ? `${name} 家长` : name},
}); });
return res.json({ return res.json({
success: true, success: true,
token: updatedInstall.token, token: updatedInstall.token,
name: updatedInstall.note, name: updatedInstall.note,
deviceType: updatedInstall.deviceType, deviceType: updatedInstall.deviceType,
updatedAt: updatedInstall.updatedAt, updatedAt: updatedInstall.updatedAt,
}); });
}) })
); );
/** /**
@ -345,33 +344,33 @@ router.post(
* Body: { note: string } * Body: { note: string }
*/ */
router.put( router.put(
"/tokens/:token/note", "/tokens/:token/note",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { token } = req.params; const {token} = req.params;
const { note } = req.body; const {note} = req.body;
// 查找 token 对应的应用安装记录 // 查找 token 对应的应用安装记录
const appInstall = await prisma.appInstall.findUnique({ const appInstall = await prisma.appInstall.findUnique({
where: { token }, where: {token},
}); });
if (!appInstall) { if (!appInstall) {
return next(errors.createError(404, "Token 不存在")); return next(errors.createError(404, "Token 不存在"));
} }
// 更新 AppInstall 的 note 字段 // 更新 AppInstall 的 note 字段
const updatedInstall = await prisma.appInstall.update({ const updatedInstall = await prisma.appInstall.update({
where: { id: appInstall.id }, where: {id: appInstall.id},
data: { note: note || null }, data: {note: note || null},
}); });
return res.json({ return res.json({
success: true, success: true,
token: updatedInstall.token, token: updatedInstall.token,
note: updatedInstall.note, note: updatedInstall.note,
updatedAt: updatedInstall.updatedAt, updatedAt: updatedInstall.updatedAt,
}); });
}) })
); );
export default router; export default router;

View File

@ -1,9 +1,10 @@
import { Router } from "express"; import {Router} from "express";
const router = Router(); import {jwtAuth} from "../middleware/jwt-auth.js";
import { jwtAuth } from "../middleware/jwt-auth.js"; import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
const router = Router();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
/** /**
@ -11,52 +12,52 @@ const prisma = new PrismaClient();
* 获取设备的所有自动授权配置 (需要 JWT 认证且设备必须绑定到该账户) * 获取设备的所有自动授权配置 (需要 JWT 认证且设备必须绑定到该账户)
*/ */
router.get( router.get(
"/devices/:uuid/auth-configs", "/devices/:uuid/auth-configs",
jwtAuth, jwtAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params; const {uuid} = req.params;
const account = res.locals.account; const account = res.locals.account;
// 查找设备并验证是否属于当前账户 // 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 验证设备是否绑定到当前账户 // 验证设备是否绑定到当前账户
if (!device.accountId || device.accountId !== account.id) { if (!device.accountId || device.accountId !== account.id) {
return next(errors.createError(403, "该设备未绑定到您的账户")); return next(errors.createError(403, "该设备未绑定到您的账户"));
} }
const autoAuths = await prisma.autoAuth.findMany({ const autoAuths = await prisma.autoAuth.findMany({
where: { deviceId: device.id }, where: {deviceId: device.id},
orderBy: { createdAt: 'desc' }, orderBy: {createdAt: 'desc'},
}); });
// 返回配置,智能处理密码显示 // 返回配置,智能处理密码显示
const configs = autoAuths.map(auth => { const configs = autoAuths.map(auth => {
// 检查是否是 bcrypt 哈希密码 // 检查是否是 bcrypt 哈希密码
const isHashedPassword = auth.password && auth.password.startsWith('$2'); const isHashedPassword = auth.password && auth.password.startsWith('$2');
return { return {
id: auth.id, id: auth.id,
password: isHashedPassword ? null : auth.password, // 哈希密码不返回 password: isHashedPassword ? null : auth.password, // 哈希密码不返回
isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码 isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码
deviceType: auth.deviceType, deviceType: auth.deviceType,
isReadOnly: auth.isReadOnly, isReadOnly: auth.isReadOnly,
createdAt: auth.createdAt, createdAt: auth.createdAt,
updatedAt: auth.updatedAt, updatedAt: auth.updatedAt,
}; };
}); });
return res.json({ return res.json({
success: true, success: true,
configs, configs,
}); });
}) })
); );
/** /**
@ -65,163 +66,164 @@ router.get(
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean } * Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
*/ */
router.post( router.post(
"/devices/:uuid/auth-configs", "/devices/:uuid/auth-configs",
jwtAuth, jwtAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params; const {uuid} = req.params;
const account = res.locals.account; const account = res.locals.account;
const { password, deviceType, isReadOnly } = req.body; const {password, deviceType, isReadOnly} = req.body;
// 查找设备并验证是否属于当前账户 // 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 验证设备是否绑定到当前账户 // 验证设备是否绑定到当前账户
if (!device.accountId || device.accountId !== account.id) { if (!device.accountId || device.accountId !== account.id) {
return next(errors.createError(403, "该设备未绑定到您的账户")); return next(errors.createError(403, "该设备未绑定到您的账户"));
} }
// 验证 deviceType 如果提供的话 // 验证 deviceType 如果提供的话
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
if (deviceType && !validDeviceTypes.includes(deviceType)) { if (deviceType && !validDeviceTypes.includes(deviceType)) {
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
} }
// 规范化密码:空字符串视为 null // 规范化密码:空字符串视为 null
const plainPassword = (password !== undefined && password !== '') ? password : null; const plainPassword = (password !== undefined && password !== '') ? password : null;
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码 // 查询该设备的所有自动授权配置,本地检查是否存在相同密码
const allAuths = await prisma.autoAuth.findMany({ const allAuths = await prisma.autoAuth.findMany({
where: { deviceId: device.id }, where: {deviceId: device.id},
}); });
const existingAuth = allAuths.find(auth => auth.password === plainPassword); const existingAuth = allAuths.find(auth => auth.password === plainPassword);
if (existingAuth) { if (existingAuth) {
return next(errors.createError(400, "该密码的自动授权配置已存在")); return next(errors.createError(400, "该密码的自动授权配置已存在"));
} }
// 创建新的自动授权配置(密码明文存储) // 创建新的自动授权配置(密码明文存储)
const autoAuth = await prisma.autoAuth.create({ const autoAuth = await prisma.autoAuth.create({
data: { data: {
deviceId: device.id, deviceId: device.id,
password: plainPassword, password: plainPassword,
deviceType: deviceType || null, deviceType: deviceType || null,
isReadOnly: isReadOnly || false, isReadOnly: isReadOnly || false,
}, },
}); });
return res.status(201).json({ return res.status(201).json({
success: true, success: true,
config: { config: {
id: autoAuth.id, id: autoAuth.id,
password: autoAuth.password, // 返回明文密码 password: autoAuth.password, // 返回明文密码
deviceType: autoAuth.deviceType, deviceType: autoAuth.deviceType,
isReadOnly: autoAuth.isReadOnly, isReadOnly: autoAuth.isReadOnly,
createdAt: autoAuth.createdAt, createdAt: autoAuth.createdAt,
}, },
}); });
}) })
);/** );
/**
* PUT /auto-auth/devices/:uuid/auth-configs/:configId * PUT /auto-auth/devices/:uuid/auth-configs/:configId
* 更新自动授权配置 (需要 JWT 认证且设备必须绑定到该账户) * 更新自动授权配置 (需要 JWT 认证且设备必须绑定到该账户)
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean } * Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
*/ */
router.put( router.put(
"/devices/:uuid/auth-configs/:configId", "/devices/:uuid/auth-configs/:configId",
jwtAuth, jwtAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid, configId } = req.params; const {uuid, configId} = req.params;
const account = res.locals.account; const account = res.locals.account;
const { password, deviceType, isReadOnly } = req.body; const {password, deviceType, isReadOnly} = req.body;
// 查找设备并验证是否属于当前账户 // 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 验证设备是否绑定到当前账户 // 验证设备是否绑定到当前账户
if (!device.accountId || device.accountId !== account.id) { if (!device.accountId || device.accountId !== account.id) {
return next(errors.createError(403, "该设备未绑定到您的账户")); return next(errors.createError(403, "该设备未绑定到您的账户"));
} }
// 查找自动授权配置 // 查找自动授权配置
const autoAuth = await prisma.autoAuth.findUnique({ const autoAuth = await prisma.autoAuth.findUnique({
where: { id: configId }, where: {id: configId},
}); });
if (!autoAuth) { if (!autoAuth) {
return next(errors.createError(404, "自动授权配置不存在")); return next(errors.createError(404, "自动授权配置不存在"));
} }
// 确保配置属于当前设备 // 确保配置属于当前设备
if (autoAuth.deviceId !== device.id) { if (autoAuth.deviceId !== device.id) {
return next(errors.createError(403, "无权操作此配置")); return next(errors.createError(403, "无权操作此配置"));
} }
// 验证 deviceType // 验证 deviceType
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
if (deviceType && !validDeviceTypes.includes(deviceType)) { if (deviceType && !validDeviceTypes.includes(deviceType)) {
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
} }
// 准备更新数据 // 准备更新数据
const updateData = {}; const updateData = {};
if (password !== undefined) { if (password !== undefined) {
// 规范化密码:空字符串视为 null // 规范化密码:空字符串视为 null
const plainPassword = (password !== '') ? password : null; const plainPassword = (password !== '') ? password : null;
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突 // 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
const allAuths = await prisma.autoAuth.findMany({ const allAuths = await prisma.autoAuth.findMany({
where: { deviceId: device.id }, where: {deviceId: device.id},
}); });
const conflictAuth = allAuths.find(auth => const conflictAuth = allAuths.find(auth =>
auth.id !== configId && auth.password === plainPassword auth.id !== configId && auth.password === plainPassword
); );
if (conflictAuth) { if (conflictAuth) {
return next(errors.createError(400, "该密码已被其他配置使用")); return next(errors.createError(400, "该密码已被其他配置使用"));
} }
updateData.password = plainPassword; updateData.password = plainPassword;
} }
if (deviceType !== undefined) { if (deviceType !== undefined) {
updateData.deviceType = deviceType || null; updateData.deviceType = deviceType || null;
} }
if (isReadOnly !== undefined) { if (isReadOnly !== undefined) {
updateData.isReadOnly = isReadOnly; updateData.isReadOnly = isReadOnly;
} }
// 更新配置 // 更新配置
const updatedAuth = await prisma.autoAuth.update({ const updatedAuth = await prisma.autoAuth.update({
where: { id: configId }, where: {id: configId},
data: updateData, data: updateData,
}); });
return res.json({ return res.json({
success: true, success: true,
config: { config: {
id: updatedAuth.id, id: updatedAuth.id,
password: updatedAuth.password, // 返回明文密码 password: updatedAuth.password, // 返回明文密码
deviceType: updatedAuth.deviceType, deviceType: updatedAuth.deviceType,
isReadOnly: updatedAuth.isReadOnly, isReadOnly: updatedAuth.isReadOnly,
updatedAt: updatedAuth.updatedAt, updatedAt: updatedAuth.updatedAt,
}, },
}); });
}) })
); );
/** /**
@ -229,47 +231,47 @@ router.put(
* 删除自动授权配置 (需要 JWT 认证且设备必须绑定到该账户) * 删除自动授权配置 (需要 JWT 认证且设备必须绑定到该账户)
*/ */
router.delete( router.delete(
"/devices/:uuid/auth-configs/:configId", "/devices/:uuid/auth-configs/:configId",
jwtAuth, jwtAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid, configId } = req.params; const {uuid, configId} = req.params;
const account = res.locals.account; const account = res.locals.account;
// 查找设备并验证是否属于当前账户 // 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 验证设备是否绑定到当前账户 // 验证设备是否绑定到当前账户
if (!device.accountId || device.accountId !== account.id) { if (!device.accountId || device.accountId !== account.id) {
return next(errors.createError(403, "该设备未绑定到您的账户")); return next(errors.createError(403, "该设备未绑定到您的账户"));
} }
// 查找自动授权配置 // 查找自动授权配置
const autoAuth = await prisma.autoAuth.findUnique({ const autoAuth = await prisma.autoAuth.findUnique({
where: { id: configId }, where: {id: configId},
}); });
if (!autoAuth) { if (!autoAuth) {
return next(errors.createError(404, "自动授权配置不存在")); return next(errors.createError(404, "自动授权配置不存在"));
} }
// 确保配置属于当前设备 // 确保配置属于当前设备
if (autoAuth.deviceId !== device.id) { if (autoAuth.deviceId !== device.id) {
return next(errors.createError(403, "无权操作此配置")); return next(errors.createError(403, "无权操作此配置"));
} }
// 删除配置 // 删除配置
await prisma.autoAuth.delete({ await prisma.autoAuth.delete({
where: { id: configId }, where: {id: configId},
}); });
return res.status(204).end(); return res.status(204).end();
}) })
); );
/** /**
@ -278,66 +280,66 @@ router.delete(
* Body: { namespace: string } * Body: { namespace: string }
*/ */
router.put( router.put(
"/devices/:uuid/namespace", "/devices/:uuid/namespace",
jwtAuth, jwtAuth,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params; const {uuid} = req.params;
const account = res.locals.account; const account = res.locals.account;
const { namespace } = req.body; const {namespace} = req.body;
if (!namespace) { if (!namespace) {
return next(errors.createError(400, "需要提供 namespace")); return next(errors.createError(400, "需要提供 namespace"));
} }
// 规范化 namespace去除首尾空格 // 规范化 namespace去除首尾空格
const trimmedNamespace = namespace.trim(); const trimmedNamespace = namespace.trim();
if (!trimmedNamespace) { if (!trimmedNamespace) {
return next(errors.createError(400, "namespace 不能为空")); return next(errors.createError(400, "namespace 不能为空"));
} }
// 查找设备并验证是否属于当前账户 // 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 验证设备是否绑定到当前账户 // 验证设备是否绑定到当前账户
if (!device.accountId || device.accountId !== account.id) { if (!device.accountId || device.accountId !== account.id) {
return next(errors.createError(403, "该设备未绑定到您的账户")); return next(errors.createError(403, "该设备未绑定到您的账户"));
} }
// 检查新的 namespace 是否已被其他设备使用 // 检查新的 namespace 是否已被其他设备使用
if (device.namespace !== trimmedNamespace) { if (device.namespace !== trimmedNamespace) {
const existingDevice = await prisma.device.findUnique({ const existingDevice = await prisma.device.findUnique({
where: { namespace: trimmedNamespace }, where: {namespace: trimmedNamespace},
}); });
if (existingDevice) { if (existingDevice) {
return next(errors.createError(409, "该 namespace 已被其他设备使用")); return next(errors.createError(409, "该 namespace 已被其他设备使用"));
} }
} }
// 更新设备的 namespace // 更新设备的 namespace
const updatedDevice = await prisma.device.update({ const updatedDevice = await prisma.device.update({
where: { id: device.id }, where: {id: device.id},
data: { namespace: trimmedNamespace }, data: {namespace: trimmedNamespace},
}); });
return res.json({ return res.json({
success: true, success: true,
device: { device: {
id: updatedDevice.id, id: updatedDevice.id,
uuid: updatedDevice.uuid, uuid: updatedDevice.uuid,
name: updatedDevice.name, name: updatedDevice.name,
namespace: updatedDevice.namespace, namespace: updatedDevice.namespace,
updatedAt: updatedDevice.updatedAt, updatedAt: updatedDevice.updatedAt,
}, },
}); });
}) })
); );
export default router; export default router;

View File

@ -1,13 +1,12 @@
import { Router } from "express"; import {Router} from "express";
import deviceCodeStore from "../utils/deviceCodeStore.js"; import deviceCodeStore from "../utils/deviceCodeStore.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
const router = Router(); const router = Router();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
/** /**
* POST /device/code * POST /device/code
* 生成设备授权码 * 生成设备授权码
@ -21,16 +20,16 @@ const prisma = new PrismaClient();
* } * }
*/ */
router.post( router.post(
"/device/code", "/device/code",
errors.catchAsync(async (req, res) => { errors.catchAsync(async (req, res) => {
const deviceCode = deviceCodeStore.create(); const deviceCode = deviceCodeStore.create();
return res.json({ return res.json({
device_code: deviceCode, device_code: deviceCode,
expires_in: 900, // 15分钟 expires_in: 900, // 15分钟
message: "请在前端输入此代码进行授权", message: "请在前端输入此代码进行授权",
}); });
}) })
); );
/** /**
@ -53,39 +52,39 @@ router.post(
* } * }
*/ */
router.post( router.post(
"/device/bind", "/device/bind",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { device_code, token } = req.body; const {device_code, token} = req.body;
if (!device_code || !token) { if (!device_code || !token) {
return next( return next(
errors.createError(400, "请提供 device_code 和 token") errors.createError(400, "请提供 device_code 和 token")
); );
} }
// 验证token是否有效检查数据库 // 验证token是否有效检查数据库
const appInstall = await prisma.appInstall.findUnique({ const appInstall = await prisma.appInstall.findUnique({
where: { token }, where: {token},
}); });
if (!appInstall) { if (!appInstall) {
return next(errors.createError(400, "无效的令牌")); return next(errors.createError(400, "无效的令牌"));
} }
// 绑定令牌到设备代码 // 绑定令牌到设备代码
const success = deviceCodeStore.bindToken(device_code, token); const success = deviceCodeStore.bindToken(device_code, token);
if (!success) { if (!success) {
return next( return next(
errors.createError(400, "设备代码不存在或已过期") errors.createError(400, "设备代码不存在或已过期")
); );
} }
return res.json({ return res.json({
success: true, success: true,
message: "令牌已成功绑定到设备代码", message: "令牌已成功绑定到设备代码",
}); });
}) })
); );
/** /**
@ -117,43 +116,43 @@ router.post(
* } * }
*/ */
router.get( router.get(
"/device/token", "/device/token",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { device_code } = req.query; const {device_code} = req.query;
if (!device_code) { if (!device_code) {
return next(errors.createError(400, "请提供 device_code")); return next(errors.createError(400, "请提供 device_code"));
} }
// 尝试获取并移除令牌 // 尝试获取并移除令牌
const token = deviceCodeStore.getAndRemove(device_code); const token = deviceCodeStore.getAndRemove(device_code);
if (token) { if (token) {
// 令牌已绑定,返回并删除 // 令牌已绑定,返回并删除
return res.json({ return res.json({
status: "success", status: "success",
token, token,
}); });
} }
// 检查设备代码是否存在 // 检查设备代码是否存在
const status = deviceCodeStore.getStatus(device_code); const status = deviceCodeStore.getStatus(device_code);
if (!status) { if (!status) {
// 设备代码不存在或已过期 // 设备代码不存在或已过期
return res.json({ return res.json({
status: "expired", status: "expired",
message: "设备代码不存在或已过期", message: "设备代码不存在或已过期",
}); });
} }
// 设备代码存在但令牌未绑定 // 设备代码存在但令牌未绑定
return res.json({ return res.json({
status: "pending", status: "pending",
message: "等待用户授权", message: "等待用户授权",
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000), expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
}); });
}) })
); );
/** /**
@ -172,32 +171,32 @@ router.get(
* } * }
*/ */
router.get( router.get(
"/device/status", "/device/status",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { device_code } = req.query; const {device_code} = req.query;
if (!device_code) { if (!device_code) {
return next(errors.createError(400, "请提供 device_code")); return next(errors.createError(400, "请提供 device_code"));
} }
const status = deviceCodeStore.getStatus(device_code); const status = deviceCodeStore.getStatus(device_code);
if (!status) { if (!status) {
return res.json({ return res.json({
device_code, device_code,
exists: false, exists: false,
message: "设备代码不存在或已过期", message: "设备代码不存在或已过期",
}); });
} }
return res.json({ return res.json({
device_code, device_code,
exists: true, exists: true,
has_token: status.hasToken, has_token: status.hasToken,
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000), expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
created_at: status.createdAt, created_at: status.createdAt,
}); });
}) })
); );
export default router; export default router;

View File

@ -1,12 +1,11 @@
import { Router } from "express"; import {Router} from "express";
const router = Router(); import {extractDeviceInfo} from "../middleware/uuidAuth.js";
import { uuidAuth, extractDeviceInfo } from "../middleware/uuidAuth.js"; import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js"; import {getOnlineDevices} from "../utils/socket.js";
import { getOnlineDevices } from "../utils/socket.js"; import {registeredDevicesTotal} from "../utils/metrics.js";
import { registeredDevicesTotal } from "../utils/metrics.js";
const router = Router();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -15,20 +14,20 @@ const prisma = new PrismaClient();
* @param {number} deviceId - 设备ID * @param {number} deviceId - 设备ID
*/ */
async function createDefaultAutoAuth(deviceId) { async function createDefaultAutoAuth(deviceId) {
try { try {
// 创建默认的自动授权配置不需要密码、类型是classroom一体机 // 创建默认的自动授权配置不需要密码、类型是classroom一体机
await prisma.autoAuth.create({ await prisma.autoAuth.create({
data: { data: {
deviceId: deviceId, deviceId: deviceId,
password: null, // 无密码 password: null, // 无密码
deviceType: "classroom", // 一体机类型 deviceType: "classroom", // 一体机类型
isReadOnly: false, // 非只读 isReadOnly: false, // 非只读
}, },
}); });
} catch (error) { } catch (error) {
console.error('创建默认自动登录配置失败:', error); console.error('创建默认自动登录配置失败:', error);
// 这里不抛出错误,避免影响设备创建流程 // 这里不抛出错误,避免影响设备创建流程
} }
} }
/** /**
@ -36,70 +35,70 @@ async function createDefaultAutoAuth(deviceId) {
* 注册新设备 * 注册新设备
*/ */
router.post( router.post(
"/", "/",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid, deviceName, namespace } = req.body; const {uuid, deviceName, namespace} = req.body;
if (!uuid) { if (!uuid) {
return next(errors.createError(400, "设备UUID是必需的")); return next(errors.createError(400, "设备UUID是必需的"));
} }
if (!deviceName) { if (!deviceName) {
return next(errors.createError(400, "设备名称是必需的")); return next(errors.createError(400, "设备名称是必需的"));
} }
try { try {
// 检查UUID是否已存在 // 检查UUID是否已存在
const existingDevice = await prisma.device.findUnique({ const existingDevice = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
}); });
if (existingDevice) { if (existingDevice) {
return next(errors.createError(409, "设备UUID已存在")); return next(errors.createError(409, "设备UUID已存在"));
} }
// 处理 namespace如果没有提供则使用 uuid // 处理 namespace如果没有提供则使用 uuid
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid; const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
// 检查 namespace 是否已被使用 // 检查 namespace 是否已被使用
const existingNamespace = await prisma.device.findUnique({ const existingNamespace = await prisma.device.findUnique({
where: { namespace: deviceNamespace }, where: {namespace: deviceNamespace},
}); });
if (existingNamespace) { if (existingNamespace) {
return next(errors.createError(409, "该 namespace 已被使用")); return next(errors.createError(409, "该 namespace 已被使用"));
} }
// 创建设备 // 创建设备
const device = await prisma.device.create({ const device = await prisma.device.create({
data: { data: {
uuid, uuid,
name: deviceName, name: deviceName,
namespace: deviceNamespace, namespace: deviceNamespace,
}, },
}); });
// 为新设备创建默认的自动登录配置 // 为新设备创建默认的自动登录配置
await createDefaultAutoAuth(device.id); await createDefaultAutoAuth(device.id);
// 更新注册设备总数指标 // 更新注册设备总数指标
const totalDevices = await prisma.device.count(); const totalDevices = await prisma.device.count();
registeredDevicesTotal.set(totalDevices); registeredDevicesTotal.set(totalDevices);
return res.status(201).json({ return res.status(201).json({
success: true, success: true,
device: { device: {
id: device.id, id: device.id,
uuid: device.uuid, uuid: device.uuid,
name: device.name, name: device.name,
namespace: device.namespace, namespace: device.namespace,
createdAt: device.createdAt, createdAt: device.createdAt,
}, },
}); });
} catch (error) { } catch (error) {
throw error; throw error;
} }
}) })
); );
/** /**
@ -107,111 +106,111 @@ router.post(
* 获取设备信息 (公开接口无需认证) * 获取设备信息 (公开接口无需认证)
*/ */
router.get( router.get(
"/:uuid", "/:uuid",
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params; const {uuid} = req.params;
// 查找设备,包含绑定的账户信息 // 查找设备,包含绑定的账户信息
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { uuid }, where: {uuid},
include: { include: {
account: { account: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
avatarUrl: true, avatarUrl: true,
}, },
}, },
}, },
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
return res.json({ return res.json({
id: device.id, id: device.id,
uuid: device.uuid, uuid: device.uuid,
name: device.name, name: device.name,
hasPassword: !!device.password, hasPassword: !!device.password,
passwordHint: device.passwordHint, passwordHint: device.passwordHint,
createdAt: device.createdAt, createdAt: device.createdAt,
account: device.account ? { account: device.account ? {
id: device.account.id, id: device.account.id,
name: device.account.name, name: device.account.name,
email: device.account.email, email: device.account.email,
avatarUrl: device.account.avatarUrl, avatarUrl: device.account.avatarUrl,
} : null, } : null,
isBoundToAccount: !!device.account, isBoundToAccount: !!device.account,
namespace: device.namespace, namespace: device.namespace,
}); });
}) })
);/** );
/**
* PUT /devices/:uuid/name * PUT /devices/:uuid/name
* 设置设备名称 (需要UUID认证) * 设置设备名称 (需要UUID认证)
*/ */
router.put( router.put(
"/:uuid/name", "/:uuid/name",
extractDeviceInfo, extractDeviceInfo,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { name } = req.body; const {name} = req.body;
const device = res.locals.device; const device = res.locals.device;
if (!name) { if (!name) {
return next(errors.createError(400, "设备名称是必需的")); return next(errors.createError(400, "设备名称是必需的"));
} }
const updatedDevice = await prisma.device.update({ const updatedDevice = await prisma.device.update({
where: { id: device.id }, where: {id: device.id},
data: { name }, data: {name},
}); });
return res.json({ return res.json({
success: true, success: true,
device: { device: {
id: updatedDevice.id, id: updatedDevice.id,
uuid: updatedDevice.uuid, uuid: updatedDevice.uuid,
name: updatedDevice.name, name: updatedDevice.name,
hasPassword: !!updatedDevice.password, hasPassword: !!updatedDevice.password,
passwordHint: updatedDevice.passwordHint, passwordHint: updatedDevice.passwordHint,
}, },
}); });
}) })
); );
/** /**
* GET /devices/online * GET /devices/online
* 查询在线设备WebSocket 已连接 * 查询在线设备WebSocket 已连接
* 返回[{ uuid, connections, name? }] * 返回[{ uuid, connections, name? }]
*/ */
router.get( router.get(
"/online", "/online",
errors.catchAsync(async (req, res) => { errors.catchAsync(async (req, res) => {
const list = getOnlineDevices(); const list = getOnlineDevices();
if (list.length === 0) { if (list.length === 0) {
return res.json({ success: true, devices: [] }); return res.json({success: true, devices: []});
} }
// 补充设备名称 // 补充设备名称
const uuids = list.map((x) => x.uuid); const uuids = list.map((x) => x.uuid);
const rows = await prisma.device.findMany({ const rows = await prisma.device.findMany({
where: { uuid: { in: uuids } }, where: {uuid: {in: uuids}},
select: { uuid: true, name: true }, select: {uuid: true, name: true},
}); });
const nameMap = new Map(rows.map((r) => [r.uuid, r.name])); const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
const devices = list.map((x) => ({ const devices = list.map((x) => ({
uuid: x.uuid, uuid: x.uuid,
connections: x.connections, connections: x.connections,
name: nameMap.get(x.uuid) || null, name: nameMap.get(x.uuid) || null,
})); }));
res.json({ success: true, devices }); res.json({success: true, devices});
}) })
); );
export default router; export default router;

View File

@ -1,9 +1,10 @@
import { Router } from "express"; import {Router} from "express";
var router = Router(); var router = Router();
/* GET home page. */ /* GET home page. */
router.get("/", function (req, res, next) { router.get("/", function (req, res, next) {
res.render("index"); res.render("index");
}); });
export default router; export default router;

View File

@ -1,17 +1,18 @@
import { Router } from "express"; import {Router} from "express";
const router = Router();
import kvStore from "../utils/kvStore.js"; import kvStore from "../utils/kvStore.js";
import { broadcastKeyChanged } from "../utils/socket.js"; import {broadcastKeyChanged} from "../utils/socket.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; import {kvTokenAuth} from "../middleware/kvTokenAuth.js";
import { import {
tokenReadLimiter, prepareTokenForRateLimit,
tokenWriteLimiter, tokenBatchLimiter,
tokenDeleteLimiter, tokenDeleteLimiter,
tokenBatchLimiter, tokenReadLimiter,
prepareTokenForRateLimit tokenWriteLimiter
} from "../middleware/rateLimiter.js"; } from "../middleware/rateLimiter.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
const router = Router();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -26,52 +27,52 @@ router.use(prepareTokenForRateLimit);
* 获取当前token所属设备的信息如果关联了账号也返回账号信息 * 获取当前token所属设备的信息如果关联了账号也返回账号信息
*/ */
router.get( router.get(
"/_info", "/_info",
tokenReadLimiter, tokenReadLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
// 获取设备信息,包含关联的账号 // 获取设备信息,包含关联的账号
const device = await prisma.device.findUnique({ const device = await prisma.device.findUnique({
where: { id: deviceId }, where: {id: deviceId},
include: { include: {
account: true, account: true,
}, },
}); });
if (!device) { if (!device) {
return next(errors.createError(404, "设备不存在")); return next(errors.createError(404, "设备不存在"));
} }
// 构建响应对象:当设备没有关联账号时返回 uuid若已关联账号则不返回 uuid // 构建响应对象:当设备没有关联账号时返回 uuid若已关联账号则不返回 uuid
const response = { const response = {
device: { device: {
id: device.id, id: device.id,
name: device.name, name: device.name,
createdAt: device.createdAt, createdAt: device.createdAt,
updatedAt: device.updatedAt, updatedAt: device.updatedAt,
}, },
}; };
// 仅当设备未绑定账号时,包含 uuid 字段 // 仅当设备未绑定账号时,包含 uuid 字段
if (!device.account) { if (!device.account) {
response.device.uuid = device.uuid; response.device.uuid = device.uuid;
} }
// 标识是否已绑定账号 // 标识是否已绑定账号
response.hasAccount = !!device.account; response.hasAccount = !!device.account;
// 如果关联了账号,添加账号信息 // 如果关联了账号,添加账号信息
if (device.account) { if (device.account) {
response.account = { response.account = {
id: device.account.id, id: device.account.id,
name: device.account.name, name: device.account.name,
avatarUrl: device.account.avatarUrl, avatarUrl: device.account.avatarUrl,
}; };
} }
return res.json(response); return res.json(response);
}) })
); );
/** /**
@ -79,48 +80,48 @@ router.get(
* 获取当前 KV Token 的详细信息类型备注等 * 获取当前 KV Token 的详细信息类型备注等
*/ */
router.get( router.get(
"/_token", "/_token",
tokenReadLimiter, tokenReadLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const token = res.locals.token; const token = res.locals.token;
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
// 查找当前 token 对应的应用安装记录 // 查找当前 token 对应的应用安装记录
const appInstall = await prisma.appInstall.findUnique({ const appInstall = await prisma.appInstall.findUnique({
where: { token }, where: {token},
include: { include: {
device: { device: {
select: { select: {
id: true, id: true,
uuid: true, uuid: true,
name: true, name: true,
namespace: true, namespace: true,
}, },
}, },
}, },
}); });
if (!appInstall) { if (!appInstall) {
return next(errors.createError(404, "Token 信息不存在")); return next(errors.createError(404, "Token 信息不存在"));
} }
return res.json({ return res.json({
success: true, success: true,
token: appInstall.token, token: appInstall.token,
appId: appInstall.appId, appId: appInstall.appId,
deviceType: appInstall.deviceType, deviceType: appInstall.deviceType,
isReadOnly: appInstall.isReadOnly, isReadOnly: appInstall.isReadOnly,
note: appInstall.note, note: appInstall.note,
installedAt: appInstall.installedAt, installedAt: appInstall.installedAt,
updatedAt: appInstall.updatedAt, updatedAt: appInstall.updatedAt,
device: { device: {
id: appInstall.device.id, id: appInstall.device.id,
uuid: appInstall.device.uuid, uuid: appInstall.device.uuid,
name: appInstall.device.name, name: appInstall.device.name,
namespace: appInstall.device.namespace, namespace: appInstall.device.namespace,
}, },
}); });
}) })
); );
/** /**
@ -128,50 +129,50 @@ router.get(
* 获取当前token对应设备的键名列表分页不包括内容 * 获取当前token对应设备的键名列表分页不包括内容
*/ */
router.get( router.get(
"/_keys", "/_keys",
tokenReadLimiter, tokenReadLimiter,
errors.catchAsync(async (req, res) => { errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query; const {sortBy, sortDir, limit, skip} = req.query;
// 构建选项 // 构建选项
const options = { const options = {
sortBy: sortBy || "key", sortBy: sortBy || "key",
sortDir: sortDir || "asc", sortDir: sortDir || "asc",
limit: limit ? parseInt(limit) : 100, limit: limit ? parseInt(limit) : 100,
skip: skip ? parseInt(skip) : 0, skip: skip ? parseInt(skip) : 0,
}; };
const keys = await kvStore.listKeysOnly(deviceId, options); const keys = await kvStore.listKeysOnly(deviceId, options);
const totalRows = keys.length; const totalRows = keys.length;
// 构建响应对象 // 构建响应对象
const response = { const response = {
keys: keys, keys: keys,
total_rows: totalRows, total_rows: totalRows,
current_page: { current_page: {
limit: options.limit, limit: options.limit,
skip: options.skip, skip: options.skip,
count: keys.length, count: keys.length,
}, },
}; };
// 如果还有更多数据添加load_more字段 // 如果还有更多数据添加load_more字段
const nextSkip = options.skip + options.limit; const nextSkip = options.skip + options.limit;
if (nextSkip < totalRows) { if (nextSkip < totalRows) {
const baseUrl = `${req.baseUrl}/_keys`; const baseUrl = `${req.baseUrl}/_keys`;
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
sortBy: options.sortBy, sortBy: options.sortBy,
sortDir: options.sortDir, sortDir: options.sortDir,
limit: options.limit, limit: options.limit,
skip: nextSkip, skip: nextSkip,
}).toString(); }).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对应设备的所有键名及元数据列表 * 获取当前token对应设备的所有键名及元数据列表
*/ */
router.get( router.get(
"/", "/",
tokenReadLimiter, tokenReadLimiter,
errors.catchAsync(async (req, res) => { errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query; const {sortBy, sortDir, limit, skip} = req.query;
// 构建选项 // 构建选项
const options = { const options = {
sortBy: sortBy || "key", sortBy: sortBy || "key",
sortDir: sortDir || "asc", sortDir: sortDir || "asc",
limit: limit ? parseInt(limit) : 100, limit: limit ? parseInt(limit) : 100,
skip: skip ? parseInt(skip) : 0, skip: skip ? parseInt(skip) : 0,
}; };
const keys = await kvStore.list(deviceId, options); const keys = await kvStore.list(deviceId, options);
const totalRows = await kvStore.count(deviceId); const totalRows = await kvStore.count(deviceId);
// 构建响应对象 // 构建响应对象
const response = { const response = {
items: keys, items: keys,
total_rows: totalRows, total_rows: totalRows,
}; };
// 如果还有更多数据添加load_more字段 // 如果还有更多数据添加load_more字段
const nextSkip = options.skip + options.limit; const nextSkip = options.skip + options.limit;
if (nextSkip < totalRows) { if (nextSkip < totalRows) {
const baseUrl = `${req.baseUrl}`; const baseUrl = `${req.baseUrl}`;
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
sortBy: options.sortBy, sortBy: options.sortBy,
sortDir: options.sortDir, sortDir: options.sortDir,
limit: options.limit, limit: options.limit,
skip: nextSkip, skip: nextSkip,
}).toString(); }).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( router.get(
"/:key", "/:key",
tokenReadLimiter, tokenReadLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
const { key } = req.params; const {key} = req.params;
const value = await kvStore.get(deviceId, key); const value = await kvStore.get(deviceId, key);
if (value === null) { if (value === null) {
return next( return next(
errors.createError(404, `未找到键名为 '${key}' 的记录`) errors.createError(404, `未找到键名为 '${key}' 的记录`)
); );
} }
return res.json(value); return res.json(value);
}) })
); );
/** /**
@ -248,20 +249,20 @@ router.get(
* 获取键的元数据 * 获取键的元数据
*/ */
router.get( router.get(
"/:key/metadata", "/:key/metadata",
tokenReadLimiter, tokenReadLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
const { key } = req.params; const {key} = req.params;
const metadata = await kvStore.getMetadata(deviceId, key); const metadata = await kvStore.getMetadata(deviceId, key);
if (!metadata) { if (!metadata) {
return next( return next(
errors.createError(404, `未找到键名为 '${key}' 的记录`) errors.createError(404, `未找到键名为 '${key}' 的记录`)
); );
} }
return res.json(metadata); return res.json(metadata);
}) })
); );
/** /**
@ -269,73 +270,73 @@ router.get(
* 批量导入键值对 * 批量导入键值对
*/ */
router.post( router.post(
"/_batchimport", "/_batchimport",
tokenBatchLimiter, tokenBatchLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读 // 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) { if (res.locals.appInstall?.isReadOnly) {
return next(errors.createError(403, "当前token为只读模式,无法修改数据")); 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,
});
} }
} catch (error) {
errorList.push({
key,
error: error.message,
});
}
}
return res.status(200).json({ const deviceId = res.locals.deviceId;
deviceId, const data = req.body;
total: Object.keys(data).length,
successful: results.length, if (!data || Object.keys(data).length === 0) {
failed: errorList.length, return next(
results, errors.createError(
errors: errorList.length > 0 ? errorList : undefined, 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( router.post(
"/:key", "/:key",
tokenWriteLimiter, tokenWriteLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读 // 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) { if (res.locals.appInstall?.isReadOnly) {
return next(errors.createError(403, "当前token为只读模式,无法修改数据")); return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
} }
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
const { key } = req.params; const {key} = req.params;
const value = req.body; const value = req.body;
if (!value || Object.keys(value).length === 0) { if (!value || Object.keys(value).length === 0) {
return next(errors.createError(400, "请提供有效的JSON值")); return next(errors.createError(400, "请提供有效的JSON值"));
} }
// 获取客户端IP // 获取客户端IP
const creatorIp = const creatorIp =
req.headers["x-forwarded-for"] || req.headers["x-forwarded-for"] ||
req.connection.remoteAddress || req.connection.remoteAddress ||
req.socket.remoteAddress || req.socket.remoteAddress ||
req.connection.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; const uuid = res.locals.device?.uuid;
if (uuid) { if (uuid) {
broadcastKeyChanged(uuid, { broadcastKeyChanged(uuid, {
key: result.key, key: result.key,
action: "upsert", action: "upsert",
created: result.createdAt.getTime() === result.updatedAt.getTime(), created: result.createdAt.getTime() === result.updatedAt.getTime(),
updatedAt: result.updatedAt, updatedAt: result.updatedAt,
}); });
} }
return res.status(200).json({ return res.status(200).json({
deviceId: result.deviceId, deviceId: result.deviceId,
key: result.key, key: result.key,
created: result.createdAt.getTime() === result.updatedAt.getTime(), created: result.createdAt.getTime() === result.updatedAt.getTime(),
updatedAt: result.updatedAt, updatedAt: result.updatedAt,
}); });
}) })
); );
/** /**
@ -394,38 +395,38 @@ router.post(
* 删除键值对 * 删除键值对
*/ */
router.delete( router.delete(
"/:key", "/:key",
tokenDeleteLimiter, tokenDeleteLimiter,
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读 // 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) { if (res.locals.appInstall?.isReadOnly) {
return next(errors.createError(403, "当前token为只读模式,无法修改数据")); return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
} }
const deviceId = res.locals.deviceId; const deviceId = res.locals.deviceId;
const { key } = req.params; const {key} = req.params;
const result = await kvStore.delete(deviceId, key); const result = await kvStore.delete(deviceId, key);
if (!result) { if (!result) {
return next( return next(
errors.createError(404, `未找到键名为 '${key}' 的记录`) errors.createError(404, `未找到键名为 '${key}' 的记录`)
); );
} }
// 广播删除 // 广播删除
const uuid = res.locals.device?.uuid; const uuid = res.locals.device?.uuid;
if (uuid) { if (uuid) {
broadcastKeyChanged(uuid, { broadcastKeyChanged(uuid, {
key, key,
action: "delete", action: "delete",
deletedAt: new Date(), deletedAt: new Date(),
}); });
} }
// 204状态码表示成功但无内容返回 // 204状态码表示成功但无内容返回
return res.status(204).end(); return res.status(204).end();
}) })
); );
export default router; export default router;

View File

@ -1,4 +1,5 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
export const siteKey = process.env.SITE_KEY || ""; export const siteKey = process.env.SITE_KEY || "";

View File

@ -1,5 +1,5 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { Base64 } from "js-base64"; import {Base64} from "js-base64";
const SALT_ROUNDS = 8; const SALT_ROUNDS = 8;
@ -7,37 +7,37 @@ const SALT_ROUNDS = 8;
* base64 解码字符串 * base64 解码字符串
*/ */
export function decodeBase64(str) { export function decodeBase64(str) {
if (!str) return null; if (!str) return null;
try { try {
return Base64.decode(str); return Base64.decode(str);
} catch (error) { } catch (error) {
return null; return null;
} }
} }
/** /**
* 对字符串进行 UTF-8 编码处理 * 对字符串进行 UTF-8 编码处理
*/ */
function encodeUTF8(str) { function encodeUTF8(str) {
try { try {
return encodeURIComponent(str); return encodeURIComponent(str);
} catch (error) { } catch (error) {
return null; return null;
} }
} }
/** /**
* 验证站点密钥 * 验证站点密钥
*/ */
export function verifySiteKey(providedKey, actualKey) { export function verifySiteKey(providedKey, actualKey) {
if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过 if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过
if (!providedKey) return false; if (!providedKey) return false;
const decodedKey = decodeBase64(providedKey); const decodedKey = decodeBase64(providedKey);
if (!decodedKey) return false; if (!decodedKey) return false;
const encodedKey = encodeUTF8(decodedKey); const encodedKey = encodeUTF8(decodedKey);
if (!encodedKey) return false; if (!encodedKey) return false;
console.debug(encodedKey); console.debug(encodedKey);
return encodedKey === actualKey; return encodedKey === actualKey;
} }
/** /**
@ -46,8 +46,8 @@ export function verifySiteKey(providedKey, actualKey) {
* @returns {Promise<string>} 哈希后的密码 * @returns {Promise<string>} 哈希后的密码
*/ */
export async function hashPassword(password) { export async function hashPassword(password) {
if (!password) return null; if (!password) return null;
return await bcrypt.hash(password, SALT_ROUNDS); return await bcrypt.hash(password, SALT_ROUNDS);
} }
/** /**
@ -57,11 +57,11 @@ export async function hashPassword(password) {
* @returns {Promise<boolean>} 密码是否匹配 * @returns {Promise<boolean>} 密码是否匹配
*/ */
export async function verifyDevicePassword(providedPassword, hashedPassword) { export async function verifyDevicePassword(providedPassword, hashedPassword) {
if (!providedPassword || !hashedPassword) return false; if (!providedPassword || !hashedPassword) return false;
try { try {
return await bcrypt.compare(providedPassword, hashedPassword); return await bcrypt.compare(providedPassword, hashedPassword);
} catch (error) { } catch (error) {
console.error('密码验证错误:', error); console.error('密码验证错误:', error);
return false; return false;
} }
} }

View File

@ -6,182 +6,182 @@
*/ */
class DeviceCodeStore { class DeviceCodeStore {
constructor() { constructor() {
// 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } } // 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } }
this.store = new Map(); this.store = new Map();
// 默认过期时间: 15分钟 // 默认过期时间: 15分钟
this.expirationTime = 15 * 60 * 1000; this.expirationTime = 15 * 60 * 1000;
// 定期清理过期数据 (每5分钟) // 定期清理过期数据 (每5分钟)
this.cleanupInterval = setInterval(() => { this.cleanupInterval = setInterval(() => {
this.cleanup(); this.cleanup();
}, 5 * 60 * 1000); }, 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;
} }
// 检查是否过期 /**
if (Date.now() > entry.expiresAt) { * 生成设备代码 (格式: 1234-ABCD)
this.store.delete(deviceCode); */
return false; 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;
/** // 确保生成的代码不重复
* 获取设备代码对应的令牌获取后删除 do {
* @param {string} deviceCode - 设备代码 deviceCode = this.generateDeviceCode();
* @returns {string|null} 令牌如果不存在或未绑定返回null } while (this.store.has(deviceCode));
*/
getAndRemove(deviceCode) {
const entry = this.store.get(deviceCode);
if (!entry) { const now = Date.now();
return null; this.store.set(deviceCode, {
token: null,
expiresAt: now + this.expirationTime,
createdAt: now,
});
return deviceCode;
} }
// 检查是否过期 /**
if (Date.now() > entry.expiresAt) { * 绑定令牌到设备代码
this.store.delete(deviceCode); * @param {string} deviceCode - 设备代码
return null; * @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);
// 获取令牌后删除条目 if (!entry) {
const token = entry.token; return null;
this.store.delete(deviceCode); }
return token;
}
/** // 检查是否过期
* 检查设备代码是否存在且未过期 if (Date.now() > entry.expiresAt) {
* @param {string} deviceCode - 设备代码 this.store.delete(deviceCode);
* @returns {boolean} return null;
*/ }
exists(deviceCode) {
const entry = this.store.get(deviceCode);
if (!entry) { // 如果令牌未绑定返回null但不删除代码
return false; if (!entry.token) {
} return null;
}
if (Date.now() > entry.expiresAt) { // 获取令牌后删除条目
this.store.delete(deviceCode); const token = entry.token;
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); 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);
/** if (!entry) {
* 获取当前存储的条目数量 return false;
*/ }
size() {
return this.store.size;
}
/** if (Date.now() > entry.expiresAt) {
* 清理定时器用于优雅关闭 this.store.delete(deviceCode);
*/ return false;
destroy() { }
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval); 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', () => { process.on('SIGTERM', () => {
deviceCodeStore.destroy(); deviceCodeStore.destroy();
}); });
process.on('SIGINT', () => { process.on('SIGINT', () => {
deviceCodeStore.destroy(); deviceCodeStore.destroy();
}); });
export default deviceCodeStore; export default deviceCodeStore;

View File

@ -7,14 +7,14 @@
* @returns {object} 标准错误对象 * @returns {object} 标准错误对象
*/ */
const createError = (statusCode, message, details = null, code = null) => { const createError = (statusCode, message, details = null, code = null) => {
// 直接返回错误对象,不抛出异常 // 直接返回错误对象,不抛出异常
const error = { const error = {
statusCode: statusCode, statusCode: statusCode,
message: message || '服务器错误', message: message || '服务器错误',
details: details, details: details,
code: code || undefined, code: code || undefined,
}; };
return error; return error;
}; };
/** /**
@ -24,11 +24,11 @@ const createError = (statusCode, message, details = null, code = null) => {
* @returns {object} 格式化的成功响应 * @returns {object} 格式化的成功响应
*/ */
const createSuccessResponse = (data, message = null) => { const createSuccessResponse = (data, message = null) => {
return { return {
success: true, success: true,
message, message,
data, data,
}; };
}; };
/** /**
@ -37,20 +37,20 @@ const createSuccessResponse = (data, message = null) => {
* @param {Function} next - Express中间件next函数 * @param {Function} next - Express中间件next函数
*/ */
const passError = (error, next) => { const passError = (error, next) => {
// 不管是什么类型的错误,统一转换并传递 // 不管是什么类型的错误,统一转换并传递
if (error instanceof Error) { if (error instanceof Error) {
// 如果是标准Error则转换为HTTP错误并保留原始信息 // 如果是标准Error则转换为HTTP错误并保留原始信息
const httpError = { const httpError = {
statusCode: error.statusCode || 500, statusCode: error.statusCode || 500,
message: error.message || '服务器错误', message: error.message || '服务器错误',
details: error.details || null, details: error.details || null,
originalError: error originalError: error
}; };
next(httpError); next(httpError);
} else { } else {
// 已经是自定义错误对象结构,直接传递 // 已经是自定义错误对象结构,直接传递
next(error); next(error);
} }
}; };
/** /**
@ -59,11 +59,11 @@ const passError = (error, next) => {
* @returns {Function} 包装后的函数 * @returns {Function} 包装后的函数
*/ */
const catchAsync = (fn) => { const catchAsync = (fn) => {
return (req, res, next) => { return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(error => { Promise.resolve(fn(req, res, next)).catch(error => {
passError(error, next); passError(error, next);
}); });
}; };
}; };
/** /**
@ -73,31 +73,31 @@ const catchAsync = (fn) => {
* @returns {[error, result]} 包含错误和结果的数组 * @returns {[error, result]} 包含错误和结果的数组
*/ */
const trySafe = (fn, ...args) => { const trySafe = (fn, ...args) => {
try { try {
const result = fn(...args); const result = fn(...args);
return [null, result]; return [null, result];
} catch (error) { } catch (error) {
return [error, null]; return [error, null];
} }
}; };
// 常用状态码 // 常用状态码
const HTTP_STATUS = { const HTTP_STATUS = {
OK: 200, OK: 200,
CREATED: 201, CREATED: 201,
NO_CONTENT: 204, NO_CONTENT: 204,
BAD_REQUEST: 400, BAD_REQUEST: 400,
UNAUTHORIZED: 401, UNAUTHORIZED: 401,
FORBIDDEN: 403, FORBIDDEN: 403,
NOT_FOUND: 404, NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500, INTERNAL_SERVER_ERROR: 500,
}; };
export default { export default {
createError, createError,
createSuccessResponse, createSuccessResponse,
passError, passError,
catchAsync, catchAsync,
trySafe, trySafe,
HTTP_STATUS, HTTP_STATUS,
}; };

View File

@ -1,42 +1,43 @@
import "dotenv/config"; import "dotenv/config";
import { NodeSDK } from "@opentelemetry/sdk-node"; import {NodeSDK} from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; import {getNodeAutoInstrumentations} from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import {OTLPTraceExporter} from "@opentelemetry/exporter-trace-otlp-proto";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import {BatchSpanProcessor} from "@opentelemetry/sdk-trace-base";
import { resourceFromAttributes } from "@opentelemetry/resources"; import {resourceFromAttributes} from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; import {SemanticResourceAttributes} from "@opentelemetry/semantic-conventions";
if (process.env.AXIOM_TOKEN && process.env.AXIOM_DATASET) { 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
// Initialize OTLP trace exporter with the endpoint URL and headers // Initialize OTLP trace exporter with the endpoint URL and headers
const traceExporter = new OTLPTraceExporter({ const traceExporter = new OTLPTraceExporter({
url: "https://api.axiom.co/v1/traces", url: "https://api.axiom.co/v1/traces",
headers: { headers: {
Authorization: `Bearer ${process.env.AXIOM_TOKEN}`, Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
"X-Axiom-Dataset": process.env.AXIOM_DATASET, "X-Axiom-Dataset": process.env.AXIOM_DATASET,
}, },
}); });
const resourceAttributes = { const resourceAttributes = {
[SemanticResourceAttributes.SERVICE_NAME]: "node traces", [SemanticResourceAttributes.SERVICE_NAME]: "node traces",
}; };
const resource = resourceFromAttributes(resourceAttributes); const resource = resourceFromAttributes(resourceAttributes);
// Configuring the OpenTelemetry Node SDK // Configuring the OpenTelemetry Node SDK
const sdk = new NodeSDK({ const sdk = new NodeSDK({
// Adding a BatchSpanProcessor to batch and send traces // Adding a BatchSpanProcessor to batch and send traces
spanProcessor: new BatchSpanProcessor(traceExporter), spanProcessor: new BatchSpanProcessor(traceExporter),
// Registering the resource to the SDK // Registering the resource to the SDK
resource: resource, resource: resource,
// Adding auto-instrumentations to automatically collect trace data // Adding auto-instrumentations to automatically collect trace data
instrumentations: [getNodeAutoInstrumentations()], instrumentations: [getNodeAutoInstrumentations()],
}); });
console.log("✅成功加载 Axiom 遥测"); console.log("✅成功加载 Axiom 遥测");
// Starting the OpenTelemetry SDK to begin collecting telemetry data // Starting the OpenTelemetry SDK to begin collecting telemetry data
sdk.start(); sdk.start();
} else { } else {
console.log("❌未设置 Axiom 遥测"); console.log("❌未设置 Axiom 遥测");
} }

View File

@ -1,11 +1,11 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { import {
generateAccessToken, generateAccessToken,
verifyAccessToken, generateTokenPair,
generateTokenPair, refreshAccessToken,
refreshAccessToken, revokeAllTokens,
revokeAllTokens, revokeRefreshToken,
revokeRefreshToken, verifyAccessToken,
} from './tokenManager.js'; } from './tokenManager.js';
// JWT 配置(支持 HS256 与 RS256 // 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'); const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY?.replace(/\\n/g, '\n');
function getSignVerifyKeys() { function getSignVerifyKeys() {
if (JWT_ALG === 'RS256') { if (JWT_ALG === 'RS256') {
if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) { if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) {
throw new Error('RS256 需要同时提供 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 * @deprecated 建议使用 generateAccessToken
*/ */
export function signToken(payload) { export function signToken(payload) {
const { signKey } = getSignVerifyKeys(); const {signKey} = getSignVerifyKeys();
return jwt.sign(payload, signKey, { return jwt.sign(payload, signKey, {
expiresIn: JWT_EXPIRES_IN, expiresIn: JWT_EXPIRES_IN,
algorithm: JWT_ALG, algorithm: JWT_ALG,
}); });
} }
/** /**
@ -47,8 +47,8 @@ export function signToken(payload) {
* @deprecated 建议使用 verifyAccessToken * @deprecated 建议使用 verifyAccessToken
*/ */
export function verifyToken(token) { export function verifyToken(token) {
const { verifyKey } = getSignVerifyKeys(); const {verifyKey} = getSignVerifyKeys();
return jwt.verify(token, verifyKey, { algorithms: [JWT_ALG] }); return jwt.verify(token, verifyKey, {algorithms: [JWT_ALG]});
} }
/** /**
@ -56,21 +56,21 @@ export function verifyToken(token) {
* @deprecated 建议使用 generateTokenPair 获取完整的令牌对 * @deprecated 建议使用 generateTokenPair 获取完整的令牌对
*/ */
export function generateAccountToken(account) { export function generateAccountToken(account) {
return signToken({ return signToken({
accountId: account.id, accountId: account.id,
provider: account.provider, provider: account.provider,
email: account.email, email: account.email,
name: account.name, name: account.name,
avatarUrl: account.avatarUrl, avatarUrl: account.avatarUrl,
}); });
} }
// 重新导出新的token管理功能 // 重新导出新的token管理功能
export { export {
generateAccessToken, generateAccessToken,
verifyAccessToken, verifyAccessToken,
generateTokenPair, generateTokenPair,
refreshAccessToken, refreshAccessToken,
revokeAllTokens, revokeAllTokens,
revokeRefreshToken, revokeRefreshToken,
}; };

View File

@ -1,223 +1,224 @@
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import { keysTotal } from "./metrics.js"; import {keysTotal} from "./metrics.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
class KVStore { class KVStore {
/** /**
* 通过设备ID和键名获取值 * 通过设备ID和键名获取值
* @param {number} deviceId - 设备ID * @param {number} deviceId - 设备ID
* @param {string} key - 键名 * @param {string} key - 键名
* @returns {object|null} 键对应的值或null * @returns {object|null} 键对应的值或null
*/ */
async get(deviceId, key) { async get(deviceId, key) {
const item = await prisma.kVStore.findUnique({ const item = await prisma.kVStore.findUnique({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: deviceId, deviceId: deviceId,
key: key, key: key,
}, },
}, },
}); });
return item ? item.value : null; 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;
} }
}
/** /**
* 列出指定设备下的所有键名及其元数据 * 获取键的完整信息包括元数据
* @param {number} deviceId - 设备ID * @param {number} deviceId - 设备ID
* @param {object} options - 选项参数 * @param {string} key - 键名
* @returns {Array} 键名和元数据数组 * @returns {object|null} 键的完整信息或null
*/ */
async list(deviceId, options = {}) { async getMetadata(deviceId, key) {
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options; 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;
const orderBy = {};
orderBy[sortBy] = sortDir.toLowerCase();
// 查询设备的所有键 // 转换为更友好的格式
const items = await prisma.kVStore.findMany({ return {
where: { deviceId: item.deviceId,
deviceId: deviceId, key: item.key,
}, metadata: {
select: { creatorIp: item.creatorIp,
deviceId: true, createdAt: item.createdAt,
key: true, updatedAt: item.updatedAt,
creatorIp: true, },
createdAt: true, };
updatedAt: true, }
value: false,
},
orderBy,
take: limit,
skip: skip,
});
// 处理结果 /**
return items.map((item) => ({ * 在指定设备下创建或更新键值
deviceId: item.deviceId, * @param {number} deviceId - 设备ID
key: item.key, * @param {string} key - 键名
metadata: { * @param {object} value - 键值
creatorIp: item.creatorIp, * @param {string} creatorIp - 创建者IP可选
createdAt: item.createdAt, * @returns {object} 创建或更新的记录
updatedAt: item.updatedAt, */
}, 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();
* @param {number} deviceId - 设备ID keysTotal.set(totalKeys);
* @param {object} options - 查询选项
* @returns {Array} 键名列表
*/
async listKeysOnly(deviceId, options = {}) {
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
// 构建排序条件 // 返回带有设备ID和原始键的结果
const orderBy = {}; return {
orderBy[sortBy] = sortDir.toLowerCase(); deviceId,
key,
value: item.value,
creatorIp: item.creatorIp,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
}
// 查询设备的所有键,只选择键名 /**
const items = await prisma.kVStore.findMany({ * 通过设备ID和键名删除
where: { * @param {number} deviceId - 设备ID
deviceId: deviceId, * @param {string} key - 键名
}, * @returns {object|null} 删除的记录或null
select: { */
key: true, async delete(deviceId, key) {
}, try {
orderBy, const item = await prisma.kVStore.delete({
take: limit, where: {
skip: skip, deviceId_key: {
}); deviceId: deviceId,
key: key,
},
},
});
// 只返回键名数组 // 更新键总数指标
return items.map((item) => item.key); const totalKeys = await prisma.kVStore.count();
} keysTotal.set(totalKeys);
/** return item ? {...item, deviceId, key} : null;
* 统计指定设备下的键值对数量 } catch (error) {
* @param {number} deviceId - 设备ID // 忽略记录不存在的错误
* @returns {number} 键值对数量 if (error.code === "P2025") {
*/ return null;
async count(deviceId) { }
const count = await prisma.kVStore.count({ throw error;
where: { }
deviceId: deviceId, }
},
}); /**
return count; * 列出指定设备下的所有键名及其元数据
} * @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(); export default new KVStore();

View File

@ -5,45 +5,45 @@ const register = new client.Registry();
// 当前在线设备数(连接了 SocketIO 的设备) // 当前在线设备数(连接了 SocketIO 的设备)
export const onlineDevicesGauge = new client.Gauge({ export const onlineDevicesGauge = new client.Gauge({
name: 'classworks_online_devices_total', name: 'classworks_online_devices_total',
help: 'Total number of online devices (connected via SocketIO)', help: 'Total number of online devices (connected via SocketIO)',
registers: [register], registers: [register],
}); });
// 已注册设备总数 // 已注册设备总数
export const registeredDevicesTotal = new client.Gauge({ export const registeredDevicesTotal = new client.Gauge({
name: 'classworks_registered_devices_total', name: 'classworks_registered_devices_total',
help: 'Total number of registered devices', help: 'Total number of registered devices',
registers: [register], registers: [register],
}); });
// 已创建键总数(不区分设备) // 已创建键总数(不区分设备)
export const keysTotal = new client.Gauge({ export const keysTotal = new client.Gauge({
name: 'classworks_keys_total', name: 'classworks_keys_total',
help: 'Total number of keys across all devices', help: 'Total number of keys across all devices',
registers: [register], registers: [register],
}); });
// 初始化指标数据 // 初始化指标数据
export async function initializeMetrics() { export async function initializeMetrics() {
try { try {
const { PrismaClient } = await import('@prisma/client'); const {PrismaClient} = await import('@prisma/client');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// 获取已注册设备总数 // 获取已注册设备总数
const deviceCount = await prisma.device.count(); const deviceCount = await prisma.device.count();
registeredDevicesTotal.set(deviceCount); registeredDevicesTotal.set(deviceCount);
// 获取已创建键总数 // 获取已创建键总数
const keyCount = await prisma.kVStore.count(); const keyCount = await prisma.kVStore.count();
keysTotal.set(keyCount); keysTotal.set(keyCount);
await prisma.$disconnect(); await prisma.$disconnect();
console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount); console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount);
} catch (error) { } catch (error) {
console.error('Failed to initialize metrics:', error); console.error('Failed to initialize metrics:', error);
} }
} }
// 导出注册表用于 /metrics 端点 // 导出注册表用于 /metrics 端点
export { register }; export {register};

View File

@ -1,4 +1,4 @@
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import kvStore from "./kvStore.js"; import kvStore from "./kvStore.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -12,8 +12,8 @@ let systemDeviceId = null;
// 封装默认 readme 对象 // 封装默认 readme 对象
const defaultReadme = { const defaultReadme = {
title: "Classworks 服务端", title: "Classworks 服务端",
readme: "暂无 Readme 内容", readme: "暂无 Readme 内容",
}; };
/** /**
@ -21,25 +21,25 @@ const defaultReadme = {
* @returns {Promise<number>} 系统设备ID * @returns {Promise<number>} 系统设备ID
*/ */
async function getSystemDeviceId() { async function getSystemDeviceId() {
if (systemDeviceId) return systemDeviceId; if (systemDeviceId) return systemDeviceId;
let device = await prisma.device.findUnique({ let device = await prisma.device.findUnique({
where: { uuid: SYSTEM_DEVICE_UUID }, where: {uuid: SYSTEM_DEVICE_UUID},
select: { id: true }, select: {id: true},
});
if (!device) {
device = await prisma.device.create({
data: {
uuid: SYSTEM_DEVICE_UUID,
name: "系统设备",
},
select: { id: true },
}); });
}
systemDeviceId = device.id; if (!device) {
return systemDeviceId; 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 () => { export const initReadme = async () => {
try { try {
const deviceId = await getSystemDeviceId(); const deviceId = await getSystemDeviceId();
const storedValue = await kvStore.get(deviceId, "info"); const storedValue = await kvStore.get(deviceId, "info");
// 合并默认值与存储值,确保结构完整 // 合并默认值与存储值,确保结构完整
readmeValue = { readmeValue = {
...defaultReadme, ...defaultReadme,
...(storedValue || {}), ...(storedValue || {}),
}; };
console.log("✅ 站点信息初始化成功"); console.log("✅ 站点信息初始化成功");
} catch (error) { } catch (error) {
console.error("❌ 站点信息初始化失败:", { console.error("❌ 站点信息初始化失败:", {
message: error?.message, message: error?.message,
stack: error?.stack, stack: error?.stack,
}); });
// 确保在异常情况下也有默认值 // 确保在异常情况下也有默认值
readmeValue = { ...defaultReadme }; readmeValue = {...defaultReadme};
} }
}; };
/** /**
@ -74,7 +74,7 @@ export const initReadme = async () => {
* @returns {Object} readme 值对象 * @returns {Object} readme 值对象
*/ */
export const getReadmeValue = () => { export const getReadmeValue = () => {
return readmeValue || { ...defaultReadme }; return readmeValue || {...defaultReadme};
}; };
/** /**
@ -83,19 +83,19 @@ export const getReadmeValue = () => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export const updateReadmeValue = async (newValue) => { export const updateReadmeValue = async (newValue) => {
try { try {
const deviceId = await getSystemDeviceId(); const deviceId = await getSystemDeviceId();
await kvStore.upsert(deviceId, "info", newValue); await kvStore.upsert(deviceId, "info", newValue);
readmeValue = { readmeValue = {
...defaultReadme, ...defaultReadme,
...newValue, ...newValue,
}; };
console.log("✅ 站点信息更新成功"); console.log("✅ 站点信息更新成功");
} catch (error) { } catch (error) {
console.error("❌ 站点信息更新失败:", { console.error("❌ 站点信息更新失败:", {
message: error?.message, message: error?.message,
stack: error?.stack, stack: error?.stack,
}); });
throw error; throw error;
} }
}; };

View File

@ -9,9 +9,9 @@
* - 提供广播 KV 键变更的工具方法 * - 提供广播 KV 键变更的工具方法
*/ */
import { Server } from "socket.io"; import {Server} from "socket.io";
import { PrismaClient } from "@prisma/client"; import {PrismaClient} from "@prisma/client";
import { onlineDevicesGauge } from "./metrics.js"; import {onlineDevicesGauge} from "./metrics.js";
// Socket.IO 单例实例 // Socket.IO 单例实例
let io = null; let io = null;
@ -27,105 +27,107 @@ const prisma = new PrismaClient();
* @param {import('http').Server} server HTTP Server 实例 * @param {import('http').Server} server HTTP Server 实例
*/ */
export function initSocket(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, { io = new Server(server, {
cors: { cors: {
origin: allowOrigin, origin: allowOrigin,
methods: ["GET", "POST"], methods: ["GET", "POST"],
credentials: true, 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(() => {});
}
}); });
// 客户端使用 token 离开房间 io.on("connection", (socket) => {
socket.on("leave-token", async (payload) => { // 初始化每个连接所加入的设备房间集合
try { socket.data.deviceUuids = new Set();
const token = payload?.token || payload?.apptoken;
if (typeof token !== "string" || token.length === 0) return; // 仅允许通过 query.token/apptoken 加入
const appInstall = await prisma.appInstall.findUnique({ const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
where: { token }, if (qToken && typeof qToken === "string") {
include: { device: { select: { uuid: true } } }, joinByToken(socket, qToken).catch(() => {
}); });
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
}
});
// 离开所有已加入的设备房间 // 客户端使用 KV token 加入房间
socket.on("leave-all", () => { socket.on("join-token", (payload) => {
const uuids = Array.from(socket.data.deviceUuids || []); const token = payload?.token || payload?.apptoken;
uuids.forEach((u) => leaveDeviceRoom(socket, u)); if (typeof token === "string" && token.length > 0) {
}); joinByToken(socket, token).catch(() => {
});
// 聊天室:发送文本消息到加入的设备频道 }
socket.on("chat:send", (data) => { });
try {
const text = typeof data === "string" ? data : data?.text; // 客户端使用 token 离开房间
if (typeof text !== "string") return; socket.on("leave-token", async (payload) => {
const trimmed = text.trim(); try {
if (!trimmed) return; const token = payload?.token || payload?.apptoken;
if (typeof token !== "string" || token.length === 0) return;
// 限制消息最大长度,避免滥用 const appInstall = await prisma.appInstall.findUnique({
const MAX_LEN = 2000; where: {token},
const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed; include: {device: {select: {uuid: true}}},
});
const uuids = Array.from(socket.data.deviceUuids || []); const uuid = appInstall?.device?.uuid;
if (uuids.length === 0) return; if (uuid) {
leaveDeviceRoom(socket, uuid);
const at = new Date().toISOString(); // 移除 token 连接跟踪
const payload = { text: safeText, at, senderId: socket.id }; removeTokenConnection(token, socket.id);
if (socket.data.tokens) socket.data.tokens.delete(token);
uuids.forEach((uuid) => { }
io.to(uuid).emit("chat:message", { uuid, ...payload }); } 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", () => { return io;
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;
} }
/** 返回 Socket.IO 实例 */ /** 返回 Socket.IO 实例 */
export function getIO() { export function getIO() {
return io; return io;
} }
/** /**
@ -134,15 +136,15 @@ export function getIO() {
* @param {string} uuid * @param {string} uuid
*/ */
function joinDeviceRoom(socket, uuid) { function joinDeviceRoom(socket, uuid) {
socket.join(uuid); socket.join(uuid);
if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set(); if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set();
socket.data.deviceUuids.add(uuid); socket.data.deviceUuids.add(uuid);
// 记录在线 // 记录在线
const set = onlineMap.get(uuid) || new Set(); const set = onlineMap.get(uuid) || new Set();
set.add(socket.id); set.add(socket.id);
onlineMap.set(uuid, set); onlineMap.set(uuid, set);
// 可选:通知加入 // 可选:通知加入
io.to(uuid).emit("device-joined", { uuid, connections: set.size }); io.to(uuid).emit("device-joined", {uuid, connections: set.size});
} }
/** /**
@ -151,16 +153,16 @@ function joinDeviceRoom(socket, uuid) {
* @param {string} token * @param {string} token
*/ */
function trackTokenConnection(socket, token) { function trackTokenConnection(socket, token) {
if (!socket.data.tokens) socket.data.tokens = new Set(); if (!socket.data.tokens) socket.data.tokens = new Set();
socket.data.tokens.add(token); socket.data.tokens.add(token);
// 记录 token 连接 // 记录 token 连接
const set = onlineTokens.get(token) || new Set(); const set = onlineTokens.get(token) || new Set();
set.add(socket.id); set.add(socket.id);
onlineTokens.set(token, set); onlineTokens.set(token, set);
// 更新在线设备数指标(基于不同的 token 数量) // 更新在线设备数指标(基于不同的 token 数量)
onlineDevicesGauge.set(onlineTokens.size); onlineDevicesGauge.set(onlineTokens.size);
} }
/** /**
@ -169,20 +171,20 @@ function trackTokenConnection(socket, token) {
* @param {string} uuid * @param {string} uuid
*/ */
function leaveDeviceRoom(socket, uuid) { function leaveDeviceRoom(socket, uuid) {
socket.leave(uuid); socket.leave(uuid);
if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid); if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid);
removeOnline(uuid, socket.id); removeOnline(uuid, socket.id);
} }
function removeOnline(uuid, socketId) { function removeOnline(uuid, socketId) {
const set = onlineMap.get(uuid); const set = onlineMap.get(uuid);
if (!set) return; if (!set) return;
set.delete(socketId); set.delete(socketId);
if (set.size === 0) { if (set.size === 0) {
onlineMap.delete(uuid); onlineMap.delete(uuid);
} else { } else {
onlineMap.set(uuid, set); onlineMap.set(uuid, set);
} }
} }
/** /**
@ -191,16 +193,16 @@ function removeOnline(uuid, socketId) {
* @param {string} socketId * @param {string} socketId
*/ */
function removeTokenConnection(token, socketId) { function removeTokenConnection(token, socketId) {
const set = onlineTokens.get(token); const set = onlineTokens.get(token);
if (!set) return; if (!set) return;
set.delete(socketId); set.delete(socketId);
if (set.size === 0) { if (set.size === 0) {
onlineTokens.delete(token); onlineTokens.delete(token);
} else { } else {
onlineTokens.set(token, set); onlineTokens.set(token, set);
} }
// 更新在线设备数指标(基于不同的 token 数量) // 更新在线设备数指标(基于不同的 token 数量)
onlineDevicesGauge.set(onlineTokens.size); onlineDevicesGauge.set(onlineTokens.size);
} }
/** /**
@ -209,8 +211,8 @@ function removeTokenConnection(token, socketId) {
* @param {object} payload { key, action: 'upsert'|'delete'|'batch', updatedAt?, created? } * @param {object} payload { key, action: 'upsert'|'delete'|'batch', updatedAt?, created? }
*/ */
export function broadcastKeyChanged(uuid, payload) { export function broadcastKeyChanged(uuid, payload) {
if (!io || !uuid) return; if (!io || !uuid) return;
io.to(uuid).emit("kv-key-changed", { uuid, ...payload }); io.to(uuid).emit("kv-key-changed", {uuid, ...payload});
} }
/** /**
@ -218,19 +220,19 @@ export function broadcastKeyChanged(uuid, payload) {
* @returns {Array<{token:string, connections:number}>} * @returns {Array<{token:string, connections:number}>}
*/ */
export function getOnlineDevices() { export function getOnlineDevices() {
const list = []; const list = [];
for (const [token, set] of onlineTokens.entries()) { for (const [token, set] of onlineTokens.entries()) {
list.push({ token, connections: set.size }); list.push({token, connections: set.size});
} }
// 默认按连接数降序 // 默认按连接数降序
return list.sort((a, b) => b.connections - a.connections); return list.sort((a, b) => b.connections - a.connections);
} }
export default { export default {
initSocket, initSocket,
getIO, getIO,
broadcastKeyChanged, broadcastKeyChanged,
getOnlineDevices, getOnlineDevices,
}; };
/** /**
@ -239,18 +241,18 @@ export default {
* @param {string} token * @param {string} token
*/ */
async function joinByToken(socket, token) { async function joinByToken(socket, token) {
const appInstall = await prisma.appInstall.findUnique({ const appInstall = await prisma.appInstall.findUnique({
where: { token }, where: {token},
include: { device: { select: { uuid: true } } }, include: {device: {select: {uuid: true}}},
}); });
const uuid = appInstall?.device?.uuid; const uuid = appInstall?.device?.uuid;
if (uuid) { if (uuid) {
joinDeviceRoom(socket, uuid); joinDeviceRoom(socket, uuid);
// 跟踪 token 连接用于指标统计 // 跟踪 token 连接用于指标统计
trackTokenConnection(socket, token); trackTokenConnection(socket, token);
// 可选:回执 // 可选:回执
socket.emit("joined", { by: "token", uuid, token }); socket.emit("joined", {by: "token", uuid, token});
} else { } else {
socket.emit("join-error", { by: "token", reason: "invalid_token" }); socket.emit("join-error", {by: "token", reason: "invalid_token"});
} }
} }

View File

@ -1,6 +1,6 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import crypto from 'crypto'; import crypto from 'crypto';
import { PrismaClient } from '@prisma/client'; import {PrismaClient} from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -25,266 +25,271 @@ const REFRESH_TOKEN_PUBLIC_KEY = process.env.REFRESH_TOKEN_PUBLIC_KEY?.replace(/
* 获取签名和验证密钥 * 获取签名和验证密钥
*/ */
function getKeys(tokenType = 'access') { function getKeys(tokenType = 'access') {
if (JWT_ALG === 'RS256') { if (JWT_ALG === 'RS256') {
const privateKey = tokenType === 'access' ? ACCESS_TOKEN_PRIVATE_KEY : REFRESH_TOKEN_PRIVATE_KEY; const privateKey = tokenType === 'access' ? ACCESS_TOKEN_PRIVATE_KEY : REFRESH_TOKEN_PRIVATE_KEY;
const publicKey = tokenType === 'access' ? ACCESS_TOKEN_PUBLIC_KEY : REFRESH_TOKEN_PUBLIC_KEY; const publicKey = tokenType === 'access' ? ACCESS_TOKEN_PUBLIC_KEY : REFRESH_TOKEN_PUBLIC_KEY;
if (!privateKey || !publicKey) { if (!privateKey || !publicKey) {
throw new Error(`RS256 需要同时提供 ${tokenType.toUpperCase()}_TOKEN_PRIVATE_KEY 与 ${tokenType.toUpperCase()}_TOKEN_PUBLIC_KEY`); 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 // 默认 HS256
const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET; const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
return { signKey: secret, verifyKey: secret }; return {signKey: secret, verifyKey: secret};
} }
/** /**
* 生成访问令牌 * 生成访问令牌
*/ */
export function generateAccessToken(account) { export function generateAccessToken(account) {
const { signKey } = getKeys('access'); const {signKey} = getKeys('access');
const payload = { const payload = {
type: 'access', type: 'access',
accountId: account.id, accountId: account.id,
provider: account.provider, provider: account.provider,
email: account.email, email: account.email,
name: account.name, name: account.name,
avatarUrl: account.avatarUrl, avatarUrl: account.avatarUrl,
tokenVersion: account.tokenVersion || 1, tokenVersion: account.tokenVersion || 1,
}; };
return jwt.sign(payload, signKey, { return jwt.sign(payload, signKey, {
expiresIn: ACCESS_TOKEN_EXPIRES_IN, expiresIn: ACCESS_TOKEN_EXPIRES_IN,
algorithm: JWT_ALG, algorithm: JWT_ALG,
issuer: 'ClassworksKV', issuer: 'ClassworksKV',
audience: 'classworks-client', audience: 'classworks-client',
}); });
} }
/** /**
* 生成刷新令牌 * 生成刷新令牌
*/ */
export function generateRefreshToken(account) { export function generateRefreshToken(account) {
const { signKey } = getKeys('refresh'); const {signKey} = getKeys('refresh');
const payload = { const payload = {
type: 'refresh', type: 'refresh',
accountId: account.id, accountId: account.id,
tokenVersion: account.tokenVersion || 1, tokenVersion: account.tokenVersion || 1,
// 添加随机字符串增加安全性 // 添加随机字符串增加安全性
jti: crypto.randomBytes(16).toString('hex'), jti: crypto.randomBytes(16).toString('hex'),
}; };
return jwt.sign(payload, signKey, { return jwt.sign(payload, signKey, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN, expiresIn: REFRESH_TOKEN_EXPIRES_IN,
algorithm: JWT_ALG, algorithm: JWT_ALG,
issuer: 'ClassworksKV', issuer: 'ClassworksKV',
audience: 'classworks-client', audience: 'classworks-client',
}); });
} }
/** /**
* 验证访问令牌 * 验证访问令牌
*/ */
export function verifyAccessToken(token) { export function verifyAccessToken(token) {
const { verifyKey } = getKeys('access'); const {verifyKey} = getKeys('access');
try { try {
const decoded = jwt.verify(token, verifyKey, { const decoded = jwt.verify(token, verifyKey, {
algorithms: [JWT_ALG], algorithms: [JWT_ALG],
issuer: 'ClassworksKV', issuer: 'ClassworksKV',
audience: 'classworks-client', audience: 'classworks-client',
}); });
if (decoded.type !== 'access') { if (decoded.type !== 'access') {
throw new Error('Invalid token type'); throw new Error('Invalid token type');
}
return decoded;
} catch (error) {
throw error;
} }
return decoded;
} catch (error) {
throw error;
}
} }
/** /**
* 验证刷新令牌 * 验证刷新令牌
*/ */
export function verifyRefreshToken(token) { export function verifyRefreshToken(token) {
const { verifyKey } = getKeys('refresh'); const {verifyKey} = getKeys('refresh');
try { try {
const decoded = jwt.verify(token, verifyKey, { const decoded = jwt.verify(token, verifyKey, {
algorithms: [JWT_ALG], algorithms: [JWT_ALG],
issuer: 'ClassworksKV', issuer: 'ClassworksKV',
audience: 'classworks-client', audience: 'classworks-client',
}); });
if (decoded.type !== 'refresh') { if (decoded.type !== 'refresh') {
throw new Error('Invalid token type'); throw new Error('Invalid token type');
}
return decoded;
} catch (error) {
throw error;
} }
return decoded;
} catch (error) {
throw error;
}
} }
/** /**
* 生成令牌对访问令牌 + 刷新令牌 * 生成令牌对访问令牌 + 刷新令牌
*/ */
export async function generateTokenPair(account) { export async function generateTokenPair(account) {
const accessToken = generateAccessToken(account); const accessToken = generateAccessToken(account);
const refreshToken = generateRefreshToken(account); const refreshToken = generateRefreshToken(account);
// 计算刷新令牌过期时间 // 计算刷新令牌过期时间
const refreshTokenExpiry = new Date(); const refreshTokenExpiry = new Date();
const expiresInMs = parseExpirationToMs(REFRESH_TOKEN_EXPIRES_IN); const expiresInMs = parseExpirationToMs(REFRESH_TOKEN_EXPIRES_IN);
refreshTokenExpiry.setTime(refreshTokenExpiry.getTime() + expiresInMs); refreshTokenExpiry.setTime(refreshTokenExpiry.getTime() + expiresInMs);
// 更新数据库中的刷新令牌 // 更新数据库中的刷新令牌
await prisma.account.update({ await prisma.account.update({
where: { id: account.id }, where: {id: account.id},
data: { data: {
refreshToken, refreshToken,
refreshTokenExpiry, refreshTokenExpiry,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
return { return {
accessToken, accessToken,
refreshToken, refreshToken,
accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN, accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN,
refreshTokenExpiresIn: REFRESH_TOKEN_EXPIRES_IN, refreshTokenExpiresIn: REFRESH_TOKEN_EXPIRES_IN,
}; };
} }
/** /**
* 刷新访问令牌 * 刷新访问令牌
*/ */
export async function refreshAccessToken(refreshToken) { export async function refreshAccessToken(refreshToken) {
try { try {
// 验证刷新令牌 // 验证刷新令牌
const decoded = verifyRefreshToken(refreshToken); const decoded = verifyRefreshToken(refreshToken);
// 从数据库获取账户信息 // 从数据库获取账户信息
const account = await prisma.account.findUnique({ const account = await prisma.account.findUnique({
where: { id: decoded.accountId }, where: {id: decoded.accountId},
}); });
if (!account) { if (!account) {
throw new Error('Account not found'); 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) { export async function revokeAllTokens(accountId) {
await prisma.account.update({ await prisma.account.update({
where: { id: accountId }, where: {id: accountId},
data: { data: {
tokenVersion: { increment: 1 }, tokenVersion: {increment: 1},
refreshToken: null, refreshToken: null,
refreshTokenExpiry: null, refreshTokenExpiry: null,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
} }
/** /**
* 撤销当前刷新令牌登出当前设备 * 撤销当前刷新令牌登出当前设备
*/ */
export async function revokeRefreshToken(accountId) { export async function revokeRefreshToken(accountId) {
await prisma.account.update({ await prisma.account.update({
where: { id: accountId }, where: {id: accountId},
data: { data: {
refreshToken: null, refreshToken: null,
refreshTokenExpiry: null, refreshTokenExpiry: null,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
} }
/** /**
* 解析过期时间字符串为毫秒 * 解析过期时间字符串为毫秒
*/ */
function parseExpirationToMs(expiresIn) { function parseExpirationToMs(expiresIn) {
if (typeof expiresIn === 'number') { if (typeof expiresIn === 'number') {
return expiresIn * 1000; return expiresIn * 1000;
} }
const match = expiresIn.match(/^(\d+)([smhd])$/); const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) { if (!match) {
throw new Error('Invalid expiration format'); throw new Error('Invalid expiration format');
} }
const value = parseInt(match[1]); const value = parseInt(match[1]);
const unit = match[2]; const unit = match[2];
switch (unit) { switch (unit) {
case 's': return value * 1000; case 's':
case 'm': return value * 60 * 1000; return value * 1000;
case 'h': return value * 60 * 60 * 1000; case 'm':
case 'd': return value * 24 * 60 * 60 * 1000; return value * 60 * 1000;
default: throw new Error('Invalid time unit'); 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) { export async function validateAccountToken(decoded) {
const account = await prisma.account.findUnique({ const account = await prisma.account.findUnique({
where: { id: decoded.accountId }, where: {id: decoded.accountId},
}); });
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error('Account not found');
} }
// 验证令牌版本 // 验证令牌版本
if (account.tokenVersion !== decoded.tokenVersion) { if (account.tokenVersion !== decoded.tokenVersion) {
throw new Error('Token version mismatch'); throw new Error('Token version mismatch');
} }
return account; return account;
} }
// 向后兼容的导出 // 向后兼容的导出

View File

@ -2,14 +2,16 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Classworks 服务端</title> <title>Classworks 服务端</title>
</head> </head>
<body> <body>
<h1>Classworks 服务端</h1> <h1>Classworks 服务端</h1>
<p>服务运行中</p> <p>服务运行中</p>
</body> </body>
<script>
window.open('https://kv.houlang.cloud')
</script>
</html> </html>