mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-08 22:53:10 +00:00
Compare commits
12 Commits
68cf10f040
...
5f44e9ed9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f44e9ed9f | ||
|
|
ddf001b1c1 | ||
|
|
7a010faa54 | ||
|
|
d6330c81fe | ||
|
|
c545612c9c | ||
|
|
4ec10acfcf | ||
|
|
398f79d5c9 | ||
|
|
4ae023afb0 | ||
|
|
4ff64ad514 | ||
|
|
a1deb5e6e3 | ||
|
|
78843418de | ||
|
|
43a49b6516 |
4
app.js
4
app.js
@ -1,7 +1,7 @@
|
||||
import "./utils/instrumentation.js";
|
||||
// import createError from "http-errors";
|
||||
import express from "express";
|
||||
import { join, dirname } from "path";
|
||||
import {dirname, join} from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
// import cookieParser from "cookie-parser";
|
||||
import logger from "morgan";
|
||||
@ -16,10 +16,10 @@ import deviceAuthRouter from "./routes/device-auth.js";
|
||||
import accountsRouter from "./routes/accounts.js";
|
||||
import autoAuthRouter from "./routes/auto-auth.js";
|
||||
import {register} from "./utils/metrics.js";
|
||||
import cors from "cors";
|
||||
|
||||
var app = express();
|
||||
|
||||
import cors from "cors";
|
||||
app.options("/{*path}", cors());
|
||||
app.use(
|
||||
cors({
|
||||
|
||||
@ -10,8 +10,6 @@
|
||||
* 或配置为可执行:chmod +x cli/get-token.js && ./cli/get-token.js
|
||||
*/
|
||||
|
||||
import readline from 'readline';
|
||||
|
||||
// 配置
|
||||
const CONFIG = {
|
||||
// API服务器地址
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||
import {analyzeDevice} from "../utils/deviceDetector.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -38,7 +39,7 @@ async function createDefaultAutoAuth(deviceId) {
|
||||
* 设备中间件 - 统一处理设备UUID
|
||||
*
|
||||
* 从req.params.deviceUuid或req.body.deviceUuid获取UUID
|
||||
* 如果设备不存在则自动创建
|
||||
* 如果设备不存在则自动创建,并智能生成设备名称
|
||||
* 将设备信息存储到res.locals.device
|
||||
*
|
||||
* 使用方式:
|
||||
@ -58,11 +59,18 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
// 设备不存在,自动创建
|
||||
// 设备不存在,自动创建并生成智能设备名称
|
||||
const userAgent = req.headers['user-agent'];
|
||||
const customDeviceType = req.body.deviceType || req.query.deviceType;
|
||||
const note = req.body.note || req.query.note;
|
||||
|
||||
// 生成设备名称,确保不为空
|
||||
const deviceName = analyzeDevice(userAgent, req.headers, customDeviceType, note).generatedName;
|
||||
|
||||
device = await prisma.device.create({
|
||||
data: {
|
||||
uuid: deviceUuid,
|
||||
name: null,
|
||||
name: deviceName,
|
||||
password: null,
|
||||
passwordHint: null,
|
||||
accountId: null,
|
||||
@ -71,6 +79,9 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
|
||||
// 为新创建的设备添加默认的自动登录配置
|
||||
await createDefaultAutoAuth(device.id);
|
||||
|
||||
// 将设备分析结果添加到响应中
|
||||
res.locals.deviceAnalysis = deviceAnalysis;
|
||||
}
|
||||
|
||||
// 将设备信息存储到res.locals
|
||||
|
||||
@ -17,11 +17,13 @@ const errorHandler = (err, req, res, next) => {
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
const message = err.message || "服务器错误";
|
||||
const details = err.details || null;
|
||||
const code = err.code || undefined;
|
||||
|
||||
// 返回统一格式的错误响应
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
message: message,
|
||||
code: code,
|
||||
details: details,
|
||||
error:
|
||||
process.env.NODE_ENV === "production"
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* 适用于只需要账户验证的接口
|
||||
*/
|
||||
|
||||
import { verifyAccessToken, validateAccountToken, generateAccessToken } from "../utils/tokenManager.js";
|
||||
import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js";
|
||||
import {verifyToken} from "../utils/jwt.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
@ -76,7 +76,10 @@ export const jwtAuth = async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') {
|
||||
return next(errors.createError(401, "JWT token已过期"));
|
||||
// 统一的账户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验证失败"));
|
||||
|
||||
@ -56,7 +56,6 @@ export const prepareTokenForRateLimit = (req, res, next) => {
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 认证相关路由限速器(防止暴力破解)
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 30 * 60 * 1000, // 30分钟
|
||||
|
||||
@ -93,6 +93,22 @@ export const uuidAuth = async (req, res, next) => {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
export const extractDeviceInfo = async (req, res, next) => {
|
||||
var uuid = extractUuid(req);
|
||||
|
||||
if (!uuid) {
|
||||
throw errors.createError(400, "需要提供设备UUID");
|
||||
}
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
if (!device) {
|
||||
throw errors.createError(404, "设备不存在");
|
||||
}
|
||||
res.locals.device = device;
|
||||
res.locals.deviceId = device.id;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取UUID
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ClassworksKV",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
@ -30,9 +30,10 @@
|
||||
"js-base64": "^3.7.7",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "~1.10.0",
|
||||
"node-device-detector": "^2.2.4",
|
||||
"prom-client": "^15.1.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.18.0"
|
||||
|
||||
1939
pnpm-lock.yaml
generated
1939
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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>
|
||||
<style>
|
||||
* {
|
||||
@ -50,9 +50,15 @@
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
@ -123,7 +129,7 @@
|
||||
<div class="container">
|
||||
<div class="error-icon">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -134,7 +140,7 @@
|
||||
<div class="error-code" id="errorCode"></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>
|
||||
|
||||
<div class="help-text">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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>
|
||||
<style>
|
||||
* {
|
||||
@ -135,7 +135,7 @@
|
||||
<div class="container">
|
||||
<div class="success-icon">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {Router} from "express";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
|
||||
import { generateAccountToken, generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken } from "../utils/jwt.js";
|
||||
import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js";
|
||||
import {generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken} from "../utils/jwt.js";
|
||||
import {jwtAuth} from "../middleware/jwt-auth.js";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
@ -584,6 +584,7 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
|
||||
id: true,
|
||||
uuid: true,
|
||||
name: true,
|
||||
namespace: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import {Router} from "express";
|
||||
const router = Router();
|
||||
import {uuidAuth} from "../middleware/uuidAuth.js";
|
||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import errors from "../utils/errors.js";
|
||||
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
@ -217,7 +216,7 @@ router.post(
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果验证失败,继续尝试下一个
|
||||
continue;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -287,8 +286,8 @@ router.post(
|
||||
}
|
||||
|
||||
// 验证 token 类型是否为 student
|
||||
if (appInstall.deviceType !== 'student') {
|
||||
return next(errors.createError(403, "只有学生类型的 token 可以设置名称"));
|
||||
if (!['student', 'parent'].includes(appInstall.deviceType)) {
|
||||
return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称"));
|
||||
}
|
||||
|
||||
// 读取设备的 classworks-list-main 键值
|
||||
@ -326,7 +325,7 @@ router.post(
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: {id: appInstall.id},
|
||||
data: { note: name },
|
||||
data: {note: appInstall.deviceType === 'parent' ? `${name} 家长` : name},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import {Router} from "express";
|
||||
const router = Router();
|
||||
import {jwtAuth} from "../middleware/jwt-auth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
@ -127,7 +128,8 @@ router.post(
|
||||
},
|
||||
});
|
||||
})
|
||||
);/**
|
||||
);
|
||||
/**
|
||||
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
|
||||
* 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||
|
||||
@ -7,7 +7,6 @@ const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* POST /device/code
|
||||
* 生成设备授权码
|
||||
|
||||
210
routes/device.js
210
routes/device.js
@ -1,13 +1,12 @@
|
||||
import {Router} from "express";
|
||||
const router = Router();
|
||||
import { uuidAuth } from "../middleware/uuidAuth.js";
|
||||
import {extractDeviceInfo} from "../middleware/uuidAuth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import errors from "../utils/errors.js";
|
||||
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
||||
import {getOnlineDevices} from "../utils/socket.js";
|
||||
import {registeredDevicesTotal} from "../utils/metrics.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
@ -147,13 +146,14 @@ router.get(
|
||||
namespace: device.namespace,
|
||||
});
|
||||
})
|
||||
);/**
|
||||
);
|
||||
/**
|
||||
* PUT /devices/:uuid/name
|
||||
* 设置设备名称 (需要UUID认证)
|
||||
*/
|
||||
router.put(
|
||||
"/:uuid/name",
|
||||
uuidAuth,
|
||||
extractDeviceInfo,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {name} = req.body;
|
||||
const device = res.locals.device;
|
||||
@ -180,202 +180,6 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /devices/:uuid/password
|
||||
* 初次设置设备密码 (无需认证,仅当设备未设置密码时)
|
||||
*/
|
||||
router.post(
|
||||
"/:uuid/password",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const newPassword = req.query.newPassword || req.body.newPassword;
|
||||
|
||||
if (!newPassword) {
|
||||
return next(errors.createError(400, "新密码是必需的"));
|
||||
}
|
||||
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 只有在设备未设置密码时才允许无认证设置
|
||||
if (device.password) {
|
||||
return next(errors.createError(403, "设备已设置密码,请使用修改密码接口"));
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "密码设置成功",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /devices/:uuid/password
|
||||
* 修改设备密码 (需要UUID认证和当前密码验证,账户拥有者除外)
|
||||
*/
|
||||
router.put(
|
||||
"/:uuid/password",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const currentPassword = req.query.currentPassword;
|
||||
const newPassword = req.query.newPassword || req.body.newPassword;
|
||||
const passwordHint = req.query.passwordHint || req.body.passwordHint;
|
||||
const device = res.locals.device;
|
||||
const isAccountOwner = res.locals.isAccountOwner;
|
||||
|
||||
if (!newPassword) {
|
||||
return next(errors.createError(400, "新密码是必需的"));
|
||||
}
|
||||
|
||||
// 如果是账户拥有者,无需验证当前密码
|
||||
if (!isAccountOwner) {
|
||||
if (!device.password) {
|
||||
return next(errors.createError(400, "设备未设置密码,请使用设置密码接口"));
|
||||
}
|
||||
|
||||
if (!currentPassword) {
|
||||
return next(errors.createError(400, "当前密码是必需的"));
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
const isCurrentPasswordValid = await verifyDevicePassword(currentPassword, device.password);
|
||||
if (!isCurrentPasswordValid) {
|
||||
return next(errors.createError(401, "当前密码错误"));
|
||||
}
|
||||
}
|
||||
|
||||
const hashedNewPassword = await hashPassword(newPassword);
|
||||
|
||||
await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: {
|
||||
password: hashedNewPassword,
|
||||
passwordHint: passwordHint !== undefined ? passwordHint : device.passwordHint,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "密码修改成功",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /devices/:uuid/password-hint
|
||||
* 设置密码提示 (需要UUID认证)
|
||||
*/
|
||||
router.put(
|
||||
"/:uuid/password-hint",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { passwordHint } = req.body;
|
||||
const device = res.locals.device;
|
||||
|
||||
await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: { passwordHint: passwordHint || null },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "密码提示设置成功",
|
||||
passwordHint: passwordHint || null,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /devices/:uuid/password-hint
|
||||
* 获取设备密码提示 (无需认证)
|
||||
*/
|
||||
router.get(
|
||||
"/:uuid/password-hint",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
select: {
|
||||
passwordHint: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
passwordHint: device.passwordHint || null,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /devices/:uuid/password
|
||||
* 删除设备密码 (需要UUID认证和密码验证,账户拥有者除外)
|
||||
*/
|
||||
router.delete(
|
||||
"/:uuid/password",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const password = req.query.password;
|
||||
const device = res.locals.device;
|
||||
const isAccountOwner = res.locals.isAccountOwner;
|
||||
|
||||
if (!device.password) {
|
||||
return next(errors.createError(400, "设备未设置密码"));
|
||||
}
|
||||
|
||||
// 如果不是账户拥有者,需要验证密码
|
||||
if (!isAccountOwner) {
|
||||
if (!password) {
|
||||
return next(errors.createError(400, "密码是必需的"));
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await verifyDevicePassword(password, device.password);
|
||||
if (!isPasswordValid) {
|
||||
return next(errors.createError(401, "密码错误"));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: {
|
||||
password: null,
|
||||
passwordHint: null,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "密码删除成功",
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* GET /devices/online
|
||||
@ -408,3 +212,5 @@ router.get(
|
||||
res.json({success: true, devices});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {Router} from "express";
|
||||
|
||||
var router = Router();
|
||||
|
||||
/* GET home page. */
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import {Router} from "express";
|
||||
const router = Router();
|
||||
import kvStore from "../utils/kvStore.js";
|
||||
import {broadcastKeyChanged} from "../utils/socket.js";
|
||||
import {kvTokenAuth} from "../middleware/kvTokenAuth.js";
|
||||
import {
|
||||
tokenReadLimiter,
|
||||
tokenWriteLimiter,
|
||||
tokenDeleteLimiter,
|
||||
prepareTokenForRateLimit,
|
||||
tokenBatchLimiter,
|
||||
prepareTokenForRateLimit
|
||||
tokenDeleteLimiter,
|
||||
tokenReadLimiter,
|
||||
tokenWriteLimiter
|
||||
} from "../middleware/rateLimiter.js";
|
||||
import errors from "../utils/errors.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 使用KV专用token认证
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const siteKey = process.env.SITE_KEY || "";
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
/**
|
||||
* 创建标准错误对象
|
||||
* @param {number} statusCode - HTTP状态码
|
||||
* @param {string} [message] - 错误消息
|
||||
* @param {string} [message] - 错误消息(推荐使用稳定的机器可读文本,如 JWT_EXPIRED)
|
||||
* @param {object} [details] - 附加信息
|
||||
* @param {string} [code] - 业务错误码(如 AUTH_JWT_EXPIRED)
|
||||
* @returns {object} 标准错误对象
|
||||
*/
|
||||
const createError = (statusCode, message, details = null) => {
|
||||
const createError = (statusCode, message, details = null, code = null) => {
|
||||
// 直接返回错误对象,不抛出异常
|
||||
const error = {
|
||||
statusCode: statusCode,
|
||||
message: message || '服务器错误',
|
||||
details: details
|
||||
details: details,
|
||||
code: code || undefined,
|
||||
};
|
||||
return error;
|
||||
};
|
||||
|
||||
@ -5,6 +5,7 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
||||
import {BatchSpanProcessor} from "@opentelemetry/sdk-trace-base";
|
||||
import {resourceFromAttributes} from "@opentelemetry/resources";
|
||||
import {SemanticResourceAttributes} from "@opentelemetry/semantic-conventions";
|
||||
|
||||
if (process.env.AXIOM_TOKEN && process.env.AXIOM_DATASET) {
|
||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
generateAccessToken,
|
||||
verifyAccessToken,
|
||||
generateTokenPair,
|
||||
refreshAccessToken,
|
||||
revokeAllTokens,
|
||||
revokeRefreshToken,
|
||||
verifyAccessToken,
|
||||
} from './tokenManager.js';
|
||||
|
||||
// JWT 配置(支持 HS256 与 RS256)
|
||||
|
||||
@ -2,6 +2,7 @@ import { PrismaClient } from "@prisma/client";
|
||||
import {keysTotal} from "./metrics.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class KVStore {
|
||||
/**
|
||||
* 通过设备ID和键名获取值
|
||||
|
||||
403
utils/socket.js
403
utils/socket.js
@ -7,21 +7,88 @@
|
||||
* - 同一设备的不同 token 会被归入同一频道
|
||||
* - 维护在线设备列表
|
||||
* - 提供广播 KV 键变更的工具方法
|
||||
* - 支持任意类型事件转发:客户端可发送自定义事件类型和JSON内容到其他设备
|
||||
* - 记录事件历史:包含时间戳、来源令牌、设备类型、权限等完整元数据
|
||||
* - 令牌信息缓存:在连接时预加载令牌详细信息以提高性能
|
||||
*/
|
||||
|
||||
import {Server} from "socket.io";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {onlineDevicesGauge} from "./metrics.js";
|
||||
import DeviceDetector from "node-device-detector";
|
||||
import ClientHints from "node-device-detector/client-hints.js";
|
||||
|
||||
// Socket.IO 单例实例
|
||||
let io = null;
|
||||
|
||||
// 设备检测器实例
|
||||
const deviceDetector = new DeviceDetector({
|
||||
clientIndexes: true,
|
||||
deviceIndexes: true,
|
||||
deviceAliasCode: false,
|
||||
});
|
||||
const clientHints = new ClientHints();
|
||||
|
||||
// 在线设备映射:uuid -> Set<socketId>
|
||||
const onlineMap = new Map();
|
||||
// 在线 token 映射:token -> Set<socketId> (用于指标统计)
|
||||
const onlineTokens = new Map();
|
||||
// 令牌信息缓存:token -> {appId, isReadOnly, deviceType, note, deviceUuid, deviceName}
|
||||
const tokenInfoCache = new Map();
|
||||
// 事件历史记录:每个设备最多保存1000条事件记录
|
||||
const eventHistory = new Map(); // uuid -> Array<EventRecord>
|
||||
const MAX_EVENT_HISTORY = 1000;
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 检测设备并生成友好的设备名称
|
||||
* @param {string} userAgent 用户代理字符串
|
||||
* @param {object} headers HTTP headers对象
|
||||
* @returns {string} 生成的设备名称
|
||||
*/
|
||||
function detectDeviceName(userAgent, headers = {}) {
|
||||
if (!userAgent) return "Unknown Device";
|
||||
|
||||
try {
|
||||
const clientHintsData = clientHints.parse(headers);
|
||||
const deviceInfo = deviceDetector.detect(userAgent, clientHintsData);
|
||||
const botInfo = deviceDetector.parseBot(userAgent);
|
||||
|
||||
// 如果是bot,返回bot名称
|
||||
if (botInfo && botInfo.name) {
|
||||
return `Bot: ${botInfo.name}`;
|
||||
}
|
||||
|
||||
// 构建设备名称
|
||||
let deviceName = "";
|
||||
|
||||
if (deviceInfo.device && deviceInfo.device.brand && deviceInfo.device.model) {
|
||||
deviceName = `${deviceInfo.device.brand} ${deviceInfo.device.model}`;
|
||||
} else if (deviceInfo.os && deviceInfo.os.name) {
|
||||
deviceName = deviceInfo.os.name;
|
||||
if (deviceInfo.os.version) {
|
||||
deviceName += ` ${deviceInfo.os.version}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加客户端信息
|
||||
if (deviceInfo.client && deviceInfo.client.name) {
|
||||
deviceName += deviceName ? ` (${deviceInfo.client.name}` : deviceInfo.client.name;
|
||||
if (deviceInfo.client.version) {
|
||||
deviceName += ` ${deviceInfo.client.version}`;
|
||||
}
|
||||
if (deviceName.includes("(")) {
|
||||
deviceName += ")";
|
||||
}
|
||||
}
|
||||
|
||||
return deviceName || "Unknown Device";
|
||||
} catch (error) {
|
||||
console.warn("Device detection error:", error);
|
||||
return "Unknown Device";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Socket.IO
|
||||
* @param {import('http').Server} server HTTP Server 实例
|
||||
@ -46,14 +113,16 @@ export function initSocket(server) {
|
||||
// 仅允许通过 query.token/apptoken 加入
|
||||
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
|
||||
if (qToken && typeof qToken === "string") {
|
||||
joinByToken(socket, qToken).catch(() => {});
|
||||
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(() => {});
|
||||
joinByToken(socket, token).catch(() => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -84,29 +153,128 @@ export function initSocket(server) {
|
||||
uuids.forEach((u) => leaveDeviceRoom(socket, u));
|
||||
});
|
||||
|
||||
// 聊天室:发送文本消息到加入的设备频道
|
||||
socket.on("chat:send", (data) => {
|
||||
// 获取事件历史记录
|
||||
socket.on("get-event-history", (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 {limit = 50, offset = 0} = data || {};
|
||||
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 };
|
||||
if (uuids.length === 0) {
|
||||
socket.emit("event-history-error", {reason: "not_joined_any_device"});
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回所有加入设备的事件历史
|
||||
const historyData = {};
|
||||
uuids.forEach((uuid) => {
|
||||
io.to(uuid).emit("chat:message", { uuid, ...payload });
|
||||
historyData[uuid] = getEventHistory(uuid, limit, offset);
|
||||
});
|
||||
|
||||
socket.emit("event-history", {
|
||||
devices: historyData,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestedBy: {
|
||||
deviceType: socket.data.tokenInfo?.deviceType,
|
||||
deviceName: socket.data.tokenInfo?.deviceName,
|
||||
isReadOnly: socket.data.tokenInfo?.isReadOnly
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("chat:send error:", err);
|
||||
console.error("get-event-history error:", err);
|
||||
socket.emit("event-history-error", {reason: "internal_error"});
|
||||
}
|
||||
});
|
||||
|
||||
// 通用事件转发:允许发送任意类型事件到其他设备
|
||||
socket.on("send-event", (data) => {
|
||||
try {
|
||||
// 验证数据结构
|
||||
if (!data || typeof data !== "object") {
|
||||
socket.emit("event-error", {reason: "invalid_data_format"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, content} = data;
|
||||
|
||||
// 验证事件类型
|
||||
if (typeof type !== "string" || type.trim().length === 0) {
|
||||
socket.emit("event-error", {reason: "invalid_event_type"});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证内容格式(必须是对象或null)
|
||||
if (content !== null && (typeof content !== "object" || Array.isArray(content))) {
|
||||
socket.emit("event-error", {reason: "content_must_be_object_or_null"});
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前socket所在的设备房间
|
||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||
if (uuids.length === 0) {
|
||||
socket.emit("event-error", {reason: "not_joined_any_device"});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查只读权限
|
||||
const tokenInfo = socket.data.tokenInfo;
|
||||
if (tokenInfo?.isReadOnly) {
|
||||
socket.emit("event-error", {reason: "readonly_token_cannot_send_events"});
|
||||
return;
|
||||
}
|
||||
|
||||
// 限制序列化后内容大小,避免滥用
|
||||
const MAX_SIZE = 10240; // 10KB
|
||||
const serializedContent = JSON.stringify(content);
|
||||
if (serializedContent.length > MAX_SIZE) {
|
||||
socket.emit("event-error", {reason: "content_too_large", maxSize: MAX_SIZE});
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const eventId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 构建完整的事件载荷,包含发送者信息
|
||||
const eventPayload = {
|
||||
eventId,
|
||||
content,
|
||||
timestamp,
|
||||
senderId: socket.id,
|
||||
senderInfo: {
|
||||
appId: tokenInfo?.appId,
|
||||
deviceType: tokenInfo?.deviceType,
|
||||
deviceName: tokenInfo?.deviceName,
|
||||
isReadOnly: tokenInfo?.isReadOnly || false,
|
||||
note: tokenInfo?.note
|
||||
}
|
||||
};
|
||||
|
||||
// 记录事件到历史记录(包含type用于历史记录)
|
||||
const historyPayload = {
|
||||
...eventPayload,
|
||||
type: type.trim()
|
||||
};
|
||||
uuids.forEach((uuid) => {
|
||||
recordEventHistory(uuid, historyPayload);
|
||||
});
|
||||
|
||||
// 直接使用事件名称发送到所有相关设备房间(排除发送者所在的socket)
|
||||
uuids.forEach((uuid) => {
|
||||
socket.to(uuid).emit(type.trim(), eventPayload);
|
||||
});
|
||||
|
||||
// 发送确认回执给发送者
|
||||
socket.emit("event-sent", {
|
||||
eventId: eventPayload.eventId,
|
||||
eventName: type.trim(),
|
||||
timestamp: eventPayload.timestamp,
|
||||
targetDevices: uuids.length,
|
||||
senderInfo: eventPayload.senderInfo
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("send-event error:", err);
|
||||
socket.emit("event-error", {reason: "internal_error"});
|
||||
}
|
||||
});
|
||||
|
||||
@ -117,6 +285,16 @@ export function initSocket(server) {
|
||||
// 清理 token 连接跟踪
|
||||
const tokens = Array.from(socket.data.tokens || []);
|
||||
tokens.forEach((token) => removeTokenConnection(token, socket.id));
|
||||
|
||||
// 清理socket相关缓存
|
||||
if (socket.data.currentToken) {
|
||||
// 如果这是该token的最后一个连接,考虑清理缓存
|
||||
const tokenSet = onlineTokens.get(socket.data.currentToken);
|
||||
if (!tokenSet || tokenSet.size === 0) {
|
||||
// 可以选择保留缓存一段时间,这里暂时保留
|
||||
// tokenInfoCache.delete(socket.data.currentToken);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -210,7 +388,134 @@ function removeTokenConnection(token, socketId) {
|
||||
*/
|
||||
export function broadcastKeyChanged(uuid, payload) {
|
||||
if (!io || !uuid) return;
|
||||
io.to(uuid).emit("kv-key-changed", { uuid, ...payload });
|
||||
|
||||
// 发送KV变更事件
|
||||
const timestamp = new Date().toISOString();
|
||||
const eventId = `kv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const eventPayload = {
|
||||
eventId,
|
||||
content: payload,
|
||||
timestamp,
|
||||
senderId: "realtime",
|
||||
senderInfo: {
|
||||
appId: "5c2a54d553951a37b47066ead68c8642",
|
||||
deviceType: "server",
|
||||
deviceName: "realtime",
|
||||
isReadOnly: false,
|
||||
note: "Database realtime sync"
|
||||
}
|
||||
};
|
||||
|
||||
// 记录到事件历史(包含type用于历史记录)
|
||||
const historyPayload = {
|
||||
...eventPayload,
|
||||
type: "kv-key-changed"
|
||||
};
|
||||
recordEventHistory(uuid, historyPayload);
|
||||
|
||||
// 直接发送kv-key-changed事件
|
||||
io.to(uuid).emit("kv-key-changed", eventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定设备广播自定义事件
|
||||
* @param {string} uuid 设备 uuid
|
||||
* @param {string} type 事件类型
|
||||
* @param {object|null} content 事件内容(JSON对象或null)
|
||||
* @param {string} [senderId] 发送者ID(可选)
|
||||
*/
|
||||
export function broadcastDeviceEvent(uuid, type, content = null, senderId = "system") {
|
||||
if (!io || !uuid || typeof type !== "string" || type.trim().length === 0) return;
|
||||
|
||||
// 验证内容格式
|
||||
if (content !== null && (typeof content !== "object" || Array.isArray(content))) {
|
||||
console.warn("broadcastDeviceEvent: content must be object or null");
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const eventId = `sys-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const eventPayload = {
|
||||
eventId,
|
||||
content,
|
||||
timestamp,
|
||||
senderId,
|
||||
senderInfo: {
|
||||
appId: "system",
|
||||
deviceType: "system",
|
||||
deviceName: "System",
|
||||
isReadOnly: false,
|
||||
note: "System broadcast"
|
||||
}
|
||||
};
|
||||
|
||||
// 记录系统事件到历史(包含type用于历史记录)
|
||||
const historyPayload = {
|
||||
...eventPayload,
|
||||
type: type.trim()
|
||||
};
|
||||
recordEventHistory(uuid, historyPayload);
|
||||
|
||||
io.to(uuid).emit(type.trim(), eventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录事件到历史记录
|
||||
* @param {string} uuid 设备UUID
|
||||
* @param {object} eventPayload 事件载荷
|
||||
*/
|
||||
function recordEventHistory(uuid, eventPayload) {
|
||||
if (!eventHistory.has(uuid)) {
|
||||
eventHistory.set(uuid, []);
|
||||
}
|
||||
|
||||
const history = eventHistory.get(uuid);
|
||||
history.push({
|
||||
...eventPayload,
|
||||
recordedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 保持历史记录在限制范围内
|
||||
if (history.length > MAX_EVENT_HISTORY) {
|
||||
history.splice(0, history.length - MAX_EVENT_HISTORY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备事件历史记录
|
||||
* @param {string} uuid 设备UUID
|
||||
* @param {number} [limit=50] 返回记录数量限制
|
||||
* @param {number} [offset=0] 偏移量
|
||||
* @returns {Array} 事件历史记录
|
||||
*/
|
||||
export function getEventHistory(uuid, limit = 50, offset = 0) {
|
||||
const history = eventHistory.get(uuid) || [];
|
||||
return history.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取令牌信息
|
||||
* @param {string} token 令牌
|
||||
* @returns {object|null} 令牌信息
|
||||
*/
|
||||
export function getTokenInfo(token) {
|
||||
return tokenInfoCache.get(token) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理设备相关缓存
|
||||
* @param {string} uuid 设备UUID
|
||||
*/
|
||||
function cleanupDeviceCache(uuid) {
|
||||
// 清理事件历史
|
||||
eventHistory.delete(uuid);
|
||||
|
||||
// 清理相关令牌缓存
|
||||
for (const [token, info] of tokenInfoCache.entries()) {
|
||||
if (info.deviceUuid === uuid) {
|
||||
tokenInfoCache.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -230,7 +535,10 @@ export default {
|
||||
initSocket,
|
||||
getIO,
|
||||
broadcastKeyChanged,
|
||||
broadcastDeviceEvent,
|
||||
getOnlineDevices,
|
||||
getEventHistory,
|
||||
getTokenInfo,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -239,18 +547,69 @@ export default {
|
||||
* @param {string} token
|
||||
*/
|
||||
async function joinByToken(socket, token) {
|
||||
try {
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: {token},
|
||||
include: { device: { select: { uuid: true } } },
|
||||
include: {
|
||||
device: {
|
||||
select: {
|
||||
uuid: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const uuid = appInstall?.device?.uuid;
|
||||
if (uuid) {
|
||||
if (uuid && appInstall) {
|
||||
// 检测设备信息
|
||||
const userAgent = socket.handshake?.headers?.['user-agent'];
|
||||
const detectedDeviceName = detectDeviceName(userAgent, socket.handshake?.headers);
|
||||
|
||||
// 拼接设备名称:检测到的设备信息 + token的note
|
||||
let finalDeviceName = detectedDeviceName;
|
||||
if (appInstall.note && appInstall.note.trim()) {
|
||||
finalDeviceName = `${detectedDeviceName} - ${appInstall.note.trim()}`;
|
||||
}
|
||||
|
||||
// 缓存令牌信息,使用拼接后的设备名称
|
||||
const tokenInfo = {
|
||||
appId: appInstall.appId,
|
||||
isReadOnly: appInstall.isReadOnly,
|
||||
deviceType: appInstall.deviceType,
|
||||
note: appInstall.note,
|
||||
deviceUuid: uuid,
|
||||
deviceName: finalDeviceName, // 使用拼接后的设备名称
|
||||
detectedDevice: detectedDeviceName, // 保留检测到的设备信息
|
||||
originalNote: appInstall.note, // 保留原始备注
|
||||
installedAt: appInstall.installedAt
|
||||
};
|
||||
tokenInfoCache.set(token, tokenInfo);
|
||||
|
||||
// 在socket上记录当前令牌信息
|
||||
socket.data.currentToken = token;
|
||||
socket.data.tokenInfo = tokenInfo;
|
||||
|
||||
joinDeviceRoom(socket, uuid);
|
||||
// 跟踪 token 连接用于指标统计
|
||||
trackTokenConnection(socket, token);
|
||||
// 可选:回执
|
||||
socket.emit("joined", { by: "token", uuid, token });
|
||||
socket.emit("joined", {
|
||||
by: "token",
|
||||
uuid,
|
||||
token,
|
||||
tokenInfo: {
|
||||
isReadOnly: tokenInfo.isReadOnly,
|
||||
deviceType: tokenInfo.deviceType,
|
||||
deviceName: tokenInfo.deviceName,
|
||||
userAgent: userAgent
|
||||
}
|
||||
});
|
||||
} else {
|
||||
socket.emit("join-error", {by: "token", reason: "invalid_token"});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("joinByToken error:", error);
|
||||
socket.emit("join-error", {by: "token", reason: "database_error"});
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,11 +259,16 @@ function parseExpirationToMs(expiresIn) {
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's': return value * 1000;
|
||||
case 'm': return value * 60 * 1000;
|
||||
case 'h': return value * 60 * 60 * 1000;
|
||||
case 'd': return value * 24 * 60 * 60 * 1000;
|
||||
default: throw new Error('Invalid time unit');
|
||||
case 's':
|
||||
return value * 1000;
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
throw new Error('Invalid time unit');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,5 +11,7 @@
|
||||
<h1>Classworks 服务端</h1>
|
||||
<p>服务运行中</p>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
window.open('https://kv.houlang.cloud')
|
||||
</script>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user