1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-10-23 02:43:11 +00:00

Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
970d7e784a
Bump @prisma/client from 6.8.2 to 6.16.2
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.8.2 to 6.16.2.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.16.2/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 6.16.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-06 03:17:29 +00:00
SunWuyuan
24c443bb89
Merge branch 'main' of https://github.com/ZeroCatDev/ClassworksKV 2025-10-06 11:11:17 +08:00
SunWuyuan
0fca7900c8
cskv 2025-10-06 11:10:54 +08:00
SunWuyuan
aec482cbcb
cskv 2025-10-06 10:49:48 +08:00
SunWuyuan
7b1e224f70
继续一大堆功能实现 2025-10-03 21:22:18 +08:00
SunWuyuan
521522c1d2
更新到一半 2025-10-02 12:07:50 +08:00
48 changed files with 4165 additions and 1660 deletions

View File

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(pnpm create:*)",
"Bash(pnpm install:*)",
"Bash(pnpm add:*)",
"Bash(pnpm dlx:*)",
"WebFetch(domain:www.shadcn-vue.com)",
"Bash(pnpm build)"
],
"deny": [],
"ask": []
}
}

18
.env.oauth.example Normal file
View File

@ -0,0 +1,18 @@
# OAuth配置示例
# 复制此文件为 .env 并填入实际的值
# 服务基础URL用于生成回调地址
BASE_URL=http://localhost:3000
# GitHub OAuth
# 在 https://github.com/settings/developers 创建OAuth App
# Authorization callback URL: http://localhost:3000/accounts/oauth/github/callback
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# ZeroCat OAuth
# 在 ZeroCat 开发者平台创建应用
# 回调地址: http://localhost:3000/accounts/oauth/zerocat/callback
# 权限范围: user:basic user:email
ZEROCAT_CLIENT_ID=your_zerocat_client_id
ZEROCAT_CLIENT_SECRET=your_zerocat_client_secret

View File

@ -1,137 +0,0 @@
## 快速开始
如果你想快速体验 Classworks我们推荐使用 SQLite 版本。可以零配置运行。
## 部署方案
### MySQL 版本
```yaml
version: '3.8'
services:
app:
build:
context: .
args:
DATABASE_TYPE: mysql
environment:
- NODE_ENV=production
- MYSQL_DATABASE_URL=mysql://user:password@mysql:3306/classworks
ports:
- 3000:3000
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8
environment:
- MYSQL_DATABASE=classworks
- MYSQL_USER=user
- MYSQL_PASSWORD=password
- MYSQL_ROOT_PASSWORD=rootpassword
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:
```
默认配置:
- 数据库版本MySQL 8
- 默认端口3306
- 数据持久化:自动配置
### PostgreSQL 版本
```yaml
version: '3.8'
services:
app:
build:
context: .
args:
DATABASE_TYPE: postgres
environment:
- NODE_ENV=production
- PG_DATABASE_URL=postgresql://user:password@postgres:5432/classworks
ports:
- 3000:3000
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=classworks
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d classworks"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
```
默认配置:
- 数据库版本PostgreSQL 15 Alpine
- 默认端口5432
- 数据持久化:自动配置
### SQLite 版本
将以下内容保存为 `docker-compose.yml`
```yaml
version: '3.8'
services:
app:
build:
context: .
args:
DATABASE_TYPE: sqlite
ports:
- 3000:3000
environment:
- NODE_ENV=production
volumes:
- sqlite_data:/data
volumes:
sqlite_data:
```
## 使用说明
1. 选择你需要的版本,将对应的配置复制到 `docker-compose.yml` 文件中
2. 根据需要修改环境变量(见下方环境变量配置)
3. 运行 `docker compose up -d` 启动服务
## 环境变量配置
```
# Axiom.co 遥测配置 可选
AXIOM_DATASET=
AXIOM_TOKEN=
# 网站密钥 可选
SITE_KEY=
# 服务端口 可选 默认3000
PORT=
```

29
app.js
View File

