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

Compare commits

..

24 Commits
v1.3.1 ... main

Author SHA1 Message Date
Sunwuyuan
8e3b3df1ae
1.3.8 2025-12-07 13:50:27 +08:00
Sunwuyuan
21d6ddf164
feat: 实现批量upsert方法,优化键值对处理性能 2025-12-07 13:33:47 +08:00
Sunwuyuan
e65f84aa22
feat: 更新CORS配置,允许跨域请求携带凭证和自定义请求头 2025-12-06 13:41:40 +08:00
Sunwuyuan
ab8904b549
feat: 修复POST /:key处理,确保kvStore.upsert操作为异步执行 2025-12-06 13:10:02 +08:00
Sunwuyuan
da633ca5b6
feat: 增强POST /:key处理,支持空值和JSON格式验证 2025-12-06 13:09:00 +08:00
Sunwuyuan
1f68aea39f
Merge branch 'main' of https://github.com/ZeroCatDev/ClassworksKV 2025-12-06 11:54:58 +08:00
Sunwuyuan
b782945674
feat: 更新Socket.IO CORS设置,支持更多HTTP方法 2025-12-06 11:54:41 +08:00
Sunwuyuan
1e1b99a070
feat: 更新Socket.IO初始化配置,优化CORS设置和传输方式 2025-12-06 11:53:57 +08:00
Sunwuyuan
63716e0429 1.3.7 2025-12-01 12:24:09 +00:00
Sunwuyuan
b582521fee
Merge pull request #54 from tempChanghong/main
Fix: 允许保存空数组,修复无法删除最后一条通知的Bug
2025-12-01 20:21:29 +08:00
Sunwuyuan
f985b6a11a
Update validation logic for request body
Allow empty arrays to pass validation while intercepting empty objects.
2025-12-01 20:20:17 +08:00
tempChanghong
f0de2cd59b Fix:修改语法错误 2025-12-01 16:01:20 +08:00
tempChanghong
d52ed81a29 Fix: 允许保存空数组,修复无法删除最后一条通知的Bug 2025-12-01 15:06:52 +08:00
Sunwuyuan
e73ff53f58
1.3.6 2025-11-29 19:55:06 +08:00
Sunwuyuan
79ec5b94a4
feat: 添加Dlass登录 2025-11-29 19:54:50 +08:00
Sunwuyuan
ddf001b1c1
1.3.5 2025-11-23 17:05:07 +08:00
Sunwuyuan
7a010faa54
1 2025-11-23 16:48:27 +08:00
Sunwuyuan
d6330c81fe
1.3.4 2025-11-16 16:15:13 +08:00
Sunwuyuan
c545612c9c
规范代码格式 2025-11-16 16:15:05 +08:00
Sunwuyuan
4ec10acfcf
feat: 增强错误处理,统一JWT过期返回格式,添加业务错误码支持 2025-11-16 16:11:31 +08:00
Sunwuyuan
398f79d5c9
1.3.3 2025-11-16 14:58:09 +08:00
Sunwuyuan
4ae023afb0
feat: 添加设备信息提取功能,增强 UUID 认证中间件的错误处理 2025-11-16 14:46:05 +08:00
Sunwuyuan
4ff64ad514
1.3.2 2025-11-15 20:40:07 +08:00
Sunwuyuan
a1deb5e6e3
feat: 更新设备列表接口,添加 namespace 字段;修改 token 设置名称的条件,支持家长类型 2025-11-15 20:40:00 +08:00
41 changed files with 13539 additions and 4137 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/FixClassworksKV.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/FixClassworksKV.iml" filepath="$PROJECT_DIR$/.idea/FixClassworksKV.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

8
app.js
View File

@ -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,15 +16,19 @@ 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({
exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露
maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求
credentials: true, // 允许跨域请求携带凭证
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept"], // 允许的请求头
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], // 允许的HTTP方法
withCredentials: true, // 允许携带cookie等凭证信息
})
);
app.disable("x-powered-by");

View File

