mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-10-23 02:43:11 +00:00
Compare commits
6 Commits
a38e77bef3
...
970d7e784a
Author | SHA1 | Date | |
---|---|---|---|
![]() |
970d7e784a | ||
![]() |
24c443bb89 | ||
![]() |
0fca7900c8 | ||
![]() |
aec482cbcb | ||
![]() |
7b1e224f70 | ||
![]() |
521522c1d2 |
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
18
.env.oauth.example
Normal 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
|
137
DEPLOYMENT.md
137
DEPLOYMENT.md
@ -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
29
app.js
@ -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) => {
|
||||
|
@ -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
191
cli/README.md
Normal 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
422
cli/get-token-callback.js
Normal 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
233
cli/get-token.js
Normal 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
39
config/oauth.js
Normal 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);
|
||||
}
|
@ -10,7 +10,6 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_TYPE=sqlite
|
||||
- DATABASE_URL=
|
||||
volumes:
|
||||
- .data:/app/data
|
||||
|
@ -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
126
middleware/device.js
Normal 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.deviceUuid、req.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
54
middleware/jwt-auth.js
Normal 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
64
middleware/kvTokenAuth.js
Normal 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)
|
||||
);
|
||||
}
|
@ -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
131
middleware/uuidAuth.js
Normal 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;
|
||||
}
|
@ -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
371
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
@ -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;
|
@ -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
|
||||
}
|
@ -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")
|
||||
);
|
@ -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"
|
@ -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
|
||||
}
|
@ -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
|
||||
);
|
@ -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"
|
@ -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
|
||||
}
|
68
prisma/migrations/20251006025039_init/migration.sql
Normal file
68
prisma/migrations/20251006025039_init/migration.sql
Normal 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;
|
@ -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
166
public/auth-error.html
Normal 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
254
public/auth-success.html
Normal 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
537
routes/accounts.js
Normal 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_uri(5分钟过期)
|
||||
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
159
routes/apps.js
Normal 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
203
routes/device-auth.js
Normal 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
327
routes/device.js
Normal 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
308
routes/kv-token.js
Normal 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;
|
536
routes/kv.js
536
routes/kv.js
@ -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;
|
@ -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);
|
||||
});
|
@ -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
|
@ -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
|
@ -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;
|
@ -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;
|
@ -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
199
utils/deviceCodeStore.js
Normal 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
40
utils/jwt.js
Normal 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,
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user