@ -8,14 +8,18 @@ import logger from "morgan";
import bodyParser from "body-parser";
import errorHandler from "./middleware/errorHandler.js";
import errors from "./utils/errors.js";
import { initReadme, getReadmeValue } from "./utils/siteinfo.js";
import {
globalLimiter,
apiLimiter,
methodBasedRateLimiter,
tokenBasedRateLimiter,
} from "./middleware/rateLimiter.js";
import kvRouter from "./routes/kv.js";
import kvRouter from "./routes/kv-token.js";
import appsRouter from "./routes/apps.js";
import deviceRouter from "./routes/device.js";
import deviceAuthRouter from "./routes/device-auth.js";
import accountsRouter from "./routes/accounts.js";
var app = express();
@ -33,9 +37,6 @@ app.disable("x-powered-by");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 初始化 readme
initReadme();
// 应用全局限速
app.use(globalLimiter);
@ -73,7 +74,7 @@ app.use((req, res, next) => {
next();
});
app.get("/", (req, res) => {
res.render("index.ejs", { readmeValue: getReadmeValue() });
res.render("index.ejs");
});
app.get("/check", apiLimiter, (req, res) => {
res.json({
@ -83,8 +84,20 @@ app.get("/check", apiLimiter, (req, res) => {
});
});
// Mount the KV store router with method-based rate limiting
app.use("/", methodBasedRateLimiter, kvRouter);
// Mount the Apps router with API rate limiting
app.use("/apps", apiLimiter, appsRouter);
// Mount the Device router with API rate limiting
app.use("/devices", apiLimiter, deviceRouter);
// Mount the KV store router with token-based rate limiting (更宽松的限速)
app.use("/kv", tokenBasedRateLimiter, kvRouter);
// Mount the Device Authorization router with API rate limiting
app.use("/auth", apiLimiter, deviceAuthRouter);
// Mount the Accounts router with API rate limiting
app.use("/accounts", apiLimiter, accountsRouter);
// 兜底404路由 - 处理所有未匹配的路由
app.use((req, res, next) => {

View File

@ -1,17 +1,9 @@
#!/usr/bin/env node
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import dotenv from "dotenv";
dotenv.config();
const PRISMA_DIR = path.join(process.cwd(), "prisma");
const DATABASE_TYPE = process.env.DATABASE_TYPE || "sqlite";
const DATABASE_URL =
DATABASE_TYPE === "sqlite"
? "file:/data/db.sqlite"
: process.env.DATABASE_URL;
// 🔄 执行数据库迁移函数
function runDatabaseMigration() {
@ -28,48 +20,6 @@ function runDatabaseMigration() {
// 🧱 数据库初始化函数
function setupDatabase() {
try {
// 如果是 SQLite确保 /data 目录存在
if (DATABASE_TYPE === "sqlite") {
if (!fs.existsSync("/data")) {
fs.mkdirSync("/data", { recursive: true });
}
} else if (!DATABASE_URL) {
console.error("❌ 缺少 DATABASE_URL 环境变量");
process.exit(1);
}
// 从对应数据库类型的配置目录中复制配置文件
const sourceDir = path.join(PRISMA_DIR, "database", DATABASE_TYPE);
if (!fs.existsSync(sourceDir)) {
console.error(`❌ 数据库配置未找到:${sourceDir}`);
process.exit(1);
}
// 递归复制函数
function copyRecursive(src, dest) {
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
copyRecursive(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
}
}
// 将所有配置文件和目录复制到 prisma 根目录下
const entries = fs.readdirSync(sourceDir);
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry);
const targetPath = path.join(PRISMA_DIR, entry);
copyRecursive(sourcePath, targetPath);
}
console.log(`✅ 已复制 ${DATABASE_TYPE} 数据库配置文件和目录`);
// 执行数据库迁移
runDatabaseMigration();
} catch (error) {
@ -95,7 +45,6 @@ function buildLocal() {
// 🚀 启动服务函数
function startServer() {
try {
console.log(`🚀 使用 ${DATABASE_TYPE} 数据库启动服务中...`);
execSync("npm run start", { stdio: "inherit" }); // 启动项目
} catch (error) {
console.error("❌ 服务启动失败:", error.message);

191
cli/README.md Normal file
View File

@ -0,0 +1,191 @@
# 设备授权流程 - CLI 工具
命令行工具,用于通过设备授权流程获取访问令牌。支持两种授权模式:
- **设备代码模式** (`get-token.js`) - 用户手动输入设备代码完成授权
- **回调模式** (`get-token-callback.js`) - 通过浏览器回调自动完成授权
## 使用方法
### 1. 设备代码模式推荐用于无GUI环境
```bash
node cli/get-token.js
```
### 2. 回调模式(推荐用于桌面环境)
```bash
node cli/get-token-callback.js
```
### 环境变量配置
两种模式都支持以下环境变量:
```bash
# 设置API服务器地址默认: http://localhost:3030
export API_BASE_URL=https://your-api-server.com
# 设置授权页面地址(默认: http://localhost:5173/authorize
export AUTH_PAGE_URL=https://your-classworks-frontend.com/authorize
# 设置应用ID默认: 1
export APP_ID=1
# 设置站点密钥(如果需要)
export SITE_KEY=your-site-key
# 回调模式特有配置
export CALLBACK_PORT=8080 # 回调服务器端口(默认: 8080
export TIMEOUT=300 # 授权超时时间(默认: 300秒
# 运行工具
node cli/get-token.js # 设备代码模式
node cli/get-token-callback.js # 回调模式
```
### 使其可执行Linux/Mac
```bash
chmod +x cli/get-token.js
./cli/get-token.js
```
## 工作流程
### 设备代码模式 (`get-token.js`)
1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码
2. **显示授权链接** - 在终端显示完整的授权URL包含设备代码
3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权
4. **获取令牌** - 工具自动轮询并获取令牌
5. **保存令牌** - 令牌会保存到 `~/.classworks/token.txt`
### 回调模式 (`get-token-callback.js`)
1. **获取设备UUID** - 自动获取或生成设备UUID
2. **启动回调服务器** - 在本地启动HTTP服务器监听回调
3. **打开授权页面** - 自动在浏览器中打开授权页面
4. **用户授权** - 用户在浏览器中完成授权操作
5. **接收回调** - 本地服务器接收授权回调并获取令牌
6. **保存令牌** - 令牌会保存到 `~/.classworks/token-callback.txt`
## 输出示例
### 设备代码模式输出
```text
设备授权流程 - 令牌获取工具
✓ 设备授权码生成成功!
============================================================
请访问以下地址完成授权:
https://classworks.xiaomo.tech/authorize?app_id=1&mode=devicecode&devicecode=1234-ABCD
设备授权码: 1234-ABCD
============================================================
授权码有效期: 15 分钟
API服务器: http://localhost:3030
请在浏览器中打开上述地址,或在授权页面手动输入设备代码
等待授权中...
等待授权... (1/100)
等待授权... (2/100)
==================================================
✓ 授权成功!令牌获取完成
==================================================
您的访问令牌:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
✓ 令牌已保存到: /home/user/.classworks/token.txt
使用示例:
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
```
### 回调模式输出
```text
回调授权流程 - 令牌获取工具
正在获取设备UUID...
✓ 设备UUID: 1234567890abcdef1234567890abcdef
============================================================
请访问以下地址完成授权:
http://localhost:5173/authorize?app_id=1&mode=callback&callback_url=http://localhost:8080/callback&state=abc123
设备UUID: 1234567890abcdef1234567890abcdef
状态参数: abc123
============================================================
回调地址: http://localhost:8080/callback
API服务器: http://localhost:3030
超时时间: 300 秒
正在启动回调服务器...
✓ 回调服务器已启动: http://localhost:8080/callback
正在尝试打开浏览器...
✓ 已尝试打开浏览器
等待授权完成...
==================================================
✓ 授权成功!令牌获取完成
==================================================
您的访问令牌:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
✓ 令牌已保存到: /home/user/.classworks/token-callback.txt
使用示例:
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
```
## 配置选项
### 通用配置
可以通过修改相应文件中的 `CONFIG` 对象或设置环境变量来调整:
- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3030`
- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: `http://localhost:5173/authorize`
- `appId` / `APP_ID` - 应用ID默认: 1
- `siteKey` / `SITE_KEY` - 站点密钥(如果需要)
### 设备代码模式专用配置
- `pollInterval` - 轮询间隔默认3秒
- `maxPolls` - 最大轮询次数默认100次
### 回调模式专用配置
- `callbackPort` / `CALLBACK_PORT` - 回调服务器端口(默认: 8080
- `timeout` / `TIMEOUT` - 授权超时时间(秒,默认: 300
- `callbackPath` - 回调路径(默认: /callback
## 错误处理
### 设备代码模式
- 如果设备代码过期,会显示错误并退出
- 如果轮询超时默认5分钟会显示超时错误
- 如果无法连接到服务器,会显示连接错误
### 回调模式
- 如果回调端口被占用,会提示更换端口
- 如果授权超时,会显示超时错误并提示延长超时时间
- 如果状态参数不匹配会拒绝授权防止CSRF攻击
- 如果无法连接到服务器,会显示连接错误
## 选择模式建议
- **设备代码模式** - 适用于无GUI环境、服务器环境、或无法启动本地服务器的场景
- **回调模式** - 适用于桌面环境、开发环境、或希望更流畅授权体验的场景

422
cli/get-token-callback.js Normal file
View File

@ -0,0 +1,422 @@
#!/usr/bin/env node
/**
* 回调授权流程 - 命令行工具
*
* 用于演示回调授权流程获取访问令牌
* 通过启动本地HTTP服务器接收回调来获取令牌
*
* 使用方法
* node cli/get-token-callback.js
* 或配置为可执行chmod +x cli/get-token-callback.js && ./cli/get-token-callback.js
*/
import http from 'http';
import url from 'url';
import { randomBytes } from 'crypto';
// 配置
const CONFIG = {
// API服务器地址
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
// 站点密钥
siteKey: process.env.SITE_KEY || '',
// 应用ID
appId: process.env.APP_ID || '1',
// 授权页面地址Classworks前端
authPageUrl: process.env.AUTH_PAGE_URL || 'http://localhost:5173/authorize',
// 本地回调服务器端口
callbackPort: process.env.CALLBACK_PORT || '8080',
// 回调路径
callbackPath: '/callback',
// 超时时间(秒)
timeout: 300,
};
// 颜色输出
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message, color = '') {
console.log(`${color}${message}${colors.reset}`);
}
function logSuccess(message) {
log(`${message}`, colors.green);
}
function logError(message) {
log(`${message}`, colors.red);
}
function logInfo(message) {
log(` ${message}`, colors.cyan);
}
function logWarning(message) {
log(`${message}`, colors.yellow);
}
// HTTP请求封装
async function request(path, options = {}) {
const requestUrl = `${CONFIG.baseUrl}${path}`;
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (CONFIG.siteKey) {
headers['X-Site-Key'] = CONFIG.siteKey;
}
try {
const response = await fetch(requestUrl, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}`);
}
return data;
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
}
throw error;
}
}
// 生成随机状态字符串
function generateState() {
return randomBytes(16).toString('hex');
}
// 获取设备UUID
async function getDeviceUuid() {
try {
const deviceInfo = await request('/device/info');
return deviceInfo.uuid;
} catch (error) {
// 如果设备不存在生成新的UUID
const uuid = randomBytes(16).toString('hex');
logInfo(`生成新的设备UUID: ${uuid}`);
return uuid;
}
}
// 创建回调服务器
function createCallbackServer(state) {
return new Promise((resolve, reject) => {
let server;
let resolved = false;
const handleRequest = (req, res) => {
if (resolved) return;
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === CONFIG.callbackPath) {
const { token, error, state: returnedState } = parsedUrl.query;
// 验证状态参数
if (returnedState !== state) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">授权失败</h1>
<p>状态参数不匹配可能存在安全风险</p>
<p>请重新尝试授权流程</p>
</body>
</html>
`);
resolved = true;
server.close();
reject(new Error('状态参数不匹配'));
return;
}
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">授权失败</h1>
<p>${error}</p>
<p>您可以关闭此页面并重新尝试</p>
</body>
</html>
`);
resolved = true;
server.close();
reject(new Error(error));
return;
}
if (token) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.success { color: #2e7d32; }
.token { background: #f5f5f5; padding: 10px; border-radius: 4px; margin: 20px; font-family: monospace; word-break: break-all; }
</style>
</head>
<body>
<h1 class="success">授权成功</h1>
<p>令牌已成功获取您可以关闭此页面</p>
<div class="token">${token}</div>
<p><small>令牌已自动复制到命令行界面</small></p>
</body>
</html>
`);
resolved = true;
server.close();
resolve(token);
return;
}
// 如果没有token和error参数
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>无效请求</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">无效请求</h1>
<p>缺少必要的参数</p>
<p>请重新尝试授权流程</p>
</body>
</html>
`);
resolved = true;
server.close();
reject(new Error('缺少必要的参数'));
} else {
// 404 for other paths
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
};
server = http.createServer(handleRequest);
server.listen(CONFIG.callbackPort, (err) => {
if (err) {
reject(err);
} else {
logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
}
});
// 设置超时
setTimeout(() => {
if (!resolved) {
resolved = true;
server.close();
reject(new Error('授权超时'));
}
}, CONFIG.timeout * 1000);
server.on('error', (err) => {
if (!resolved) {
resolved = true;
reject(err);
}
});
});
}
// 打开浏览器
async function openBrowser(url) {
const { spawn } = await import('child_process');
let command;
let args;
if (process.platform === 'win32') {
command = 'cmd';
args = ['/c', 'start', url];
} else if (process.platform === 'darwin') {
command = 'open';
args = [url];
} else {
command = 'xdg-open';
args = [url];
}
try {
spawn(command, args, { detached: true, stdio: 'ignore' });
logSuccess('已尝试打开浏览器');
} catch (error) {
logWarning('无法自动打开浏览器,请手动打开授权链接');
}
}
// 显示授权信息
function displayAuthInfo(authUrl, deviceUuid, state) {
console.log('\n' + '='.repeat(60));
log(` 请访问以下地址完成授权:`, colors.bright);
console.log('');
log(` ${authUrl}`, colors.cyan + colors.bright);
console.log('');
log(` 设备UUID: ${deviceUuid}`, colors.green);
log(` 状态参数: ${state}`, colors.dim);
console.log('='.repeat(60));
logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
logInfo(`API服务器: ${CONFIG.baseUrl}`);
logInfo(`超时时间: ${CONFIG.timeout}`);
console.log('');
}
// 保存令牌到文件
async function saveToken(token) {
const fs = await import('fs');
const path = await import('path');
const os = await import('os');
const tokenDir = path.join(os.homedir(), '.classworks');
const tokenFile = path.join(tokenDir, 'token-callback.txt');
try {
// 确保目录存在
if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, { recursive: true });
}
// 写入令牌
fs.writeFileSync(tokenFile, token, 'utf8');
logSuccess(`令牌已保存到: ${tokenFile}`);
} catch (error) {
logWarning(`无法保存令牌到文件: ${error.message}`);
logInfo('您可以手动保存令牌');
}
}
// 主函数
async function main() {
console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n');
try {
// 检查配置
if (!CONFIG.siteKey) {
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
logInfo('设置方法: export SITE_KEY=your-site-key');
console.log('');
}
// 1. 获取设备UUID
logInfo('正在获取设备UUID...');
const deviceUuid = await getDeviceUuid();
logSuccess(`设备UUID: ${deviceUuid}`);
// 2. 生成状态参数
const state = generateState();
// 3. 构建回调URL
const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`;
// 4. 构建授权URL
const authUrl = new URL(CONFIG.authPageUrl);
authUrl.searchParams.set('app_id', CONFIG.appId);
authUrl.searchParams.set('mode', 'callback');
authUrl.searchParams.set('callback_url', callbackUrl);
authUrl.searchParams.set('state', state);
// 5. 显示授权信息
displayAuthInfo(authUrl.toString(), deviceUuid, state);
// 6. 启动回调服务器
logInfo('正在启动回调服务器...');
const serverPromise = createCallbackServer(state);
// 7. 打开浏览器
logInfo('正在尝试打开浏览器...');
await openBrowser(authUrl.toString());
// 8. 等待授权完成
logInfo('等待授权完成...\n');
const token = await serverPromise;
// 9. 显示令牌
console.log('\n' + '='.repeat(50));
logSuccess('授权成功!令牌获取完成');
console.log('='.repeat(50));
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
log(token, colors.green);
console.log('');
// 10. 保存令牌
await saveToken(token);
// 11. 使用示例
console.log('\n' + colors.bright + '使用示例:' + colors.reset);
console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
console.log('');
process.exit(0);
} catch (error) {
console.log('');
logError(`错误: ${error.message}`);
// 提供一些常见问题的解决方案
if (error.message.includes('EADDRINUSE')) {
logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`);
logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`);
} else if (error.message.includes('无法连接到服务器')) {
logInfo('请检查API服务器是否正在运行');
logInfo(`当前API地址: ${CONFIG.baseUrl}`);
} else if (error.message.includes('授权超时')) {
logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`);
logInfo('您可以设置更长的超时时间TIMEOUT=600 node cli/get-token-callback.js');
}
console.log('');
process.exit(1);
}
}
// 运行
main();

233
cli/get-token.js Normal file
View File

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

39
config/oauth.js Normal file
View File

@ -0,0 +1,39 @@
// OAuth 提供者配置
export const oauthProviders = {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
authorizationURL: "https://github.com/login/oauth/authorize",
tokenURL: "https://github.com/login/oauth/access_token",
userInfoURL: "https://api.github.com/user",
scope: "read:user user:email",
name: "GitHub",
icon: "github",
color: "#24292e",
description: "使用 GitHub 账号登录",
},
zerocat: {
clientId: process.env.ZEROCAT_CLIENT_ID,
clientSecret: process.env.ZEROCAT_CLIENT_SECRET,
authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize",
tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
scope: "user:basic user:email",
name: "ZeroCat",
icon: "zerocat",
color: "#6366f1",
description: "使用 ZeroCat 账号登录",
},
};
// 获取OAuth回调URL
export function getCallbackURL(provider) {
const baseUrl = process.env.BASE_URL;
return `${baseUrl}/accounts/oauth/${provider}/callback`;
}
// 生成随机state参数
export function generateState() {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}

View File

@ -10,7 +10,6 @@ services:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_TYPE=sqlite
- DATABASE_URL=
volumes:
- .data:/app/data

View File

@ -1,237 +0,0 @@
import { siteKey } from "../utils/config.js";
import { PrismaClient } from "@prisma/client";
import { DecodeAndVerifyPassword, verifySiteKey } from "../utils/crypto.js";
const prisma = new PrismaClient();
export const ACCESS_TYPES = {
PUBLIC: "PUBLIC",
PROTECTED: "PROTECTED",
PRIVATE: "PRIVATE",
};
export const checkSiteKey = (req, res, next) => {
if (!siteKey) {
return next();
}
const providedKey =
req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
if (!verifySiteKey(providedKey, siteKey)) {
return res.status(401).json({
statusCode: 401,
message: "此服务器已开启站点密钥验证,请提供有效的站点密钥",
});
}
next();
};
async function getOrCreateDevice(uuid, className) {
try {
let device = await prisma.device.findUnique({
where: { uuid },
});
if (!device) {
try {
device = await prisma.device.create({
data: {
uuid,
name: className || null,
accessType: ACCESS_TYPES.PUBLIC,
},
});
} catch (error) {
if (error.code === "P2002") {
device = await prisma.device.findUnique({
where: { uuid },
});
} else {
throw error;
}
}
}
if (
device &&
!device.password &&
device.accessType !== ACCESS_TYPES.PUBLIC
) {
device = await prisma.device.update({
where: { uuid },
data: { accessType: ACCESS_TYPES.PUBLIC },
});
}
return device;
} catch (error) {
console.error("Error in getOrCreateDevice:", error);
throw error;
}
}
export const deviceInfoMiddleware = async (req, res, next) => {
const { namespace } = req.params;
try {
const device = await getOrCreateDevice(namespace, req.body?.className);
res.locals.device = device;
next();
} catch (error) {
console.error("Auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const authMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
try {
const device = await getOrCreateDevice(namespace, req.body?.className);
res.locals.device = device;
if (device.password && password !== device.password) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
next();
} catch (error) {
console.error("Auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const readAuthMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
try {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
if (
[ACCESS_TYPES.PUBLIC, ACCESS_TYPES.PROTECTED].includes(device.accessType)
) {
return next();
}
if (
!device.password ||
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
next();
} catch (error) {
console.error("Read auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const writeAuthMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
try {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
if (device.accessType === ACCESS_TYPES.PUBLIC) {
return next();
}
if (
!device.password ||
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
next();
} catch (error) {
console.error("Write auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const removePasswordMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
const providedKey =
req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
try {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
if (!verifySiteKey(providedKey, siteKey)) {
return res.status(401).json({
statusCode: 401,
message: "此服务器已开启站点密钥验证,请提供有效的站点密钥",
});
}
if (
device.password &&
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
await prisma.device.update({
where: { uuid: device.uuid },
data: {
password: null,
accessType: ACCESS_TYPES.PUBLIC,
},
});
next();
} catch (error) {
console.error("Remove password middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};

126
middleware/device.js Normal file
View File

@ -0,0 +1,126 @@
/**
* 设备管理中间件
*
* 提供统一的设备UUID处理逻辑
* 1. deviceMiddleware - 自动获取或创建设备将设备信息存储到res.locals.device
* 2. deviceInfoMiddleware - 仅获取设备信息不创建新设备
* 3. passwordMiddleware - 验证设备密码
*/
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
import { verifyDevicePassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
/**
* 设备中间件 - 统一处理设备UUID
*
* 从req.params.deviceUuidreq.params.namespace或req.body.deviceUuid获取UUID
* 如果设备不存在则自动创建
* 将设备信息存储到res.locals.device
*
* 使用方式
* router.post('/path', deviceMiddleware, handler)
* router.get('/path/:deviceUuid', deviceMiddleware, handler)
*/
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
const deviceUuid = req.params.deviceUuid || req.params.namespace || req.body.deviceUuid;
if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID"));
}
// 查找或创建设备
let device = await prisma.device.findUnique({
where: { uuid: deviceUuid },
});
if (!device) {
// 设备不存在,自动创建
device = await prisma.device.create({
data: {
uuid: deviceUuid,
name: null,
password: null,
passwordHint: null,
accountId: null,
},
});
}
// 将设备信息存储到res.locals
res.locals.device = device;
next();
});
/**
* 设备信息中间件 - 仅获取设备信息不创建新设备
*
* 从req.params.deviceUuid获取UUID
* 如果设备不存在则返回404错误
* 将设备信息存储到res.locals.device
*
* 使用方式
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
*/
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
const deviceUuid = req.params.deviceUuid || req.params.namespace;
if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID"));
}
// 查找设备
const device = await prisma.device.findUnique({
where: { uuid: deviceUuid },
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
// 将设备信息存储到res.locals
res.locals.device = device;
next();
});
/**
* 密码验证中间件 - 验证设备密码
*
* 前置条件必须先使用deviceMiddleware或deviceInfoMiddleware
* 从req.body.password获取密码
* 如果设备有密码但未提供或密码错误则返回401错误
*
* 特殊规则如果设备绑定了账户且req.account存在且匹配则跳过密码验证
*
* 使用方式
* router.post('/path', deviceMiddleware, passwordMiddleware, handler)
*/
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const { password } = req.body;
if (!device) {
return next(errors.createError(500, "设备信息未加载请先使用deviceMiddleware"));
}
// 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
if (device.accountId && req.account && req.account.id === device.accountId) {
return next();
}
// 如果设备有密码,验证密码
if (device.password) {
if (!password) {
return next(errors.createError(401, "设备需要密码"));
}
const isValid = await verifyDevicePassword(password, device.password);
if (!isValid) {
return next(errors.createError(401, "密码错误"));
}
}
next();
});

54
middleware/jwt-auth.js Normal file
View File

@ -0,0 +1,54 @@
/**
* 纯账户JWT认证中间件
*
* 只验证账户JWT是否正确不需要设备上下文
* 适用于只需要账户验证的接口
*/
import { verifyToken } from "../utils/jwt.js";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
/**
* 纯JWT认证中间件
* 只验证Bearer token并将账户信息存储到res.locals
*/
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);
// 验证JWT token
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;
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return next(errors.createError(401, "无效的JWT token"));
}
if (error.name === 'TokenExpiredError') {
return next(errors.createError(401, "JWT token已过期"));
}
return next(errors.createError(500, "认证过程出错"));
}
};

64
middleware/kvTokenAuth.js Normal file
View File

@ -0,0 +1,64 @@
/**
* KV接口专用Token认证中间件
*
* 仅验证app token设置设备和应用信息到res.locals
* 适用于所有KV相关的接口
*/
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
/**
* KV Token认证中间件
* 从请求中提取token支持多种方式验证后将设备和应用信息注入到res.locals
*/
export const kvTokenAuth = async (req, res, next) => {
try {
// 从多种途径获取token
const token = extractToken(req);
if (!token) {
return next(errors.createError(401, "需要提供有效的token"));
}
// 查找token对应的应用安装信息
const appInstall = await prisma.appInstall.findUnique({
where: { token },
include: {
device: true,
},
});
if (!appInstall) {
return next(errors.createError(401, "无效的token"));
}
// 将信息存储到res.locals供后续使用
res.locals.device = appInstall.device;
res.locals.appInstall = appInstall;
res.locals.deviceId = appInstall.device.id;
next();
} catch (error) {
next(error);
}
};
/**
* 从请求中提取token
* 支持的方式
* 1. Header: x-app-token
* 2. Query: token apptoken
* 3. Body: token apptoken
*/
function extractToken(req) {
return (
req.headers["x-app-token"] ||
req.query.token ||
req.query.apptoken ||
(req.body && req.body.token) ||
(req.body && req.body.apptoken)
);
}

View File

@ -11,6 +11,25 @@ export const getClientIp = (req) => {
);
};
// 从请求中提取Token的函数
const extractToken = (req) => {
return (
req.headers["x-app-token"] ||
req.query.apptoken ||
req.body?.apptoken ||
null
);
};
// 获取限速键优先使用token没有token则使用IP
export const getRateLimitKey = (req) => {
const token = extractToken(req);
if (token) {
return `token:${token}`;
}
return `ip:${getClientIp(req)}`;
};
// 配置全局限速中间件
export const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
@ -26,7 +45,7 @@ export const globalLimiter = rateLimit({
// API限速器
export const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 50, // 每个IP在windowMs时间内最多允许50个请求
limit: 100, // 每个IP在windowMs时间内最多允许100个请求
standardHeaders: "draft-7",
legacyHeaders: false,
message: "API请求过于频繁请稍后再试",
@ -83,6 +102,56 @@ export const batchLimiter = rateLimit({
skipFailedRequests: false,
});
// === Token 专用限速器(更宽松的限制) ===
// Token 读操作限速器
export const tokenReadLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 1024, // 每个token在1分钟内最多1024次读操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "读操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// Token 写操作限速器
export const tokenWriteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 512, // 每个token在1分钟内最多512次写操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// Token 删除操作限速器
export const tokenDeleteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 256, // 每个token在1分钟内最多256次删除操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// Token 批量操作限速器
export const tokenBatchLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 128, // 每个token在1分钟内最多128次批量操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "批量操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 创建一个路由处理中间件根据HTTP方法应用不同的限速器
export const methodBasedRateLimiter = (req, res, next) => {
// 检查是否是批量导入路由
@ -105,3 +174,26 @@ export const methodBasedRateLimiter = (req, res, next) => {
// 其他方法使用API限速
return apiLimiter(req, res, next);
};
// Token 专用路由中间件根据HTTP方法应用不同的Token限速器
export const tokenBasedRateLimiter = (req, res, next) => {
// 检查是否是批量导入路由
if (req.method === "POST" && (req.path.endsWith("/_batchimport") || req.path.endsWith("/batch-import"))) {
return tokenBatchLimiter(req, res, next);
} else if (req.method === "GET") {
// 读操作使用Token读限速
return tokenReadLimiter(req, res, next);
} else if (
req.method === "POST" ||
req.method === "PUT" ||
req.method === "PATCH"
) {
// 写操作使用Token写限速
return tokenWriteLimiter(req, res, next);
} else if (req.method === "DELETE") {
// 删除操作使用Token删除限速
return tokenDeleteLimiter(req, res, next);
}
// 其他方法使用Token读限速
return tokenReadLimiter(req, res, next);
};

131
middleware/uuidAuth.js Normal file
View File

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

View File

@ -5,9 +5,8 @@
"scripts": {
"start": "node ./bin/www",
"prisma": "prisma generate",
"prisma:pull": "prisma db pull",
"dev": "NODE_ENV=development nodemon node .bin/www",
"migrate": "node ./scripts/batchMigrate.js"
"get-token": "node ./cli/get-token.js"
},
"type": "module",
"dependencies": {
@ -17,7 +16,7 @@
"@opentelemetry/sdk-node": "^0.201.1",
"@opentelemetry/sdk-trace-base": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@prisma/client": "6.8.2",
"@prisma/client": "6.16.3",
"axios": "^1.9.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
@ -30,10 +29,11 @@
"express-rate-limit": "^7.5.0",
"http-errors": "~2.0.0",
"js-base64": "^3.7.7",
"jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"prisma": "6.8.2"
"prisma": "6.16.2"
}
}

371
pnpm-lock.yaml generated
View File

@ -27,8 +27,8 @@ importers:
specifier: ^1.34.0
version: 1.34.0
'@prisma/client':
specifier: 6.8.2
version: 6.8.2(prisma@6.8.2)
specifier: 6.16.3
version: 6.16.3(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)
axios:
specifier: ^1.9.0
version: 1.9.0(debug@4.4.1)
@ -65,6 +65,9 @@ importers:
js-base64:
specifier: ^3.7.7
version: 3.7.7
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
morgan:
specifier: ~1.10.0
version: 1.10.0
@ -73,8 +76,8 @@ importers:
version: 11.1.0
devDependencies:
prisma:
specifier: 6.8.2
version: 6.8.2
specifier: 6.16.2
version: 6.16.2(typescript@5.8.3)
packages:
@ -603,8 +606,8 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@prisma/client@6.8.2':
resolution: {integrity: sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==}
'@prisma/client@6.16.3':
resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==}
engines: {node: '>=18.18'}
peerDependencies:
prisma: '*'
@ -615,23 +618,23 @@ packages:
typescript:
optional: true
'@prisma/config@6.8.2':
resolution: {integrity: sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==}
'@prisma/config@6.16.2':
resolution: {integrity: sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==}
'@prisma/debug@6.8.2':
resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
'@prisma/debug@6.16.2':
resolution: {integrity: sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==}
'@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e':
resolution: {integrity: sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==}
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43':
resolution: {integrity: sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==}
'@prisma/engines@6.8.2':
resolution: {integrity: sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==}
'@prisma/engines@6.16.2':
resolution: {integrity: sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==}
'@prisma/fetch-engine@6.8.2':
resolution: {integrity: sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==}
'@prisma/fetch-engine@6.16.2':
resolution: {integrity: sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==}
'@prisma/get-platform@6.8.2':
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
'@prisma/get-platform@6.16.2':
resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@ -663,6 +666,9 @@ packages:
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@types/aws-lambda@8.10.147':
resolution: {integrity: sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==}
@ -758,10 +764,21 @@ packages:
brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies:
magicast: ^0.3.5
peerDependenciesMeta:
magicast:
optional: true
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@ -774,6 +791,13 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
@ -795,6 +819,13 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.2.2:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
content-disposition@1.0.0:
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
engines: {node: '>= 0.6'}
@ -839,6 +870,13 @@ packages:
supports-color:
optional: true
deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -847,17 +885,30 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
dotenv@16.5.0:
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
engines: {node: '>=12'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
effect@3.16.12:
resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@ -866,6 +917,10 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
empathic@2.0.0:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
@ -907,9 +962,16 @@ packages:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'}
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
filelist@1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
@ -964,6 +1026,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
giget@2.0.0:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
google-logging-utils@0.0.2:
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
engines: {node: '>=14'}
@ -1044,9 +1110,40 @@ packages:
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
@ -1106,6 +1203,9 @@ packages:
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
engines: {node: ^18 || ^20 || >= 21}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@ -1119,6 +1219,11 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
nypm@0.6.2:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -1127,6 +1232,9 @@ packages:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
on-finished@2.3.0:
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
engines: {node: '>= 0.8'}
@ -1153,6 +1261,12 @@ packages:
resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
engines: {node: '>=16'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
@ -1164,6 +1278,9 @@ packages:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
@ -1180,8 +1297,8 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prisma@6.8.2:
resolution: {integrity: sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==}
prisma@6.16.2:
resolution: {integrity: sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==}
engines: {node: '>=18.18'}
hasBin: true
peerDependencies:
@ -1201,6 +1318,9 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'}
@ -1213,6 +1333,13 @@ packages:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@ -1239,6 +1366,11 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
@ -1289,6 +1421,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@ -1300,6 +1435,11 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -2114,34 +2254,40 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0)
'@prisma/client@6.8.2(prisma@6.8.2)':
'@prisma/client@6.16.3(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)':
optionalDependencies:
prisma: 6.8.2
prisma: 6.16.2(typescript@5.8.3)
typescript: 5.8.3
'@prisma/config@6.8.2':
'@prisma/config@6.16.2':
dependencies:
jiti: 2.4.2
c12: 3.1.0
deepmerge-ts: 7.1.5
effect: 3.16.12
empathic: 2.0.0
transitivePeerDependencies:
- magicast
'@prisma/debug@6.8.2': {}
'@prisma/debug@6.16.2': {}
'@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e': {}
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': {}
'@prisma/engines@6.8.2':
'@prisma/engines@6.16.2':
dependencies:
'@prisma/debug': 6.8.2
'@prisma/engines-version': 6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e
'@prisma/fetch-engine': 6.8.2
'@prisma/get-platform': 6.8.2
'@prisma/debug': 6.16.2
'@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43
'@prisma/fetch-engine': 6.16.2
'@prisma/get-platform': 6.16.2
'@prisma/fetch-engine@6.8.2':
'@prisma/fetch-engine@6.16.2':
dependencies:
'@prisma/debug': 6.8.2
'@prisma/engines-version': 6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e
'@prisma/get-platform': 6.8.2
'@prisma/debug': 6.16.2
'@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43
'@prisma/get-platform': 6.16.2
'@prisma/get-platform@6.8.2':
'@prisma/get-platform@6.16.2':
dependencies:
'@prisma/debug': 6.8.2
'@prisma/debug': 6.16.2
'@protobufjs/aspromise@1.1.2': {}
@ -2166,6 +2312,8 @@ snapshots:
'@protobufjs/utf8@1.1.0': {}
'@standard-schema/spec@1.0.0': {}
'@types/aws-lambda@8.10.147': {}
'@types/bunyan@1.8.11':
@ -2279,8 +2427,25 @@ snapshots:
dependencies:
balanced-match: 1.0.2
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {}
c12@3.1.0:
dependencies:
chokidar: 4.0.3
confbox: 0.2.2
defu: 6.1.4
dotenv: 16.6.1
exsolve: 1.0.7
giget: 2.0.0
jiti: 2.4.2
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
pkg-types: 2.3.0
rc9: 2.1.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@ -2296,6 +2461,14 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
citty@0.1.6:
dependencies:
consola: 3.4.2
cjs-module-lexer@1.4.3: {}
cliui@8.0.1:
@ -2316,6 +2489,10 @@ snapshots:
concat-map@0.0.1: {}
confbox@0.2.2: {}
consola@3.4.2: {}
content-disposition@1.0.0:
dependencies:
safe-buffer: 5.2.1
@ -2346,26 +2523,45 @@ snapshots:
dependencies:
ms: 2.1.3
deepmerge-ts@7.1.5: {}
defu@6.1.4: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
destr@2.0.5: {}
dotenv@16.5.0: {}
dotenv@16.6.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
effect@3.16.12:
dependencies:
'@standard-schema/spec': 1.0.0
fast-check: 3.23.2
ejs@3.1.10:
dependencies:
jake: 10.9.2
emoji-regex@8.0.0: {}
empathic@2.0.0: {}
encodeurl@2.0.0: {}
es-define-property@1.0.1: {}
@ -2425,8 +2621,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
exsolve@1.0.7: {}
extend@3.0.2: {}
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
filelist@1.0.4:
dependencies:
minimatch: 5.1.6
@ -2501,6 +2703,15 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
giget@2.0.0:
dependencies:
citty: 0.1.6
consola: 3.4.2
defu: 6.1.4
node-fetch-native: 1.6.7
nypm: 0.6.2
pathe: 2.0.3
google-logging-utils@0.0.2: {}
gopd@1.2.0: {}
@ -2574,8 +2785,46 @@ snapshots:
dependencies:
bignumber.js: 9.3.0
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.2
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
lodash.camelcase@4.3.0: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.once@4.1.1: {}
long@5.3.2: {}
math-intrinsics@1.1.0: {}
@ -2624,16 +2873,28 @@ snapshots:
node-addon-api@8.3.1: {}
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-gyp-build@4.8.4: {}
nypm@0.6.2:
dependencies:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 2.3.0
tinyexec: 1.0.1
object-assign@4.1.1: {}
object-inspect@1.13.3: {}
ohash@2.0.11: {}
on-finished@2.3.0:
dependencies:
ee-first: 1.1.1
@ -2654,6 +2915,10 @@ snapshots:
path-to-regexp@8.2.0: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
pg-int8@1.0.1: {}
pg-protocol@1.9.5: {}
@ -2666,6 +2931,12 @@ snapshots:
postgres-date: 1.0.7
postgres-interval: 1.2.0
pkg-types@2.3.0:
dependencies:
confbox: 0.2.2
exsolve: 1.0.7
pathe: 2.0.3
postgres-array@2.0.0: {}
postgres-bytea@1.0.0: {}
@ -2676,10 +2947,14 @@ snapshots:
dependencies:
xtend: 4.0.2
prisma@6.8.2:
prisma@6.16.2(typescript@5.8.3):
dependencies:
'@prisma/config': 6.8.2
'@prisma/engines': 6.8.2
'@prisma/config': 6.16.2
'@prisma/engines': 6.16.2
optionalDependencies:
typescript: 5.8.3
transitivePeerDependencies:
- magicast
protobufjs@7.5.4:
dependencies:
@ -2703,6 +2978,8 @@ snapshots:
proxy-from-env@1.1.0: {}
pure-rand@6.1.0: {}
qs@6.14.0:
dependencies:
side-channel: 1.1.0
@ -2716,6 +2993,13 @@ snapshots:
iconv-lite: 0.6.3
unpipe: 1.0.0
rc9@2.1.2:
dependencies:
defu: 6.1.4
destr: 2.0.5
readdirp@4.1.2: {}
require-directory@2.1.1: {}
require-in-the-middle@7.5.2:
@ -2748,6 +3032,8 @@ snapshots:
safer-buffer@2.1.2: {}
semver@7.7.2: {}
send@1.2.0:
dependencies:
debug: 4.4.1
@ -2823,6 +3109,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tinyexec@1.0.1: {}
toidentifier@1.0.1: {}
tr46@0.0.3: {}
@ -2833,6 +3121,9 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.1
typescript@5.8.3:
optional: true
undici-types@6.21.0: {}
undici-types@7.11.0: {}

View File

@ -1,24 +0,0 @@
-- CreateTable
CREATE TABLE `KVStore` (
`namespace` VARCHAR(191) NOT NULL,
`key` VARCHAR(191) NOT NULL,
`value` JSON NOT NULL,
`creatorIp` VARCHAR(191) NULL DEFAULT '',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`namespace`, `key`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Device` (
`uuid` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NULL,
`passwordHint` VARCHAR(191) NULL,
`name` VARCHAR(191) NULL,
`accessType` ENUM('PUBLIC', 'PROTECTED', 'PRIVATE') NOT NULL DEFAULT 'PUBLIC',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`uuid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -1,36 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
enum AccessType {
PUBLIC // No password required for read/write
PROTECTED // No password for read, password for write
PRIVATE // Password required for read/write
}
model KVStore {
namespace String
key String
value Json
creatorIp String? @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([namespace, key])
}
model Device {
uuid String @id
password String?
passwordHint String?
name String?
accessType AccessType @default(PUBLIC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -1,27 +0,0 @@
-- CreateEnum
CREATE TYPE "AccessType" AS ENUM ('PUBLIC', 'PROTECTED', 'PRIVATE');
-- CreateTable
CREATE TABLE "KVStore" (
"namespace" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"creatorIp" TEXT DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KVStore_pkey" PRIMARY KEY ("namespace","key")
);
-- CreateTable
CREATE TABLE "Device" (
"uuid" TEXT NOT NULL,
"password" TEXT,
"passwordHint" TEXT,
"name" TEXT,
"accessType" "AccessType" NOT NULL DEFAULT 'PUBLIC',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Device_pkey" PRIMARY KEY ("uuid")
);

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,36 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum AccessType {
PUBLIC // No password required for read/write
PROTECTED // No password for read, password for write
PRIVATE // Password required for read/write
}
model KVStore {
namespace String
key String
value Json
creatorIp String? @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([namespace, key])
}
model Device {
uuid String @id
password String?
passwordHint String?
name String?
accessType AccessType @default(PUBLIC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -1,22 +0,0 @@
-- CreateTable
CREATE TABLE "KVStore" (
"namespace" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"creatorIp" TEXT DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("namespace", "key")
);
-- CreateTable
CREATE TABLE "Device" (
"uuid" TEXT NOT NULL PRIMARY KEY,
"password" TEXT,
"passwordHint" TEXT,
"name" TEXT,
"accessType" TEXT NOT NULL DEFAULT 'PUBLIC',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@ -1,36 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../data/db.db"
}
enum AccessType {
PUBLIC // No password required for read/write
PROTECTED // No password for read, password for write
PRIVATE // Password required for read/write
}
model KVStore {
namespace String
key String
value Json
creatorIp String? @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([namespace, key])
}
model Device {
uuid String @id
password String?
passwordHint String?
name String?
accessType AccessType @default(PUBLIC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -0,0 +1,68 @@
-- CreateTable
CREATE TABLE `KVStore` (
`deviceId` INTEGER NOT NULL,
`key` VARCHAR(191) NOT NULL,
`value` JSON NOT NULL,
`creatorIp` VARCHAR(191) NULL DEFAULT '',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`deviceId`, `key`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Account` (
`id` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`providerId` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NULL,
`name` VARCHAR(191) NULL,
`avatarUrl` VARCHAR(191) NULL,
`providerData` JSON NULL,
`accessToken` VARCHAR(191) NOT NULL,
`refreshToken` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Account_accessToken_key`(`accessToken`),
UNIQUE INDEX `Account_provider_providerId_key`(`provider`, `providerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Device` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`uuid` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NULL,
`accountId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
`password` VARCHAR(191) NULL,
`passwordHint` VARCHAR(191) NULL,
UNIQUE INDEX `Device_uuid_key`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AppInstall` (
`id` VARCHAR(191) NOT NULL,
`deviceId` INTEGER NOT NULL,
`appId` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`note` VARCHAR(191) NULL,
`installedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AppInstall_token_key`(`token`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `KVStore` ADD CONSTRAINT `KVStore_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Device` ADD CONSTRAINT `Device_accountId_fkey` FOREIGN KEY (`accountId`) REFERENCES `Account`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppInstall` ADD CONSTRAINT `AppInstall_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -3,33 +3,68 @@ generator client {
}
datasource db {
provider = "sqlite"
url = "file:../data/db.db"
}
enum AccessType {
PUBLIC // No password required for read/write
PROTECTED // No password for read, password for write
PRIVATE // Password required for read/write
provider = "mysql"
url = env("DATABASE_URL")
}
model KVStore {
namespace String
deviceId Int // 设备ID作为namespace的一部分
key String
value Json
creatorIp String? @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([namespace, key])
// 关联关系
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@id([deviceId, key])
}
model Account {
id String @id @default(cuid())
provider String // OAuth提供者 (例如: google, github, gitlab等)
providerId String // 提供者返回的用户唯一ID
email String? // 用户邮箱
name String? // 用户名称
avatarUrl String? // 用户头像URL
providerData Json? // OAuth提供者返回的完整信息
accessToken String @unique // 账户访问令牌
refreshToken String? // OAuth refresh token (如果提供者支持)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联的设备
devices Device[]
@@unique([provider, providerId]) // 确保同一提供者的用户ID唯一
}
model Device {
uuid String @id
password String?
passwordHint String?
id Int @id @default(autoincrement())
uuid String @unique // 设备的唯一标识符
name String?
accessType AccessType @default(PUBLIC)
accountId String? // 关联的账户ID
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
password String?
passwordHint String?
// 关联关系
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
appInstalls AppInstall[]
kvStore KVStore[] // 设备相关的KV存储
}
model AppInstall {
id String @id @default(cuid())
deviceId Int // 关联的设备ID
appId String // 应用ID (SHA256 hash)
token String @unique // 应用安装的唯一访问令牌拥有完整KV读写权限
note String? // 安装备注
installedAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联关系
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
}

166
public/auth-error.html Normal file
View File

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

254
public/auth-success.html Normal file
View File

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

537
routes/accounts.js Normal file
View File

@ -0,0 +1,537 @@
import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
import { generateAccountToken, verifyToken } from "../utils/jwt.js";
import { jwtAuth } from "../middleware/jwt-auth.js";
const router = Router();
const prisma = new PrismaClient();
// 存储OAuth state防止CSRF攻击生产环境应使用Redis等
const oauthStates = new Map();
/**
* 生成安全的访问令牌
*/
function generateAccessToken() {
return crypto.randomBytes(32).toString("hex");
}
/**
* 获取支持的OAuth提供者列表
* GET /accounts/oauth/providers
*/
router.get("/oauth/providers", (req, res) => {
const providers = [];
for (const [key, config] of Object.entries(oauthProviders)) {
// 只返回已配置的提供者
if (config.clientId && config.clientSecret) {
providers.push({
id: key,
name: config.name,
icon: config.icon,
color: config.color,
description: config.description,
authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL
});
}
}
res.json({
success: true,
data: providers,
});
});
/**
* 发起OAuth认证
* GET /accounts/oauth/:provider
*
* Query参数:
* - redirect_uri: 前端回调地址可选
*/
router.get("/oauth/:provider", (req, res) => {
const { provider } = req.params;
const { redirect_uri } = req.query;
const providerConfig = oauthProviders[provider];
if (!providerConfig) {
return res.status(400).json({
success: false,
message: `不支持的OAuth提供者: ${provider}`,
});
}
if (!providerConfig.clientId || !providerConfig.clientSecret) {
return res.status(500).json({
success: false,
message: `OAuth提供者 ${provider} 未配置`,
});
}
// 生成state参数
const state = generateState();
// 保存state和redirect_uri5分钟过期
oauthStates.set(state, {
provider,
redirect_uri,
timestamp: Date.now(),
});
// 清理过期的state超过5分钟
for (const [key, value] of oauthStates.entries()) {
if (Date.now() - value.timestamp > 5 * 60 * 1000) {
oauthStates.delete(key);
}
}
// 构建授权URL
const params = new URLSearchParams({
client_id: providerConfig.clientId,
redirect_uri: getCallbackURL(provider),
scope: providerConfig.scope,
state: state,
response_type: "code",
});
// Google需要额外的参数
if (provider === "google") {
params.append("access_type", "offline");
params.append("prompt", "consent");
}
const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`;
// 重定向到OAuth提供者
res.redirect(authUrl);
});
/**
* OAuth回调处理
* GET /accounts/oauth/:provider/callback
*/
router.get("/oauth/:provider/callback", async (req, res) => {
const { provider } = req.params;
const { code, state, error } = req.query;
// 如果OAuth提供者返回错误
if (error) {
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const errorUrl = new URL(frontendBaseUrl);
errorUrl.searchParams.append("error", error);
errorUrl.searchParams.append("provider", provider);
errorUrl.searchParams.append("success", "false");
return res.redirect(errorUrl.toString());
}
// 验证state
const stateData = oauthStates.get(state);
if (!stateData || stateData.provider !== provider) {
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const errorUrl = new URL(frontendBaseUrl);
errorUrl.searchParams.append("error", "invalid_state");
errorUrl.searchParams.append("provider", provider);
errorUrl.searchParams.append("success", "false");
return res.redirect(errorUrl.toString());
}
// 删除已使用的state
oauthStates.delete(state);
const providerConfig = oauthProviders[provider];
try {
// 1. 使用授权码换取访问令牌
const tokenResponse = await fetch(providerConfig.tokenURL, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
code: code,
grant_type: "authorization_code",
redirect_uri: getCallbackURL(provider),
}),
});
const tokenData = await tokenResponse.json();
if (!tokenData.access_token) {
throw new Error("获取访问令牌失败");
}
// 2. 使用访问令牌获取用户信息
const userResponse = await fetch(providerConfig.userInfoURL, {
headers: {
"Authorization": `Bearer ${tokenData.access_token}`,
"Accept": "application/json",
},
});
const userData = await userResponse.json();
// 3. 标准化用户数据(不同提供者返回的字段不同)
let normalizedUser = {};
if (provider === "github") {
normalizedUser = {
providerId: String(userData.id),
email: userData.email,
name: userData.name || userData.login,
avatarUrl: userData.avatar_url,
};
} else if (provider === "zerocat") {
normalizedUser = {
providerId: userData.openid,
email: userData.email_verified ? userData.email : null,
name: userData.nickname || userData.username,
avatarUrl: userData.avatar,
};
}
// 4. 查找或创建账户
let account = await prisma.account.findUnique({
where: {
provider_providerId: {
provider,
providerId: normalizedUser.providerId,
},
},
});
if (account) {
// 更新账户信息
account = await prisma.account.update({
where: { id: account.id },
data: {
email: normalizedUser.email || account.email,
name: normalizedUser.name || account.name,
avatarUrl: normalizedUser.avatarUrl || account.avatarUrl,
providerData: userData,
refreshToken: tokenData.refresh_token || account.refreshToken,
updatedAt: new Date(),
},
});
} else {
// 创建新账户
const accessToken = generateAccessToken();
account = await prisma.account.create({
data: {
provider,
providerId: normalizedUser.providerId,
email: normalizedUser.email,
name: normalizedUser.name,
avatarUrl: normalizedUser.avatarUrl,
providerData: userData,
accessToken,
refreshToken: tokenData.refresh_token,
},
});
}
// 5. 生成JWT token
const jwtToken = generateAccountToken(account);
// 6. 重定向到前端根路径携带JWT token
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const callbackUrl = new URL(frontendBaseUrl);
callbackUrl.searchParams.append("token", jwtToken);
callbackUrl.searchParams.append("provider", provider);
callbackUrl.searchParams.append("success", "true");
res.redirect(callbackUrl.toString());
} catch (error) {
console.error(`OAuth回调处理失败 [${provider}]:`, error);
// 重定向到前端根路径,携带错误信息
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const errorUrl = new URL(frontendBaseUrl);
errorUrl.searchParams.append("error", error.message);
errorUrl.searchParams.append("provider", provider);
errorUrl.searchParams.append("success", "false");
res.redirect(errorUrl.toString());
}
});
/**
* 获取账户信息
* GET /api/accounts/profile
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.get("/profile", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
const account = await prisma.account.findUnique({
where: { id: accountContext.id },
include: {
devices: {
select: {
id: true,
uuid: true,
name: true,
createdAt: true,
},
},
},
});
res.json({
success: true,
data: {
id: account.id,
provider: account.provider,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
devices: account.devices,
createdAt: account.createdAt,
},
});
} catch (error) {
next(error);
}
});
/**
* 绑定设备到账户
* POST /api/accounts/devices/bind
*
* Headers:
* Authorization: Bearer <JWT Token>
*
* Body:
* {
* uuid: string // 设备UUID
* }
*/
router.post("/devices/bind", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
const { uuid } = req.body;
if (!uuid) {
return res.status(400).json({
success: false,
message: "缺少设备UUID",
});
}
// 查找设备
const device = await prisma.device.findUnique({
where: { uuid },
});
if (!device) {
return res.status(404).json({
success: false,
message: "设备不存在 #1",
});
}
// 检查设备是否已绑定其他账户
if (device.accountId && device.accountId !== accountContext.id) {
return res.status(400).json({
success: false,
message: "设备已绑定其他账户",
});
}
// 绑定设备到账户
const updatedDevice = await prisma.device.update({
where: { uuid },
data: {
accountId: accountContext.id,
},
});
res.json({
success: true,
message: "设备绑定成功",
data: {
deviceId: updatedDevice.id,
uuid: updatedDevice.uuid,
name: updatedDevice.name,
},
});
} catch (error) {
next(error);
}
});
/**
* 解绑设备
* POST /api/accounts/devices/unbind
*
* Headers:
* Authorization: Bearer <JWT Token>
*
* Body:
* {
* uuid: string // 设备UUID单个解绑
* uuids: string[] // 设备UUID数组批量解绑
* }
*/
router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
const { uuid, uuids } = req.body;
// 支持单个解绑或批量解绑
const uuidsToUnbind = uuids || (uuid ? [uuid] : []);
if (uuidsToUnbind.length === 0) {
return res.status(400).json({
success: false,
message: "请提供要解绑的设备UUID",
});
}
// 查找所有设备并验证所有权
const devices = await prisma.device.findMany({
where: {
uuid: { in: uuidsToUnbind },
},
});
// 检查是否有不存在的设备
if (devices.length !== uuidsToUnbind.length) {
const foundUuids = devices.map(d => d.uuid);
const notFoundUuids = uuidsToUnbind.filter(u => !foundUuids.includes(u));
return res.status(404).json({
success: false,
message: `以下设备不存在: ${notFoundUuids.join(', ')}`,
});
}
// 检查所有权
const unauthorizedDevices = devices.filter(d => d.accountId !== accountContext.id);
if (unauthorizedDevices.length > 0) {
return res.status(403).json({
success: false,
message: `您没有权限解绑以下设备: ${unauthorizedDevices.map(d => d.uuid).join(', ')}`,
});
}
// 批量解绑设备
await prisma.device.updateMany({
where: {
uuid: { in: uuidsToUnbind },
accountId: accountContext.id,
},
data: {
accountId: null,
},
});
res.json({
success: true,
message: uuidsToUnbind.length === 1 ? "设备解绑成功" : `成功解绑 ${uuidsToUnbind.length} 个设备`,
unboundCount: uuidsToUnbind.length,
});
} catch (error) {
next(error);
}
});
/**
* 获取账户绑定的设备列表
* GET /api/accounts/devices
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.get("/devices", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
// 获取账户的设备列表
const account = await prisma.account.findUnique({
where: { id: accountContext.id },
include: {
devices: {
select: {
id: true,
uuid: true,
name: true,
createdAt: true,
updatedAt: true,
},
},
},
});
res.json({
success: true,
data: account.devices,
});
} catch (error) {
next(error);
}
});
/**
* 根据设备UUID获取账户公开信息
* GET /accounts/device/:uuid/account
*
* 无需认证返回公开信息
*/
router.get("/device/:uuid/account", async (req, res, next) => {
try {
const { uuid } = req.params;
// 查找设备及其关联的账户
const device = await prisma.device.findUnique({
where: { uuid },
include: {
account: {
select: {
id: true,
provider: true,
name: true,
avatarUrl: true,
createdAt: true,
},
},
},
});
if (!device) {
return res.status(404).json({
success: false,
message: "设备不存在 #2",
});
}
if (!device.account) {
return res.json({
success: true,
data: null, // 设备未绑定账户
});
}
res.json({
success: true,
data: {
id: device.account.id,
provider: device.account.provider,
name: device.account.name,
avatarUrl: device.account.avatarUrl,
bindTime: device.updatedAt, // 绑定时间
},
});
} catch (error) {
next(error);
}
});
export default router;

159
routes/apps.js Normal file
View File

@ -0,0 +1,159 @@
import { Router } from "express";
const router = Router();
import { uuidAuth } from "../middleware/uuidAuth.js";
import { jwtAuth } from "../middleware/jwt-auth.js";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
/**
* GET /apps/devices/:uuid/apps
* 获取设备安装的应用列表 (公开接口无需认证)
*/
router.get(
"/devices/:uuid/apps",
errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params;
// 查找设备
const device = await prisma.device.findUnique({
where: { uuid },
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id },
});
const apps = installations.map(install => ({
appId: install.appId,
token: install.token,
note: install.note,
installedAt: install.createdAt,
}));
return res.json({
success: true,
apps,
});
})
);
/**
* POST /apps/devices/:uuid/install/:appId
* 为设备安装应用 (需要UUID认证)
* appId 现在是 SHA256 hash
*/
router.post(
"/devices/:uuid/install/:appId",
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const { appId } = req.params;
const { note } = req.body;
// 生成token
const token = crypto.randomBytes(32).toString("hex");
// 创建安装记录
const installation = await prisma.appInstall.create({
data: {
deviceId: device.id,
appId: appId,
token,
note: note || null,
},
});
return res.status(201).json({
id: installation.id,
appId: installation.appId,
token: installation.token,
note: installation.note,
installedAt: installation.createdAt,
});
})
);
/**
* DELETE /apps/devices/:uuid/uninstall/:installId
* 卸载设备应用 (需要UUID认证)
*/
router.delete(
"/devices/:uuid/uninstall/:installId",
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const { installId } = req.params;
const installation = await prisma.appInstall.findUnique({
where: { id: installId },
});
if (!installation) {
return next(errors.createError(404, "应用未安装"));
}
// 确保安装记录属于当前设备
if (installation.deviceId !== device.id) {
return next(errors.createError(403, "无权操作此安装记录"));
}
await prisma.appInstall.delete({
where: { id: installation.id },
});
return res.status(204).end();
})
);
/**
* GET /apps/tokens
* 获取设备的token列表 (需要设备UUID)
*/
router.get(
"/tokens",
errors.catchAsync(async (req, res, next) => {
const { uuid } = req.query;
if (!uuid) {
return next(errors.createError(400, "需要提供设备UUID"));
}
// 查找设备
const device = await prisma.device.findUnique({
where: { uuid },
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
// 获取该设备的所有应用安装记录即token
const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id },
orderBy: { installedAt: 'desc' },
});
const tokens = installations.map(install => ({
id: install.id,
token: install.token,
appId: install.appId,
installedAt: install.installedAt,
note: install.note,
}));
return res.json({
success: true,
tokens,
deviceUuid: uuid,
});
})
);
export default router;