@ -10,8 +10,6 @@
* 或配置为可执行chmod +x cli/get-token.js && ./cli/get-token.js
*/
import readline from 'readline';
// 配置
const CONFIG = {
// API服务器地址

View File

@ -67,6 +67,24 @@ export const oauthProviders = {
website: "https://houlang.cloud",
pkce: true, // 启用PKCE支持
},
dlass: {
// DlassCasdoor- 标准 OIDC Provider
clientId: process.env.DLASS_CLIENT_ID,
clientSecret: process.env.DLASS_CLIENT_SECRET,
// Casdoor 标准端点
authorizationURL: "https://auth.wiki.forum/login/oauth/authorize",
tokenURL: "https://auth.wiki.forum/api/login/oauth/access_token",
userInfoURL: "https://auth.wiki.forum/api/userinfo",
scope: "openid profile email offline_access",
// 展示相关
name: "dlass",
displayName: "Dlass 账户",
icon: "casdoor",
color: "#3498db",
description: "使用Dlass账户登录",
website: "https://dlass.tech",
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
},
};
// 获取OAuth回调URL

View File

@ -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

View File

@ -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"

View File

@ -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验证失败"));

View File

@ -56,7 +56,6 @@ export const prepareTokenForRateLimit = (req, res, next) => {
};
// 认证相关路由限速器(防止暴力破解)
export const authLimiter = rateLimit({
windowMs: 30 * 60 * 1000, // 30分钟

View File

@ -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

9032
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ClassworksKV",
"version": "1.3.1",
"version": "1.3.8",
"private": true,
"scripts": {
"start": "node ./bin/www",
@ -30,6 +30,7 @@
"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"

9
pnpm-lock.yaml generated
View File

@ -71,6 +71,9 @@ importers:
morgan:
specifier: ~1.10.0
version: 1.10.0
node-device-detector:
specifier: ^2.2.4
version: 2.2.4
prom-client:
specifier: ^15.1.3
version: 15.1.3
@ -2097,6 +2100,10 @@ packages:
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
engines: {node: ^18 || ^20 || >= 21}
node-device-detector@2.2.4:
resolution: {integrity: sha512-0nhi8XWLViGKeQyLLlg3bcUGdhTKc56ARAHx6kKWvwy39ITk7BZn5Gy6AmTSX4slM35iQMJaKAIxagR/xXsS+Q==}
engines: {node: '>= 10.x', npm: '>= 6.x'}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
@ -4706,6 +4713,8 @@ snapshots:
node-addon-api@8.3.1: {}
node-device-detector@2.2.4: {}
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:

View File

@ -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">

View File

@ -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>

View File

@ -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";
@ -282,6 +282,14 @@ router.get("/oauth/:provider/callback", async (req, res) => {
name: userData.name || userData.preferred_username || userData.nickname,
avatarUrl: userData.picture,
};
} else if (provider === "dlass") {
// DlassCasdoor标准OIDC用户信息
normalizedUser = {
providerId: userData.sub,
email: userData.email_verified ? userData.email : userData.email || null,
name: userData.name || userData.preferred_username || userData.nickname,
avatarUrl: userData.picture,
};
}
// 名称为空时,用邮箱@前部分回填(若邮箱可用)
@ -584,6 +592,7 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
id: true,
uuid: true,
name: true,
namespace: true,
createdAt: true,
updatedAt: true,
},

View File

@ -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({

View File

@ -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 }

View File

@ -7,7 +7,6 @@ const router = Router();
const prisma = new PrismaClient();
/**
* POST /device/code
* 生成设备授权码

View File

@ -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;

View File

@ -1,4 +1,5 @@
import {Router} from "express";
var router = Router();
/* GET home page. */

View File

@ -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认证
@ -297,43 +298,25 @@ router.post(
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,
});
}
}
// 使用优化的批量upsert方法
const { results, errors: errorList } = await kvStore.batchUpsert(deviceId, data, creatorIp);
return res.status(200).json({
code: 200,
message: "批量导入成功",
data: {
deviceId,
summary: {
total: Object.keys(data).length,
successful: results.length,
failed: errorList.length,
results,
errors: errorList.length > 0 ? errorList : undefined,
},
results: results.map(r => ({
key: r.key,
isNew: r.created,
})),
...(errorList.length > 0 && { errors: errorList }),
},
});
})
);
@ -353,10 +336,20 @@ router.post(
const deviceId = res.locals.deviceId;
const { key } = req.params;
const value = req.body;
let value = req.body;
if (!value || Object.keys(value).length === 0) {
return next(errors.createError(400, "请提供有效的JSON值"));
// 处理空值,转换为空对象
if (value === null || value === undefined || value === '') {
value = {};
}
// 验证是否能被 JSON 序列化
try {
JSON.stringify(value);
} catch (error) {
return next(
errors.createError(400, "无效的数据格式")
);
}
// 获取客户端IP

View File

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

View File

@ -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;
};

View File

@ -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

View File

@ -1,11 +1,11 @@
import jwt from 'jsonwebtoken';
import {
generateAccessToken,
verifyAccessToken,
generateTokenPair,
refreshAccessToken,
revokeAllTokens,
revokeRefreshToken,
verifyAccessToken,
} from './tokenManager.js';
// JWT 配置(支持 HS256 与 RS256

View File

@ -2,6 +2,7 @@ import { PrismaClient } from "@prisma/client";
import {keysTotal} from "./metrics.js";
const prisma = new PrismaClient();
class KVStore {
/**
* 通过设备ID和键名获取值
@ -101,6 +102,62 @@ class KVStore {
};
}
/**
* 批量创建或更新键值对优化性能
* @param {number} deviceId - 设备ID
* @param {object} data - 键值对数据 {key1: value1, key2: value2, ...}
* @param {string} creatorIp - 创建者IP可选
* @returns {object} {results: Array, errors: Array}
*/
async batchUpsert(deviceId, data, creatorIp = "") {
const results = [];
const errors = [];
// 使用事务处理所有操作
await prisma.$transaction(async (tx) => {
for (const [key, value] of Object.entries(data)) {
try {
const item = await tx.kVStore.upsert({
where: {
deviceId_key: {
deviceId: deviceId,
key: key,
},
},
update: {
value,
...(creatorIp && {creatorIp}),
},
create: {
deviceId: deviceId,
key: key,
value,
creatorIp,
},
});
results.push({
key: item.key,
created: item.createdAt.getTime() === item.updatedAt.getTime(),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
});
} catch (error) {
errors.push({
key,
error: error.message,
});
}
}
});
// 在事务完成后,一次性更新指标
const totalKeys = await prisma.kVStore.count();
keysTotal.set(totalKeys);
return { results, errors };
}
/**
* 通过设备ID和键名删除
* @param {number} deviceId - 设备ID
@ -218,6 +275,37 @@ class KVStore {
});
return count;
}
/**
* 获取指定设备的统计信息
* @param {number} deviceId - 设备ID
* @returns {object} 统计信息
*/
async getStats(deviceId) {
const [totalKeys, oldestKey, newestKey] = await Promise.all([
prisma.kVStore.count({
where: { deviceId },
}),
prisma.kVStore.findFirst({
where: { deviceId },
orderBy: { createdAt: "asc" },
select: { createdAt: true, key: true },
}),
prisma.kVStore.findFirst({
where: { deviceId },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true, key: true },
}),
]);
return {
totalKeys,
oldestKey: oldestKey?.key,
oldestCreatedAt: oldestKey?.createdAt,
newestKey: newestKey?.key,
newestUpdatedAt: newestKey?.updatedAt,
};
}
}
export default new KVStore();

View File

@ -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 实例
@ -29,14 +96,16 @@ const prisma = new PrismaClient();
export function initSocket(server) {
if (io) return io;
const allowOrigin = process.env.FRONTEND_URL || "*";
io = new Server(server, {
cors: {
origin: allowOrigin,
methods: ["GET", "POST"],
credentials: true,
origin: "*",
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
allowedHeaders: ["*"],
credentials: false
},
// 传输方式回退策略优先使用WebSocket,回退到轮询
transports: ["polling", "websocket"],
});
io.on("connection", (socket) => {
@ -46,14 +115,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 +155,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 +287,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 +390,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 +537,10 @@ export default {
initSocket,
getIO,
broadcastKeyChanged,
broadcastDeviceEvent,
getOnlineDevices,
getEventHistory,
getTokenInfo,
};
/**
@ -239,18 +549,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" });
}
}

View File

@ -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');
}
}

View File

@ -11,5 +11,7 @@
<h1>Classworks 服务端</h1>
<p>服务运行中</p>
</body>
<script>
window.open('https://kv.houlang.cloud')
</script>
</html>