203
routes/device-auth.js Normal file
View File

@ -0,0 +1,203 @@
import { Router } from "express";
import deviceCodeStore from "../utils/deviceCodeStore.js";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
const router = Router();
const prisma = new PrismaClient();
/**
* POST /device/code
* 生成设备授权码
*
* 应用调用此接口获取一个设备代码返回给用户在前端进行授权
*
* Response:
* {
* "device_code": "1234-ABCD",
* "expires_in": 900 ()
* }
*/
router.post(
"/device/code",
errors.catchAsync(async (req, res) => {
const deviceCode = deviceCodeStore.create();
return res.json({
device_code: deviceCode,
expires_in: 900, // 15分钟
message: "请在前端输入此代码进行授权",
});
})
);
/**
* POST /device/bind
* 绑定令牌到设备代码
*
* 前端用户授权后调用此接口将token绑定到设备代码
* 此接口独立于授权流程可单独调用
*
* Request Body:
* {
* "device_code": "1234-ABCD",
* "token": "actual-token-string"
* }
*
* Response:
* {
* "success": true,
* "message": "令牌已成功绑定到设备代码"
* }
*/
router.post(
"/device/bind",
errors.catchAsync(async (req, res, next) => {
const { device_code, token } = req.body;
if (!device_code || !token) {
return next(
errors.createError(400, "请提供 device_code 和 token")
);
}
// 验证token是否有效检查数据库
const appInstall = await prisma.appInstall.findUnique({
where: { token },
});
if (!appInstall) {
return next(errors.createError(400, "无效的令牌"));
}
// 绑定令牌到设备代码
const success = deviceCodeStore.bindToken(device_code, token);
if (!success) {
return next(
errors.createError(400, "设备代码不存在或已过期")
);
}
return res.json({
success: true,
message: "令牌已成功绑定到设备代码",
});
})
);
/**
* GET /device/token
* 轮询获取令牌
*
* 应用通过设备代码轮询此接口获取用户授权后的token
* 获取成功后服务端会删除此设备代码
*
* Query Parameters:
* - device_code: 设备代码
*
* Response (pending):
* {
* "status": "pending",
* "message": "等待用户授权"
* }
*
* Response (success):
* {
* "status": "success",
* "token": "actual-token-string"
* }
*
* Response (expired):
* {
* "status": "expired",
* "message": "设备代码已过期"
* }
*/
router.get(
"/device/token",
errors.catchAsync(async (req, res, next) => {
const { device_code } = req.query;
if (!device_code) {
return next(errors.createError(400, "请提供 device_code"));
}
// 尝试获取并移除令牌
const token = deviceCodeStore.getAndRemove(device_code);
if (token) {
// 令牌已绑定,返回并删除
return res.json({
status: "success",
token,
});
}
// 检查设备代码是否存在
const status = deviceCodeStore.getStatus(device_code);
if (!status) {
// 设备代码不存在或已过期
return res.json({
status: "expired",
message: "设备代码不存在或已过期",
});
}
// 设备代码存在但令牌未绑定
return res.json({
status: "pending",
message: "等待用户授权",
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
});
})
);
/**
* GET /device/status
* 查询设备代码状态仅用于调试
*
* Query Parameters:
* - device_code: 设备代码
*
* Response:
* {
* "device_code": "1234-ABCD",
* "exists": true,
* "has_token": false,
* "expires_in": 850 ()
* }
*/
router.get(
"/device/status",
errors.catchAsync(async (req, res, next) => {
const { device_code } = req.query;
if (!device_code) {
return next(errors.createError(400, "请提供 device_code"));
}
const status = deviceCodeStore.getStatus(device_code);
if (!status) {
return res.json({
device_code,
exists: false,
message: "设备代码不存在或已过期",
});
}
return res.json({
device_code,
exists: true,
has_token: status.hasToken,
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
created_at: status.createdAt,
});
})
);
export default router;

327
routes/device.js Normal file
View File

@ -0,0 +1,327 @@
import { Router } from "express";
const router = Router();
import { uuidAuth } 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";
const prisma = new PrismaClient();
/**
* POST /devices
* 注册新设备
*/
router.post(
"/",
errors.catchAsync(async (req, res, next) => {
const { uuid, deviceName } = req.body;
if (!uuid) {
return next(errors.createError(400, "设备UUID是必需的"));
}
if (!deviceName) {
return next(errors.createError(400, "设备名称是必需的"));
}
// 检查UUID是否已存在
const existingDevice = await prisma.device.findUnique({
where: { uuid },
});
if (existingDevice) {
return next(errors.createError(409, "设备UUID已存在"));
}
// 创建设备
const device = await prisma.device.create({
data: {
uuid,
name: deviceName,
},
});
return res.status(201).json({
success: true,
device: {
id: device.id,
uuid: device.uuid,
name: device.name,
createdAt: device.createdAt,
},
});
})
);
/**
* GET /devices/:uuid
* 获取设备信息 (公开接口无需认证)
*/
router.get(
"/:uuid",
errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params;
// 查找设备,包含绑定的账户信息
const device = await prisma.device.findUnique({
where: { uuid },
include: {
account: {
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
},
},
},
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
return res.json({
id: device.id,
uuid: device.uuid,
name: device.name,
hasPassword: !!device.password,
passwordHint: device.passwordHint,
createdAt: device.createdAt,
account: device.account ? {
id: device.account.id,
name: device.account.name,
email: device.account.email,
avatarUrl: device.account.avatarUrl,
} : null,
isBoundToAccount: !!device.account,
});
})
);/**
* PUT /devices/:uuid/name
* 设置设备名称 (需要UUID认证)
*/
router.put(
"/:uuid/name",
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const { name } = req.body;
const device = res.locals.device;
if (!name) {
return next(errors.createError(400, "设备名称是必需的"));
}
const updatedDevice = await prisma.device.update({
where: { id: device.id },
data: { name },
});
return res.json({
success: true,
device: {
id: updatedDevice.id,
uuid: updatedDevice.uuid,
name: updatedDevice.name,
hasPassword: !!updatedDevice.password,
passwordHint: updatedDevice.passwordHint,
},
});
})
);
/**
* 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;
const passwordHint = req.query.passwordHint || req.body.passwordHint;
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,
passwordHint: passwordHint || null,
},
});
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, "密码错误"));
}
}
await prisma.device.update({
where: { id: device.id },
data: {
password: null,
passwordHint: null,
},
});
return res.json({
success: true,
message: "密码删除成功",
});
})
);
export default router;

308
routes/kv-token.js Normal file
View File

@ -0,0 +1,308 @@
import { Router } from "express";
const router = Router();
import kvStore from "../utils/kvStore.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// 使用KV专用token认证
router.use(kvTokenAuth);
/**
* GET /_info
* 获取当前token所属设备的信息如果关联了账号也返回账号信息
*/
router.get(
"/_info",
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
// 获取设备信息,包含关联的账号
const device = await prisma.device.findUnique({
where: { id: deviceId },
include: {
account: true,
},
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
// 构建响应对象
const response = {
device: {
id: device.id,
uuid: device.uuid,
name: device.name,
createdAt: device.createdAt,
updatedAt: device.updatedAt,
},
};
// 如果关联了账号,添加账号信息
if (device.account) {
response.account = {
id: device.account.id,
name: device.account.name,
avatarUrl: device.account.avatarUrl,
};
}
return res.json(response);
})
);
/**
* GET /_keys
* 获取当前token对应设备的键名列表分页不包括内容
*/
router.get(
"/_keys",
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query;
// 构建选项
const options = {
sortBy: sortBy || "key",
sortDir: sortDir || "asc",
limit: limit ? parseInt(limit) : 100,
skip: skip ? parseInt(skip) : 0,
};
const keys = await kvStore.listKeysOnly(deviceId, options);
const totalRows = keys.length;
// 构建响应对象
const response = {
keys: keys,
total_rows: totalRows,
current_page: {
limit: options.limit,
skip: options.skip,
count: keys.length,
},
};
// 如果还有更多数据添加load_more字段
const nextSkip = options.skip + options.limit;
if (nextSkip < totalRows) {
const baseUrl = `${req.baseUrl}/_keys`;
const queryParams = new URLSearchParams({
sortBy: options.sortBy,
sortDir: options.sortDir,
limit: options.limit,
skip: nextSkip,
}).toString();
response.load_more = `${baseUrl}?${queryParams}`;
}
return res.json(response);
})
);
/**
* GET /
* 获取当前token对应设备的所有键名及元数据列表
*/
router.get(
"/",
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query;
// 构建选项
const options = {
sortBy: sortBy || "key",
sortDir: sortDir || "asc",
limit: limit ? parseInt(limit) : 100,
skip: skip ? parseInt(skip) : 0,
};
const keys = await kvStore.list(deviceId, options);
const totalRows = await kvStore.count(deviceId);
// 构建响应对象
const response = {
items: keys,
total_rows: totalRows,
};
// 如果还有更多数据添加load_more字段
const nextSkip = options.skip + options.limit;
if (nextSkip < totalRows) {
const baseUrl = `${req.baseUrl}`;
const queryParams = new URLSearchParams({
sortBy: options.sortBy,
sortDir: options.sortDir,
limit: options.limit,
skip: nextSkip,
}).toString();
response.load_more = `${baseUrl}?${queryParams}`;
}
return res.json(response);
})
);
/**
* GET /:key
* 通过键名获取键值
*/
router.get(
"/:key",
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
const value = await kvStore.get(deviceId, key);
if (value === null) {
return next(
errors.createError(404, `未找到键名为 '${key}' 的记录`)
);
}
return res.json(value);
})
);
/**
* GET /:key/metadata
* 获取键的元数据
*/
router.get(
"/:key/metadata",
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
const metadata = await kvStore.getMetadata(deviceId, key);
if (!metadata) {
return next(
errors.createError(404, `未找到键名为 '${key}' 的记录`)
);
}
return res.json(metadata);
})
);
/**
* POST /_batchimport
* 批量导入键值对
*/
router.post(
"/_batchimport",
errors.catchAsync(async (req, res, next) => {
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(),
});
} 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,
});
})
);
/**
* POST /:key
* 更新或创建键值
*/
router.post(
"/:key",
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
const value = req.body;
if (!value || Object.keys(value).length === 0) {
return next(errors.createError(400, "请提供有效的JSON值"));
}
// 获取客户端IP
const creatorIp =
req.headers["x-forwarded-for"] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.socket?.remoteAddress ||
"";
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
return res.status(200).json({
deviceId: result.deviceId,
key: result.key,
created: result.createdAt.getTime() === result.updatedAt.getTime(),
updatedAt: result.updatedAt,
});
})
);
/**
* DELETE /:key
* 删除键值对
*/
router.delete(
"/:key",
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
const result = await kvStore.delete(deviceId, key);
if (!result) {
return next(
errors.createError(404, `未找到键名为 '${key}' 的记录`)
);
}
// 204状态码表示成功但无内容返回
return res.status(204).end();
})
);
export default router;

View File

@ -1,536 +0,0 @@
import { Router } from "express";
const router = Router();
import kvStore from "../utils/kvStore.js";
import { checkSiteKey } from "../middleware/auth.js";
import { v4 as uuidv4 } from "uuid";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
import {
readAuthMiddleware,
writeAuthMiddleware,
removePasswordMiddleware,
deviceInfoMiddleware,
} from "../middleware/auth.js";
import { hashPassword, verifyPassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
// 定义有效的访问类型
const VALID_ACCESS_TYPES = ["PUBLIC", "PROTECTED", "PRIVATE"];
// 检查是否为受限UUID的中间件
const checkRestrictedUUID = (req, res, next) => {
const restrictedUUID = "00000000-0000-4000-8000-000000000000";
const namespace = req.params.namespace;
if (namespace === restrictedUUID) {
return next(errors.createError(403, "无权限访问此命名空间"));
}
next();
};
router.use(checkSiteKey);
// Get device info
router.get(
"/:namespace/_info",
checkRestrictedUUID,
readAuthMiddleware,
errors.catchAsync(async (req, res) => {
const device = res.locals.device;
if (!device) {
return res.status(404).json({
statusCode: 404,
message: "设备不存在",
});
}
res.json({
uuid: device.uuid,
name: device.name,
accessType: device.accessType,
hasPassword: !!device.password,
});
})
);
// Get device info
router.get(
"/:namespace/_check",
checkRestrictedUUID,
deviceInfoMiddleware,
errors.catchAsync(async (req, res) => {
const device = res.locals.device;
if (!device) {
return res.status(404).json({
statusCode: 404,
message: "设备不存在",
});
}
res.json({
status: "success",
uuid: device.uuid,
name: device.name,
accessType: device.accessType,
hasPassword: !!device.password,
});
})
);
// Get device info
router.post(
"/:namespace/_checkpassword",
checkRestrictedUUID,
deviceInfoMiddleware,
errors.catchAsync(async (req, res) => {
const { password } = req.body;
const device = res.locals.device;
if (!device) {
return res.status(404).json({
statusCode: 404,
message: "设备不存在",
});
}
const isPasswordValid = await verifyPassword(password, device.password);
if (!isPasswordValid) {
return res.status(401).json({
statusCode: 401,
message: "密码错误",
});
}
res.json({
status: "success",
uuid: device.uuid,
name: device.name,
accessType: device.accessType,
hasPassword: !!device.password,
});
})
);
// Get password hint
router.get(
"/:namespace/_hint",
checkRestrictedUUID,
errors.catchAsync(async (req, res) => {
const { namespace } = req.params;
const device = await prisma.device.findUnique({
where: { uuid: namespace },
select: { passwordHint: true },
});
if (!device) {
return res.status(404).json({
statusCode: 404,
message: "设备不存在",
});
}
res.json({
passwordHint: device.passwordHint || null,
});
})
);
// Update password hint
router.put(
"/:namespace/_hint",
checkRestrictedUUID,
writeAuthMiddleware,
errors.catchAsync(async (req, res) => {
const { hint } = req.body;
const device = res.locals.device;
const updatedDevice = await prisma.device.update({
where: { uuid: device.uuid },
data: { passwordHint: hint },
select: { passwordHint: true },
});
res.json({
message: "密码提示已更新",
passwordHint: updatedDevice.passwordHint,
});
})
);
// Update device password
router.post(
"/:namespace/_password",
writeAuthMiddleware,
errors.catchAsync(async (req, res, next) => {
const { password, oldPassword } = req.body;
const device = res.locals.device;
try {
// 验证旧密码
if (
device.password &&
!(await verifyPassword(oldPassword, device.password))
) {
return next(errors.createError(500, "密码错误"));
}
// 对新密码进行哈希处理
const hashedPassword = await hashPassword(password);
if (!hashedPassword) {
return next(errors.createError(400, "新密码格式无效"));
}
await prisma.device.update({
where: { uuid: device.uuid },
data: {
password: hashedPassword,
accessType: VALID_ACCESS_TYPES[1], // 设置密码时默认为受保护模式
},
});
res.json({ message: "密码已成功修改" });
} catch (error) {
return next(errors.createError(500, "无法修改密码"));
}
})
);
// Update device info
router.put(
"/:namespace/_info",
writeAuthMiddleware,
errors.catchAsync(async (req, res) => {
const { name, accessType } = req.body;
const device = res.locals.device;
// 验证 accessType
if (accessType && !VALID_ACCESS_TYPES.includes(accessType)) {
return res.status(400).json({
error: `Invalid access type. Must be one of: ${VALID_ACCESS_TYPES.join(
", "
)}`,
});
}
const updatedDevice = await prisma.device.update({
where: { uuid: device.uuid },
data: {
name: name || device.name,
accessType: accessType || device.accessType,
},
});
res.json({
uuid: updatedDevice.uuid,
name: updatedDevice.name,
accessType: updatedDevice.accessType,
hasPassword: !!updatedDevice.password,
});
})
);
// Remove device password
router.delete(
"/:namespace/_password",
removePasswordMiddleware,
errors.catchAsync(async (req, res) => {
res.json({ message: "密码已成功移除" });
})
);
/**
* GET /:namespace/_keys
* 获取指定命名空间下的键名列表分页不包括内容
*/
router.get(
"/:namespace/_keys",
checkRestrictedUUID,
readAuthMiddleware,
errors.catchAsync(async (req, res, next) => {
const { namespace } = req.params;
const { sortBy, sortDir, limit, skip } = req.query;
// 构建选项
const options = {
sortBy: sortBy || "key",
sortDir: sortDir || "asc",
limit: limit ? parseInt(limit) : 100,
skip: skip ? parseInt(skip) : 0,
};
const keys = await kvStore.listKeysOnly(namespace, options);
// 获取总记录数
const totalRows = await kvStore.count(namespace);
// 构建响应对象
const response = {
keys: keys,
total_rows: totalRows,
current_page: {
limit: options.limit,
skip: options.skip,
count: keys.length,
},
};
// 如果还有更多数据添加load_more字段
const nextSkip = options.skip + options.limit;
if (nextSkip < totalRows) {
const baseUrl = `${req.baseUrl}/${namespace}/_keys`;
const queryParams = new URLSearchParams({
sortBy: options.sortBy,
sortDir: options.sortDir,
limit: options.limit,
skip: nextSkip,
}).toString();
response.load_more = `${baseUrl}?${queryParams}`;
}
return res.json(response);
})
);
/**
* GET /:namespace
* 获取指定命名空间下的所有键名及元数据列表
*/
router.get(
"/:namespace",
checkRestrictedUUID,
errors.catchAsync(async (req, res, next) => {
const { namespace } = req.params;
const { sortBy, sortDir, limit, skip } = req.query;
// 构建选项
const options = {
sortBy: sortBy || "key",
sortDir: sortDir || "asc",
limit: limit ? parseInt(limit) : 100,
skip: skip ? parseInt(skip) : 0,
};
const keys = await kvStore.list(namespace, options);
// 获取总记录数
const totalRows = await kvStore.count(namespace);
// 构建响应对象
const response = {
items: keys,
total_rows: totalRows,
};
// 如果还有更多数据添加load_more字段
const nextSkip = options.skip + options.limit;
if (nextSkip < totalRows) {
const baseUrl = `${req.baseUrl}/${namespace}`;
const queryParams = new URLSearchParams({
sortBy: options.sortBy,
sortDir: options.sortDir,
limit: options.limit,
skip: nextSkip,
}).toString();
response.load_more = `${baseUrl}?${queryParams}`;
}
return res.json(response);
})
);
/**
* GET /:namespace/:key
* 通过命名空间和键名获取键值
*/
router.get(
"/:namespace/:key",
checkRestrictedUUID,
readAuthMiddleware,
errors.catchAsync(async (req, res, next) => {
const { namespace, key } = req.params;
// 否则只返回值
const value = await kvStore.get(namespace, key);
if (value === null) {
// 创建并传递错误,而不是抛出
return next(
errors.createError(
404,
`未找到命名空间 '${namespace}' 下键名为 '${key}' 的记录`
)
);
}
return res.json(value);
})
);
router.get(
"/:namespace/:key/metadata",
checkRestrictedUUID,
readAuthMiddleware,
errors.catchAsync(async (req, res, next) => {
const { namespace, key } = req.params;
const metadata = await kvStore.getMetadata(namespace, key);
if (!metadata) {
return next(
errors.createError(
404,
`未找到命名空间 '${namespace}' 下键名为 '${key}' 的记录`
)
);
}
return res.json(metadata);
})
);
/**
* POST /:namespace/batch-import
* 批量导入键值对到指定命名空间
*/
router.post(
"/:namespace/_batchimport",
checkRestrictedUUID,
errors.catchAsync(async (req, res, next) => {
const { namespace } = req.params;
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 errors = [];
// 批量处理所有键值对
for (const [key, value] of Object.entries(data)) {
try {
const result = await kvStore.upsert(namespace, key, value, creatorIp);
results.push({
key: result.key,
created: result.createdAt.getTime() === result.updatedAt.getTime(),
});
} catch (error) {
errors.push({
key,
error: error.message,
});
}
}
return res.status(200).json({
namespace,
total: Object.keys(data).length,
successful: results.length,
failed: errors.length,
results,
errors: errors.length > 0 ? errors : undefined,
});
})
);
/**
* POST /:namespace/:key
* 更新指定命名空间下的键值如果不存在则创建
*/
router.post(
"/:namespace/:key",
checkRestrictedUUID,
writeAuthMiddleware,
errors.catchAsync(async (req, res, next) => {
const { namespace, key } = req.params;
const value = req.body;
if (!value || Object.keys(value).length === 0) {
// 创建并传递错误,而不是抛出
return next(errors.createError(400, "请提供有效的JSON值"));
}
// 获取客户端IP
const creatorIp =
req.headers["x-forwarded-for"] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.socket?.remoteAddress ||
"";
const result = await kvStore.upsert(namespace, key, value, creatorIp);
return res.status(200).json({
namespace: result.namespace,
key: result.key,
created: result.createdAt.getTime() === result.updatedAt.getTime(),
updatedAt: result.updatedAt,
});
})
);
/**
* DELETE /:namespace
* 删除指定命名空间及其所有键值对
*/
router.delete(
"/:namespace",
checkRestrictedUUID,
errors.catchAsync(async (req, res, next) => {
const { namespace } = req.params;
const result = await kvStore.deleteNamespace(namespace);
if (!result) {
// 创建并传递错误,而不是抛出
return next(errors.createError(404, `未找到命名空间 '${namespace}'`));
}
// 204状态码表示成功但无内容返回
return res.status(204).end();
})
);
/**
* DELETE /:namespace/:key
* 删除指定命名空间下的键值对
*/
router.delete(
"/:namespace/:key",
checkRestrictedUUID,
errors.catchAsync(async (req, res, next) => {
const { namespace, key } = req.params;
const result = await kvStore.delete(namespace, key);
if (!result) {
// 创建并传递错误,而不是抛出
return next(
errors.createError(
404,
`未找到命名空间 '${namespace}' 下键名为 '${key}' 的记录`
)
);
}
// 204状态码表示成功但无内容返回
return res.status(204).end();
})
);
/**
* GET /uuid
* 生成并返回一个随机UUID可用作新命名空间
*/
router.get(
"/uuid",
errors.catchAsync(async (req, res) => {
const namespace = uuidv4();
res.json({ namespace });
})
);
export default router;

View File

@ -1,192 +0,0 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import dotenv from 'dotenv';
// 加载环境变量
dotenv.config();
const PRISMA_DIR = path.join(process.cwd(), 'prisma');
const DATABASE_DIR = path.join(PRISMA_DIR, 'database');
const MIGRATIONS_DIR = path.join(PRISMA_DIR, 'migrations');
// 数据库 URL 环境变量映射
const DB_URL_VARS = {
mysql: 'MYSQL_DATABASE_URL',
postgres: 'PG_DATABASE_URL'
};
function copyDirectory(source, destination) {
// 如果目标目录不存在,创建它
if (!fs.existsSync(destination)) {
fs.mkdirSync(destination, { recursive: true });
}
// 读取源目录中的所有内容
const items = fs.readdirSync(source);
for (const item of items) {
const sourcePath = path.join(source, item);
const destPath = path.join(destination, item);
const stats = fs.statSync(sourcePath);
if (stats.isDirectory()) {
// 如果是目录,递归复制
copyDirectory(sourcePath, destPath);
} else {
// 如果是文件,直接复制
fs.copyFileSync(sourcePath, destPath);
}
}
}
function deleteMigrationsDir() {
if (fs.existsSync(MIGRATIONS_DIR)) {
console.log('🗑️ 删除现有的 migrations 目录...');
fs.rmSync(MIGRATIONS_DIR, { recursive: true, force: true });
}
}
// 修改 schema 文件中的数据库配置
function updateSchemaConfig(schemaPath, dbType) {
console.log(`📝 更新 schema 文件配置...`);
// 读取原始内容
let content = fs.readFileSync(schemaPath, 'utf8');
const originalContent = content;
if (dbType === 'sqlite') {
// 修改 SQLite 数据库路径为 ../../data/db.db用于迁移
content = content.replace(
/url\s*=\s*"file:..\/data\/db.db"/,
'url = "file:../../data/db.db"'
);
} else {
// 获取对应的环境变量名
const urlEnvVar = DB_URL_VARS[dbType];
if (!urlEnvVar) {
throw new Error(`未找到 ${dbType} 的数据库 URL 环境变量映射`);
}
// 替换 env("DATABASE_URL") 为对应的环境变量
content = content.replace(
/env\s*\(\s*"DATABASE_URL"\s*\)/,
`env("${urlEnvVar}")`
);
}
// 写入修改后的内容
fs.writeFileSync(schemaPath, content, 'utf8');
return originalContent;
}
// 恢复 schema 文件的原始内容,对于 SQLite 恢复为 ../data/db.db
function restoreSchema(schemaPath, dbType, originalContent) {
if (originalContent) {
console.log(`📝 恢复 schema 文件的原始内容...`);
if (dbType === 'sqlite') {
// 确保恢复为 ../data/db.db
let content = originalContent;
if (content.includes('../../data/db.db')) {
content = content.replace(
/url\s*=\s*"file:..\/..\/data\/db.db"/,
'url = "file:../data/db.db"'
);
}
fs.writeFileSync(schemaPath, content, 'utf8');
} else {
fs.writeFileSync(schemaPath, originalContent, 'utf8');
}
}
}
async function processDatabaseType(dbType) {
const schemaPath = path.join(DATABASE_DIR, dbType, 'schema.prisma');
const dbMigrationsDir = path.join(DATABASE_DIR, dbType, 'migrations');
if (!fs.existsSync(schemaPath)) {
console.log(`⚠️ 跳过 ${dbType}: schema.prisma 文件不存在`);
return;
}
let originalContent;
try {
console.log(`\n🔄 处理 ${dbType} 数据库迁移...`);
// 删除旧的迁移目录
deleteMigrationsDir();
// 修改 schema 文件配置
originalContent = updateSchemaConfig(schemaPath, dbType);
// 先尝试部署现有迁移
console.log(`📦 部署现有迁移...`);
try {
execSync(`npx prisma migrate deploy --schema=${schemaPath}`, {
stdio: 'inherit'
});
} catch (error) {
console.log(`⚠️ 部署现有迁移失败,将创建新迁移`);
}
// 执行新迁移
console.log(`📦 创建新迁移...`);
execSync(`npx prisma migrate dev --name ${new Date().toISOString().split('T')[0]} --schema=${schemaPath}`, {
stdio: 'inherit'
});
// 复制迁移文件到数据库特定目录
if (fs.existsSync(MIGRATIONS_DIR)) {
console.log(`📋 复制迁移文件到 ${dbType} 目录...`);
copyDirectory(MIGRATIONS_DIR, dbMigrationsDir);
}
console.log(`${dbType} 迁移完成`);
} catch (error) {
console.error(`${dbType} 迁移失败:`, error.message);
} finally {
// 确保无论成功还是失败都恢复原始内容,对于 SQLite 恢复为 ../data/db.db
restoreSchema(schemaPath, dbType, originalContent);
}
}
async function main() {
try {
// 确保数据库目录存在
if (!fs.existsSync(DATABASE_DIR)) {
console.error('❌ database 目录不存在');
process.exit(1);
}
// 获取所有数据库类型目录
const dbTypes = fs.readdirSync(DATABASE_DIR).filter(item => {
const itemPath = path.join(DATABASE_DIR, item);
return fs.statSync(itemPath).isDirectory();
});
console.log('📊 发现的数据库类型:', dbTypes.join(', '));
console.log('🔑 数据库配置:');
for (const [dbType, envVar] of Object.entries(DB_URL_VARS)) {
console.log(` - ${dbType}: 使用环境变量 ${envVar}`);
}
console.log(' - sqlite: 迁移时使用 ../../data/db.db完成后恢复为 ../data/db.db');
// 依次处理每个数据库类型
for (const dbType of dbTypes) {
await processDatabaseType(dbType);
}
console.log('\n🎉 所有数据库迁移处理完成!');
} catch (error) {
console.error('❌ 批量迁移失败:', error);
process.exit(1);
}
}
// 执行主函数
main().catch(error => {
console.error('❌ 程序执行失败:', error);
process.exit(1);
});

View File

@ -1,69 +0,0 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: classworks_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-classworks}
MYSQL_DATABASE: ${MYSQL_DATABASE:-classworks}
MYSQL_USER: ${MYSQL_USER:-classworks}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-classworks}
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/conf.d:/etc/mysql/conf.d:ro
- ./mysql/initdb.d:/docker-entrypoint-initdb.d:ro
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"]
interval: 10s
timeout: 5s
retries: 5
networks:
- classworks_net
postgres:
image: postgres:15-alpine
container_name: classworks_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-classworks}
POSTGRES_USER: ${POSTGRES_USER:-classworks}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-classworks}
TZ: Asia/Shanghai
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/initdb.d:/docker-entrypoint-initdb.d:ro
command:
- "postgres"
- "-c"
- "max_connections=100"
- "-c"
- "shared_buffers=128MB"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
networks:
- classworks_net
volumes:
mysql_data:
name: classworks_mysql_data
postgres_data:
name: classworks_postgres_data
networks:
classworks_net:
name: classworks_network
driver: bridge

View File

@ -1,33 +0,0 @@
[mysqld]
# 字符集设置
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
# 连接设置
max_connections=100
max_allowed_packet=64M
# InnoDB设置
innodb_buffer_pool_size=256M
innodb_log_file_size=64M
innodb_flush_log_at_trx_commit=2
innodb_flush_method=O_DIRECT
# 优化设置
query_cache_type=1
query_cache_size=32M
sort_buffer_size=4M
read_buffer_size=2M
read_rnd_buffer_size=4M
join_buffer_size=2M
# 日志设置
slow_query_log=1
slow_query_log_file=/var/log/mysql/slow.log
long_query_time=2
[client]
default-character-set=utf8mb4
[mysql]
default-character-set=utf8mb4

View File

@ -1,12 +0,0 @@
-- 设置时区
SET GLOBAL time_zone = '+8:00';
SET time_zone = '+8:00';
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS classworks
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- 设置权限
GRANT ALL PRIVILEGES ON classworks.* TO 'classworks'@'%';
FLUSH PRIVILEGES;

View File

@ -1,10 +0,0 @@
-- 创建扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- 设置时区
SET timezone = 'Asia/Shanghai';
-- 设置默认权限
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO classworks;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO classworks;

View File

@ -26,56 +26,6 @@ function encodeUTF8(str) {
}
}
/**
* 验证密码是否匹配 base64 解码
*/
export async function DecodeAndVerifyPassword(plainPassword, hashedPassword) {
if (!plainPassword || !hashedPassword) return false;
const decodedPassword = decodeBase64(plainPassword);
console.debug(decodedPassword);
if (!decodedPassword) return false;
const encodedPassword = encodeUTF8(decodedPassword);
console.debug(encodedPassword);
if (!encodedPassword) return false;
return await bcrypt.compare(encodedPassword, hashedPassword);
}
/**
* 验证密码是否匹配不解码 base64但处理 UTF-8
*/
export async function verifyPassword(plainPassword, hashedPassword) {
if (!plainPassword || !hashedPassword) return false;
const encodedPassword = encodeUTF8(plainPassword);
console.debug(encodedPassword);
if (!encodedPassword) return false;
return await bcrypt.compare(encodedPassword, hashedPassword);
}
/**
* 对密码进行哈希处理 base64 解码
*/
export async function DecodeAndhashPassword(plainPassword) {
if (!plainPassword) return null;
const decodedPassword = decodeBase64(plainPassword);
console.debug(decodedPassword);
if (!decodedPassword) return null;
const encodedPassword = encodeUTF8(decodedPassword);
if (!encodedPassword) return null;
console.debug(encodedPassword);
return await bcrypt.hash(encodedPassword, SALT_ROUNDS);
}
/**
* 对密码进行哈希处理不解码 base64但处理 UTF-8
*/
export async function hashPassword(plainPassword) {
if (!plainPassword) return null;
const encodedPassword = encodeUTF8(plainPassword);
if (!encodedPassword) return null;
console.debug(encodedPassword);
return await bcrypt.hash(encodedPassword, SALT_ROUNDS);
}
/**
* 验证站点密钥
*/
@ -89,3 +39,29 @@ export function verifySiteKey(providedKey, actualKey) {
console.debug(encodedKey);
return encodedKey === actualKey;
}
/**
* 哈希密码
* @param {string} password - 明文密码
* @returns {Promise<string>} 哈希后的密码
*/
export async function hashPassword(password) {
if (!password) return null;
return await bcrypt.hash(password, SALT_ROUNDS);
}
/**
* 验证设备密码
* @param {string} providedPassword - 用户提供的明文密码
* @param {string} hashedPassword - 存储的哈希密码
* @returns {Promise<boolean>} 密码是否匹配
*/
export async function verifyDevicePassword(providedPassword, hashedPassword) {
if (!providedPassword || !hashedPassword) return false;
try {
return await bcrypt.compare(providedPassword, hashedPassword);
} catch (error) {
console.error('密码验证错误:', error);
return false;
}
}

199
utils/deviceCodeStore.js Normal file
View File

@ -0,0 +1,199 @@
/**
* Device Code Store - 内存存储
*
* 用于存储设备授权流程中的临时代码和令牌
* 格式如: 1234-ABCD
*/
class DeviceCodeStore {
constructor() {
// 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } }
this.store = new Map();
// 默认过期时间: 15分钟
this.expirationTime = 15 * 60 * 1000;
// 定期清理过期数据 (每5分钟)
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 5 * 60 * 1000);
}
/**
* 生成设备代码 (格式: 1234-ABCD)
*/
generateDeviceCode() {
const part1 = Math.floor(1000 + Math.random() * 9000).toString(); // 4位数字
const part2 = Math.random().toString(36).substring(2, 6).toUpperCase(); // 4位字母
return `${part1}-${part2}`;
}
/**
* 创建新的设备代码
* @returns {string} 生成的设备代码
*/
create() {
let deviceCode;
// 确保生成的代码不重复
do {
deviceCode = this.generateDeviceCode();
} while (this.store.has(deviceCode));
const now = Date.now();
this.store.set(deviceCode, {
token: null,
expiresAt: now + this.expirationTime,
createdAt: now,
});
return deviceCode;
}
/**
* 绑定令牌到设备代码
* @param {string} deviceCode - 设备代码
* @param {string} token - 令牌
* @returns {boolean} 是否成功绑定
*/
bindToken(deviceCode, token) {
const entry = this.store.get(deviceCode);
if (!entry) {
return false;
}
// 检查是否过期
if (Date.now() > entry.expiresAt) {
this.store.delete(deviceCode);
return false;
}
// 绑定令牌
entry.token = token;
return true;
}
/**
* 获取设备代码对应的令牌获取后删除
* @param {string} deviceCode - 设备代码
* @returns {string|null} 令牌如果不存在或未绑定返回null
*/
getAndRemove(deviceCode) {
const entry = this.store.get(deviceCode);
if (!entry) {
return null;
}
// 检查是否过期
if (Date.now() > entry.expiresAt) {
this.store.delete(deviceCode);
return null;
}
// 如果令牌未绑定返回null但不删除代码
if (!entry.token) {
return null;
}
// 获取令牌后删除条目
const token = entry.token;
this.store.delete(deviceCode);
return token;
}
/**
* 检查设备代码是否存在且未过期
* @param {string} deviceCode - 设备代码
* @returns {boolean}
*/
exists(deviceCode) {
const entry = this.store.get(deviceCode);
if (!entry) {
return false;
}
if (Date.now() > entry.expiresAt) {
this.store.delete(deviceCode);
return false;
}
return true;
}
/**
* 获取设备代码的状态信息不删除
* @param {string} deviceCode - 设备代码
* @returns {object|null} 状态信息
*/
getStatus(deviceCode) {
const entry = this.store.get(deviceCode);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.store.delete(deviceCode);
return null;
}
return {
hasToken: !!entry.token,
expiresAt: entry.expiresAt,
createdAt: entry.createdAt,
};
}
/**
* 清理过期的条目
*/
cleanup() {
const now = Date.now();
let cleanedCount = 0;
for (const [deviceCode, entry] of this.store.entries()) {
if (now > entry.expiresAt) {
this.store.delete(deviceCode);
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(`清理了 ${cleanedCount} 个过期的设备代码`);
}
}
/**
* 获取当前存储的条目数量
*/
size() {
return this.store.size;
}
/**
* 清理定时器用于优雅关闭
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.store.clear();
}
}
// 导出单例
const deviceCodeStore = new DeviceCodeStore();
// 优雅关闭处理
process.on('SIGTERM', () => {
deviceCodeStore.destroy();
});
process.on('SIGINT', () => {
deviceCodeStore.destroy();
});
export default deviceCodeStore;

40
utils/jwt.js Normal file
View File

@ -0,0 +1,40 @@
import jwt from 'jsonwebtoken';
// JWT密钥 - 生产环境应该从环境变量读取
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; // 默认7天过期
/**
* 签发JWT token
* @param {Object} payload - 要编码的数据
* @returns {string} JWT token
*/
export function signToken(payload) {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* 验证JWT token
* @param {string} token - JWT token
* @returns {Object} 解码后的payload
*/
export function verifyToken(token) {
return jwt.verify(token, JWT_SECRET);
}
/**
* 为账户生成JWT token
* @param {Object} account - 账户对象
* @returns {string} JWT token
*/
export function generateAccountToken(account) {
return signToken({
accountId: account.id,
provider: account.provider,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
});
}

View File

@ -2,16 +2,16 @@ import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
class KVStore {
/**
* 通过命名空间和键名获取值
* @param {string} namespace - 命名空间
* 通过设备ID和键名获取值
* @param {number} deviceId - 设备ID
* @param {string} key - 键名
* @returns {object|null} 键对应的值或null
*/
async get(namespace, key) {
async get(deviceId, key) {
const item = await prisma.kVStore.findUnique({
where: {
namespace_key: {
namespace: namespace,
deviceId_key: {
deviceId: deviceId,
key: key,
},
},
@ -21,21 +21,21 @@ class KVStore {
/**
* 获取键的完整信息包括元数据
* @param {string} namespace - 命名空间
* @param {number} deviceId - 设备ID
* @param {string} key - 键名
* @returns {object|null} 键的完整信息或null
*/
async getMetadata(namespace, key) {
async getMetadata(deviceId, key) {
const item = await prisma.kVStore.findUnique({
where: {
namespace_key: {
namespace: namespace,
deviceId_key: {
deviceId: deviceId,
key: key,
},
},
select: {
key: true,
namespace: true,
deviceId: true,
creatorIp: true,
createdAt: true,
updatedAt: true,
@ -46,7 +46,7 @@ class KVStore {
// 转换为更友好的格式
return {
namespace: item.namespace,
deviceId: item.deviceId,
key: item.key,
metadata: {
creatorIp: item.creatorIp,
@ -57,18 +57,18 @@ class KVStore {
}
/**
* 在指定命名空间下创建或更新键值
* @param {string} namespace - 命名空间
* 在指定设备下创建或更新键值
* @param {number} deviceId - 设备ID
* @param {string} key - 键名
* @param {object} value - 键值
* @param {string} creatorIp - 创建者IP可选
* @returns {object} 创建或更新的记录
*/
async upsert(namespace, key, value, creatorIp = "") {
async upsert(deviceId, key, value, creatorIp = "") {
const item = await prisma.kVStore.upsert({
where: {
namespace_key: {
namespace: namespace,
deviceId_key: {
deviceId: deviceId,
key: key,
},
},
@ -77,17 +77,16 @@ class KVStore {
...(creatorIp && { creatorIp }),
},
create: {
namespace: namespace,
deviceId: deviceId,
key: key,
value,
creatorIp,
},
});
// 返回带有命名空间和原始键的结果
// 返回带有设备ID和原始键的结果
return {
id: item.id,
namespace,
deviceId,
key,
value: item.value,
creatorIp: item.creatorIp,
@ -97,22 +96,22 @@ class KVStore {
}
/**
* 通过命名空间和键名删除
* @param {string} namespace - 命名空间
* 通过设备ID和键名删除
* @param {number} deviceId - 设备ID
* @param {string} key - 键名
* @returns {object|null} 删除的记录或null
*/
async delete(namespace, key) {
async delete(deviceId, key) {
try {
const item = await prisma.kVStore.delete({
where: {
namespace_key: {
namespace: namespace,
deviceId_key: {
deviceId: deviceId,
key: key,
},
},
});
return item ? { ...item, namespace, key } : null;
return item ? { ...item, deviceId, key } : null;
} catch (error) {
// 忽略记录不存在的错误
if (error.code === "P2025") return null;
@ -121,25 +120,25 @@ class KVStore {
}
/**
* 列出指定命名空间下的所有键名及其元数据
* @param {string} namespace - 命名空间
* 列出指定设备下的所有键名及其元数据
* @param {number} deviceId - 设备ID
* @param {object} options - 选项参数
* @returns {Array} 键名和元数据数组
*/
async list(namespace, options = {}) {
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: {
namespace: namespace,
deviceId: deviceId,
},
select: {
namespace: true,
deviceId: true,
key: true,
creatorIp: true,
createdAt: true,
@ -151,32 +150,35 @@ class KVStore {
skip: skip,
});
// 处理结果,从键名中移除命名空间前缀
// 处理结果
return items.map((item) => ({
namespace: item.namespace,
deviceId: item.deviceId,
key: item.key,
value: item.value,
metadata: item.metadata,
metadata: {
creatorIp: item.creatorIp,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
},
}));
}
/**
* 获取指定命名空间下的键名列表不包括内容
* @param {string} namespace - 命名空间
* 获取指定设备下的键名列表不包括内容
* @param {number} deviceId - 设备ID
* @param {object} options - 查询选项
* @returns {Array} 键名列表
*/
async listKeysOnly(namespace, options = {}) {
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: {
namespace: namespace,
deviceId: deviceId,
},
select: {
key: true,
@ -191,14 +193,14 @@ class KVStore {
}
/**
* 统计指定命名空间下的键值对数量
* @param {string} namespace - 命名空间
* 统计指定设备下的键值对数量
* @param {number} deviceId - 设备ID
* @returns {number} 键值对数量
*/
async count(namespace) {
async count(deviceId) {
const count = await prisma.kVStore.count({
where: {
namespace: namespace,
deviceId: deviceId,
},
});
return count;

View File

@ -1,7 +1,14 @@
import { PrismaClient } from "@prisma/client";
import kvStore from "./kvStore.js";
const prisma = new PrismaClient();
// 系统保留UUID用于存储站点信息
const SYSTEM_DEVICE_UUID = "00000000-0000-4000-8000-000000000000";
// 存储 readme 值的内存变量
let readmeValue = null;
let systemDeviceId = null;
// 封装默认 readme 对象
const defaultReadme = {
@ -9,16 +16,40 @@ const defaultReadme = {
readme: "暂无 Readme 内容",
};
/**
* 获取或创建系统设备
* @returns {Promise<number>} 系统设备ID
*/
async function getSystemDeviceId() {
if (systemDeviceId) return systemDeviceId;
let device = await prisma.device.findUnique({
where: { uuid: SYSTEM_DEVICE_UUID },
select: { id: true },
});
if (!device) {
device = await prisma.device.create({
data: {
uuid: SYSTEM_DEVICE_UUID,
name: "系统设备",
},
select: { id: true },
});
}
systemDeviceId = device.id;
return systemDeviceId;
}
/**
* 初始化 readme
* 在应用启动时调用此函数
*/
export const initReadme = async () => {
try {
const storedValue = await kvStore.get(
"00000000-0000-4000-8000-000000000000",
"info"
);
const deviceId = await getSystemDeviceId();
const storedValue = await kvStore.get(deviceId, "info");
// 合并默认值与存储值,确保结构完整
readmeValue = {
@ -53,11 +84,8 @@ export const getReadmeValue = () => {
*/
export const updateReadmeValue = async (newValue) => {
try {
await kvStore.upsert(
"00000000-0000-4000-8000-000000000000",
"info",
newValue
);
const deviceId = await getSystemDeviceId();
await kvStore.upsert(deviceId, "info", newValue);
readmeValue = {
...defaultReadme,
...newValue,

View File

@ -4,33 +4,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>
<%= readmeValue.title || "Classworks 服务端" %>
</title>
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.readme-content {
background-color: #f5f5f5;
padding: 20px;
border-radius: 5px;
margin-bottom: 20px;
}
</style>
<title>Classworks 服务端</title>
</head>
<body>
<h1>
<%= readmeValue.title || "Classworks 服务端" %>
</h1>
<div class="readme-content">
<pre><%= readmeValue.readme %></pre>
</div>
<h1>Classworks 服务端</h1>
<p>服务运行中</p>
</body>
</html>