mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-10-24 19:33: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 bodyParser from "body-parser";
|
||||||
import errorHandler from "./middleware/errorHandler.js";
|
import errorHandler from "./middleware/errorHandler.js";
|
||||||
import errors from "./utils/errors.js";
|
import errors from "./utils/errors.js";
|
||||||
import { initReadme, getReadmeValue } from "./utils/siteinfo.js";
|
|
||||||
import {
|
import {
|
||||||
globalLimiter,
|
globalLimiter,
|
||||||
apiLimiter,
|
apiLimiter,
|
||||||
methodBasedRateLimiter,
|
methodBasedRateLimiter,
|
||||||
|
tokenBasedRateLimiter,
|
||||||
} from "./middleware/rateLimiter.js";
|
} 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();
|
var app = express();
|
||||||
|
|
||||||
@ -33,9 +37,6 @@ app.disable("x-powered-by");
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
// 初始化 readme
|
|
||||||
initReadme();
|
|
||||||
|
|
||||||
// 应用全局限速
|
// 应用全局限速
|
||||||
app.use(globalLimiter);
|
app.use(globalLimiter);
|
||||||
|
|
||||||
@ -73,7 +74,7 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.render("index.ejs", { readmeValue: getReadmeValue() });
|
res.render("index.ejs");
|
||||||
});
|
});
|
||||||
app.get("/check", apiLimiter, (req, res) => {
|
app.get("/check", apiLimiter, (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@ -83,8 +84,20 @@ app.get("/check", apiLimiter, (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount the KV store router with method-based rate limiting
|
// Mount the Apps router with API rate limiting
|
||||||
app.use("/", methodBasedRateLimiter, kvRouter);
|
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路由 - 处理所有未匹配的路由
|
// 兜底404路由 - 处理所有未匹配的路由
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
@ -1,17 +1,9 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
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() {
|
function runDatabaseMigration() {
|
||||||
@ -28,48 +20,6 @@ function runDatabaseMigration() {
|
|||||||
// 🧱 数据库初始化函数
|
// 🧱 数据库初始化函数
|
||||||
function setupDatabase() {
|
function setupDatabase() {
|
||||||
try {
|
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();
|
runDatabaseMigration();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -95,7 +45,6 @@ function buildLocal() {
|
|||||||
// 🚀 启动服务函数
|
// 🚀 启动服务函数
|
||||||
function startServer() {
|
function startServer() {
|
||||||
try {
|
try {
|
||||||
console.log(`🚀 使用 ${DATABASE_TYPE} 数据库启动服务中...`);
|
|
||||||
execSync("npm run start", { stdio: "inherit" }); // 启动项目
|
execSync("npm run start", { stdio: "inherit" }); // 启动项目
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 服务启动失败:", error.message);
|
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"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_TYPE=sqlite
|
|
||||||
- DATABASE_URL=
|
- DATABASE_URL=
|
||||||
volumes:
|
volumes:
|
||||||
- .data:/app/data
|
- .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({
|
export const globalLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15分钟
|
windowMs: 15 * 60 * 1000, // 15分钟
|
||||||
@ -26,7 +45,7 @@ export const globalLimiter = rateLimit({
|
|||||||
// API限速器
|
// API限速器
|
||||||
export const apiLimiter = rateLimit({
|
export const apiLimiter = rateLimit({
|
||||||
windowMs: 1 * 60 * 1000, // 1分钟
|
windowMs: 1 * 60 * 1000, // 1分钟
|
||||||
limit: 50, // 每个IP在windowMs时间内最多允许50个请求
|
limit: 100, // 每个IP在windowMs时间内最多允许100个请求
|
||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: "API请求过于频繁,请稍后再试",
|
message: "API请求过于频繁,请稍后再试",
|
||||||
@ -83,6 +102,56 @@ export const batchLimiter = rateLimit({
|
|||||||
skipFailedRequests: false,
|
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方法应用不同的限速器
|
// 创建一个路由处理中间件,根据HTTP方法应用不同的限速器
|
||||||
export const methodBasedRateLimiter = (req, res, next) => {
|
export const methodBasedRateLimiter = (req, res, next) => {
|
||||||
// 检查是否是批量导入路由
|
// 检查是否是批量导入路由
|
||||||
@ -105,3 +174,26 @@ export const methodBasedRateLimiter = (req, res, next) => {
|
|||||||
// 其他方法使用API限速
|
// 其他方法使用API限速
|
||||||
return apiLimiter(req, res, next);
|
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": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"prisma": "prisma generate",
|
"prisma": "prisma generate",
|
||||||
"prisma:pull": "prisma db pull",
|
|
||||||
"dev": "NODE_ENV=development nodemon node .bin/www",
|
"dev": "NODE_ENV=development nodemon node .bin/www",
|
||||||
"migrate": "node ./scripts/batchMigrate.js"
|
"get-token": "node ./cli/get-token.js"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -17,7 +16,7 @@
|
|||||||
"@opentelemetry/sdk-node": "^0.201.1",
|
"@opentelemetry/sdk-node": "^0.201.1",
|
||||||
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@prisma/client": "6.16.2",
|
"@prisma/client": "6.16.3",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
@ -30,10 +29,11 @@
|
|||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"http-errors": "~2.0.0",
|
"http-errors": "~2.0.0",
|
||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "~1.10.0",
|
"morgan": "~1.10.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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
|
specifier: ^1.34.0
|
||||||
version: 1.34.0
|
version: 1.34.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 6.16.2
|
specifier: 6.16.3
|
||||||
version: 6.16.2(prisma@6.8.2)
|
version: 6.16.3(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0(debug@4.4.1)
|
version: 1.9.0(debug@4.4.1)
|
||||||
@ -65,6 +65,9 @@ importers:
|
|||||||
js-base64:
|
js-base64:
|
||||||
specifier: ^3.7.7
|
specifier: ^3.7.7
|
||||||
version: 3.7.7
|
version: 3.7.7
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.2
|
||||||
|
version: 9.0.2
|
||||||
morgan:
|
morgan:
|
||||||
specifier: ~1.10.0
|
specifier: ~1.10.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
@ -73,8 +76,8 @@ importers:
|
|||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
prisma:
|
prisma:
|
||||||
specifier: 6.8.2
|
specifier: 6.16.2
|
||||||
version: 6.8.2
|
version: 6.16.2(typescript@5.8.3)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -603,8 +606,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.1.0
|
'@opentelemetry/api': ^1.1.0
|
||||||
|
|
||||||
'@prisma/client@6.16.2':
|
'@prisma/client@6.16.3':
|
||||||
resolution: {integrity: sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==}
|
resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prisma: '*'
|
prisma: '*'
|
||||||
@ -615,23 +618,23 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@prisma/config@6.8.2':
|
'@prisma/config@6.16.2':
|
||||||
resolution: {integrity: sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==}
|
resolution: {integrity: sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==}
|
||||||
|
|
||||||
'@prisma/debug@6.8.2':
|
'@prisma/debug@6.16.2':
|
||||||
resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
|
resolution: {integrity: sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==}
|
||||||
|
|
||||||
'@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e':
|
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43':
|
||||||
resolution: {integrity: sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==}
|
resolution: {integrity: sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==}
|
||||||
|
|
||||||
'@prisma/engines@6.8.2':
|
'@prisma/engines@6.16.2':
|
||||||
resolution: {integrity: sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==}
|
resolution: {integrity: sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==}
|
||||||
|
|
||||||
'@prisma/fetch-engine@6.8.2':
|
'@prisma/fetch-engine@6.16.2':
|
||||||
resolution: {integrity: sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==}
|
resolution: {integrity: sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==}
|
||||||
|
|
||||||
'@prisma/get-platform@6.8.2':
|
'@prisma/get-platform@6.16.2':
|
||||||
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
|
resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==}
|
||||||
|
|
||||||
'@protobufjs/aspromise@1.1.2':
|
'@protobufjs/aspromise@1.1.2':
|
||||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||||
@ -663,6 +666,9 @@ packages:
|
|||||||
'@protobufjs/utf8@1.1.0':
|
'@protobufjs/utf8@1.1.0':
|
||||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0':
|
||||||
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
'@types/aws-lambda@8.10.147':
|
'@types/aws-lambda@8.10.147':
|
||||||
resolution: {integrity: sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==}
|
resolution: {integrity: sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==}
|
||||||
|
|
||||||
@ -758,10 +764,21 @@ packages:
|
|||||||
brace-expansion@2.0.1:
|
brace-expansion@2.0.1:
|
||||||
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
|
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
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:
|
call-bind-apply-helpers@1.0.2:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -774,6 +791,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
cjs-module-lexer@1.4.3:
|
||||||
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
|
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
|
||||||
|
|
||||||
@ -795,6 +819,13 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
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:
|
content-disposition@1.0.0:
|
||||||
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
|
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -839,6 +870,13 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
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:
|
delayed-stream@1.0.0:
|
||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@ -847,17 +885,30 @@ packages:
|
|||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
destr@2.0.5:
|
||||||
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
dotenv@16.5.0:
|
dotenv@16.5.0:
|
||||||
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dotenv@16.6.1:
|
||||||
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
|
effect@3.16.12:
|
||||||
|
resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==}
|
||||||
|
|
||||||
ejs@3.1.10:
|
ejs@3.1.10:
|
||||||
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -866,6 +917,10 @@ packages:
|
|||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
|
empathic@2.0.0:
|
||||||
|
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
encodeurl@2.0.0:
|
encodeurl@2.0.0:
|
||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -907,9 +962,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
exsolve@1.0.7:
|
||||||
|
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
|
||||||
|
|
||||||
extend@3.0.2:
|
extend@3.0.2:
|
||||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
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:
|
filelist@1.0.4:
|
||||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
||||||
|
|
||||||
@ -964,6 +1026,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
giget@2.0.0:
|
||||||
|
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
google-logging-utils@0.0.2:
|
google-logging-utils@0.0.2:
|
||||||
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
|
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@ -1044,9 +1110,40 @@ packages:
|
|||||||
json-bigint@1.0.0:
|
json-bigint@1.0.0:
|
||||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
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:
|
lodash.camelcase@4.3.0:
|
||||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
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:
|
long@5.3.2:
|
||||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||||
|
|
||||||
@ -1106,6 +1203,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
|
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
|
||||||
engines: {node: ^18 || ^20 || >= 21}
|
engines: {node: ^18 || ^20 || >= 21}
|
||||||
|
|
||||||
|
node-fetch-native@1.6.7:
|
||||||
|
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@ -1119,6 +1219,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||||
hasBin: true
|
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:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -1127,6 +1232,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
|
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ohash@2.0.11:
|
||||||
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
on-finished@2.3.0:
|
on-finished@2.3.0:
|
||||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -1153,6 +1261,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
|
resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
|
||||||
engines: {node: '>=16'}
|
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:
|
pg-int8@1.0.1:
|
||||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
@ -1164,6 +1278,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
postgres-array@2.0.0:
|
postgres-array@2.0.0:
|
||||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -1180,8 +1297,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
prisma@6.8.2:
|
prisma@6.16.2:
|
||||||
resolution: {integrity: sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==}
|
resolution: {integrity: sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1201,6 +1318,9 @@ packages:
|
|||||||
proxy-from-env@1.1.0:
|
proxy-from-env@1.1.0:
|
||||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
|
pure-rand@6.1.0:
|
||||||
|
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||||
|
|
||||||
qs@6.14.0:
|
qs@6.14.0:
|
||||||
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@ -1213,6 +1333,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
||||||
engines: {node: '>= 0.8'}
|
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:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -1239,6 +1366,11 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
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:
|
send@1.2.0:
|
||||||
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@ -1289,6 +1421,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tinyexec@1.0.1:
|
||||||
|
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
|
||||||
|
|
||||||
toidentifier@1.0.1:
|
toidentifier@1.0.1:
|
||||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@ -1300,6 +1435,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@ -2114,34 +2254,40 @@ snapshots:
|
|||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0)
|
'@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0)
|
||||||
|
|
||||||
'@prisma/client@6.16.2(prisma@6.8.2)':
|
'@prisma/client@6.16.3(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)':
|
||||||
optionalDependencies:
|
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:
|
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:
|
dependencies:
|
||||||
'@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/fetch-engine': 6.8.2
|
'@prisma/fetch-engine': 6.16.2
|
||||||
'@prisma/get-platform': 6.8.2
|
'@prisma/get-platform': 6.16.2
|
||||||
|
|
||||||
'@prisma/fetch-engine@6.8.2':
|
'@prisma/fetch-engine@6.16.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@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/get-platform': 6.8.2
|
'@prisma/get-platform': 6.16.2
|
||||||
|
|
||||||
'@prisma/get-platform@6.8.2':
|
'@prisma/get-platform@6.16.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 6.8.2
|
'@prisma/debug': 6.16.2
|
||||||
|
|
||||||
'@protobufjs/aspromise@1.1.2': {}
|
'@protobufjs/aspromise@1.1.2': {}
|
||||||
|
|
||||||
@ -2166,6 +2312,8 @@ snapshots:
|
|||||||
|
|
||||||
'@protobufjs/utf8@1.1.0': {}
|
'@protobufjs/utf8@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@types/aws-lambda@8.10.147': {}
|
'@types/aws-lambda@8.10.147': {}
|
||||||
|
|
||||||
'@types/bunyan@1.8.11':
|
'@types/bunyan@1.8.11':
|
||||||
@ -2279,8 +2427,25 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
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:
|
call-bind-apply-helpers@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@ -2296,6 +2461,14 @@ snapshots:
|
|||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.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: {}
|
cjs-module-lexer@1.4.3: {}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
@ -2316,6 +2489,10 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
confbox@0.2.2: {}
|
||||||
|
|
||||||
|
consola@3.4.2: {}
|
||||||
|
|
||||||
content-disposition@1.0.0:
|
content-disposition@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@ -2346,26 +2523,45 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
deepmerge-ts@7.1.5: {}
|
||||||
|
|
||||||
|
defu@6.1.4: {}
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
|
destr@2.0.5: {}
|
||||||
|
|
||||||
dotenv@16.5.0: {}
|
dotenv@16.5.0: {}
|
||||||
|
|
||||||
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.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:
|
ejs@3.1.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
jake: 10.9.2
|
jake: 10.9.2
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
|
empathic@2.0.0: {}
|
||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
|
|
||||||
es-define-property@1.0.1: {}
|
es-define-property@1.0.1: {}
|
||||||
@ -2425,8 +2621,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
exsolve@1.0.7: {}
|
||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
|
|
||||||
|
fast-check@3.23.2:
|
||||||
|
dependencies:
|
||||||
|
pure-rand: 6.1.0
|
||||||
|
|
||||||
filelist@1.0.4:
|
filelist@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimatch: 5.1.6
|
minimatch: 5.1.6
|
||||||
@ -2501,6 +2703,15 @@ snapshots:
|
|||||||
dunder-proto: 1.0.1
|
dunder-proto: 1.0.1
|
||||||
es-object-atoms: 1.1.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: {}
|
google-logging-utils@0.0.2: {}
|
||||||
|
|
||||||
gopd@1.2.0: {}
|
gopd@1.2.0: {}
|
||||||
@ -2574,8 +2785,46 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
bignumber.js: 9.3.0
|
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.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: {}
|
long@5.3.2: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@ -2624,16 +2873,28 @@ snapshots:
|
|||||||
|
|
||||||
node-addon-api@8.3.1: {}
|
node-addon-api@8.3.1: {}
|
||||||
|
|
||||||
|
node-fetch-native@1.6.7: {}
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
node-gyp-build@4.8.4: {}
|
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-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.3: {}
|
object-inspect@1.13.3: {}
|
||||||
|
|
||||||
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
on-finished@2.3.0:
|
on-finished@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
@ -2654,6 +2915,10 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@8.2.0: {}
|
path-to-regexp@8.2.0: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
pg-int8@1.0.1: {}
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
pg-protocol@1.9.5: {}
|
pg-protocol@1.9.5: {}
|
||||||
@ -2666,6 +2931,12 @@ snapshots:
|
|||||||
postgres-date: 1.0.7
|
postgres-date: 1.0.7
|
||||||
postgres-interval: 1.2.0
|
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-array@2.0.0: {}
|
||||||
|
|
||||||
postgres-bytea@1.0.0: {}
|
postgres-bytea@1.0.0: {}
|
||||||
@ -2676,10 +2947,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xtend: 4.0.2
|
xtend: 4.0.2
|
||||||
|
|
||||||
prisma@6.8.2:
|
prisma@6.16.2(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/config': 6.8.2
|
'@prisma/config': 6.16.2
|
||||||
'@prisma/engines': 6.8.2
|
'@prisma/engines': 6.16.2
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.8.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
protobufjs@7.5.4:
|
protobufjs@7.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -2703,6 +2978,8 @@ snapshots:
|
|||||||
|
|
||||||
proxy-from-env@1.1.0: {}
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
|
pure-rand@6.1.0: {}
|
||||||
|
|
||||||
qs@6.14.0:
|
qs@6.14.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
@ -2716,6 +2993,13 @@ snapshots:
|
|||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
unpipe: 1.0.0
|
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-directory@2.1.1: {}
|
||||||
|
|
||||||
require-in-the-middle@7.5.2:
|
require-in-the-middle@7.5.2:
|
||||||
@ -2748,6 +3032,8 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
|
semver@7.7.2: {}
|
||||||
|
|
||||||
send@1.2.0:
|
send@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
@ -2823,6 +3109,8 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tinyexec@1.0.1: {}
|
||||||
|
|
||||||
toidentifier@1.0.1: {}
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
tr46@0.0.3: {}
|
tr46@0.0.3: {}
|
||||||
@ -2833,6 +3121,9 @@ snapshots:
|
|||||||
media-typer: 1.1.0
|
media-typer: 1.1.0
|
||||||
mime-types: 3.0.1
|
mime-types: 3.0.1
|
||||||
|
|
||||||
|
typescript@5.8.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
undici-types@7.11.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 {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "mysql"
|
||||||
url = "file:../data/db.db"
|
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 {
|
model KVStore {
|
||||||
namespace String
|
deviceId Int // 设备ID,作为namespace的一部分
|
||||||
key String
|
key String
|
||||||
value Json
|
value Json
|
||||||
creatorIp String? @default("")
|
creatorIp String? @default("")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Device {
|
||||||
uuid String @id
|
id Int @id @default(autoincrement())
|
||||||
password String?
|
uuid String @unique // 设备的唯一标识符
|
||||||
passwordHint String?
|
|
||||||
name String?
|
name String?
|
||||||
accessType AccessType @default(PUBLIC)
|
accountId String? // 关联的账户ID
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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);
|
console.debug(encodedKey);
|
||||||
return encodedKey === actualKey;
|
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();
|
const prisma = new PrismaClient();
|
||||||
class KVStore {
|
class KVStore {
|
||||||
/**
|
/**
|
||||||
* 通过命名空间和键名获取值
|
* 通过设备ID和键名获取值
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @param {string} key - 键名
|
* @param {string} key - 键名
|
||||||
* @returns {object|null} 键对应的值或null
|
* @returns {object|null} 键对应的值或null
|
||||||
*/
|
*/
|
||||||
async get(namespace, key) {
|
async get(deviceId, key) {
|
||||||
const item = await prisma.kVStore.findUnique({
|
const item = await prisma.kVStore.findUnique({
|
||||||
where: {
|
where: {
|
||||||
namespace_key: {
|
deviceId_key: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
key: key,
|
key: key,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -21,21 +21,21 @@ class KVStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取键的完整信息(包括元数据)
|
* 获取键的完整信息(包括元数据)
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @param {string} key - 键名
|
* @param {string} key - 键名
|
||||||
* @returns {object|null} 键的完整信息或null
|
* @returns {object|null} 键的完整信息或null
|
||||||
*/
|
*/
|
||||||
async getMetadata(namespace, key) {
|
async getMetadata(deviceId, key) {
|
||||||
const item = await prisma.kVStore.findUnique({
|
const item = await prisma.kVStore.findUnique({
|
||||||
where: {
|
where: {
|
||||||
namespace_key: {
|
deviceId_key: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
key: key,
|
key: key,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
key: true,
|
key: true,
|
||||||
namespace: true,
|
deviceId: true,
|
||||||
creatorIp: true,
|
creatorIp: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
@ -46,7 +46,7 @@ class KVStore {
|
|||||||
|
|
||||||
// 转换为更友好的格式
|
// 转换为更友好的格式
|
||||||
return {
|
return {
|
||||||
namespace: item.namespace,
|
deviceId: item.deviceId,
|
||||||
key: item.key,
|
key: item.key,
|
||||||
metadata: {
|
metadata: {
|
||||||
creatorIp: item.creatorIp,
|
creatorIp: item.creatorIp,
|
||||||
@ -57,18 +57,18 @@ class KVStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在指定命名空间下创建或更新键值
|
* 在指定设备下创建或更新键值
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @param {string} key - 键名
|
* @param {string} key - 键名
|
||||||
* @param {object} value - 键值
|
* @param {object} value - 键值
|
||||||
* @param {string} creatorIp - 创建者IP,可选
|
* @param {string} creatorIp - 创建者IP,可选
|
||||||
* @returns {object} 创建或更新的记录
|
* @returns {object} 创建或更新的记录
|
||||||
*/
|
*/
|
||||||
async upsert(namespace, key, value, creatorIp = "") {
|
async upsert(deviceId, key, value, creatorIp = "") {
|
||||||
const item = await prisma.kVStore.upsert({
|
const item = await prisma.kVStore.upsert({
|
||||||
where: {
|
where: {
|
||||||
namespace_key: {
|
deviceId_key: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
key: key,
|
key: key,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -77,17 +77,16 @@ class KVStore {
|
|||||||
...(creatorIp && { creatorIp }),
|
...(creatorIp && { creatorIp }),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
key: key,
|
key: key,
|
||||||
value,
|
value,
|
||||||
creatorIp,
|
creatorIp,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回带有命名空间和原始键的结果
|
// 返回带有设备ID和原始键的结果
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
deviceId,
|
||||||
namespace,
|
|
||||||
key,
|
key,
|
||||||
value: item.value,
|
value: item.value,
|
||||||
creatorIp: item.creatorIp,
|
creatorIp: item.creatorIp,
|
||||||
@ -97,22 +96,22 @@ class KVStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过命名空间和键名删除
|
* 通过设备ID和键名删除
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @param {string} key - 键名
|
* @param {string} key - 键名
|
||||||
* @returns {object|null} 删除的记录或null
|
* @returns {object|null} 删除的记录或null
|
||||||
*/
|
*/
|
||||||
async delete(namespace, key) {
|
async delete(deviceId, key) {
|
||||||
try {
|
try {
|
||||||
const item = await prisma.kVStore.delete({
|
const item = await prisma.kVStore.delete({
|
||||||
where: {
|
where: {
|
||||||
namespace_key: {
|
deviceId_key: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
key: key,
|
key: key,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return item ? { ...item, namespace, key } : null;
|
return item ? { ...item, deviceId, key } : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 忽略记录不存在的错误
|
// 忽略记录不存在的错误
|
||||||
if (error.code === "P2025") return null;
|
if (error.code === "P2025") return null;
|
||||||
@ -121,25 +120,25 @@ class KVStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出指定命名空间下的所有键名及其元数据
|
* 列出指定设备下的所有键名及其元数据
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @param {object} options - 选项参数
|
* @param {object} options - 选项参数
|
||||||
* @returns {Array} 键名和元数据数组
|
* @returns {Array} 键名和元数据数组
|
||||||
*/
|
*/
|
||||||
async list(namespace, options = {}) {
|
async list(deviceId, options = {}) {
|
||||||
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
||||||
|
|
||||||
// 构建排序条件
|
// 构建排序条件
|
||||||
const orderBy = {};
|
const orderBy = {};
|
||||||
orderBy[sortBy] = sortDir.toLowerCase();
|
orderBy[sortBy] = sortDir.toLowerCase();
|
||||||
|
|
||||||
// 查询以命名空间开头的所有键
|
// 查询设备的所有键
|
||||||
const items = await prisma.kVStore.findMany({
|
const items = await prisma.kVStore.findMany({
|
||||||
where: {
|
where: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
namespace: true,
|
deviceId: true,
|
||||||
key: true,
|
key: true,
|
||||||
creatorIp: true,
|
creatorIp: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@ -151,32 +150,35 @@ class KVStore {
|
|||||||
skip: skip,
|
skip: skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理结果,从键名中移除命名空间前缀
|
// 处理结果
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
namespace: item.namespace,
|
deviceId: item.deviceId,
|
||||||
key: item.key,
|
key: item.key,
|
||||||
value: item.value,
|
metadata: {
|
||||||
metadata: item.metadata,
|
creatorIp: item.creatorIp,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定命名空间下的键名列表(不包括内容)
|
* 获取指定设备下的键名列表(不包括内容)
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @param {object} options - 查询选项
|
* @param {object} options - 查询选项
|
||||||
* @returns {Array} 键名列表
|
* @returns {Array} 键名列表
|
||||||
*/
|
*/
|
||||||
async listKeysOnly(namespace, options = {}) {
|
async listKeysOnly(deviceId, options = {}) {
|
||||||
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
||||||
|
|
||||||
// 构建排序条件
|
// 构建排序条件
|
||||||
const orderBy = {};
|
const orderBy = {};
|
||||||
orderBy[sortBy] = sortDir.toLowerCase();
|
orderBy[sortBy] = sortDir.toLowerCase();
|
||||||
|
|
||||||
// 查询以命名空间开头的所有键,只选择键名
|
// 查询设备的所有键,只选择键名
|
||||||
const items = await prisma.kVStore.findMany({
|
const items = await prisma.kVStore.findMany({
|
||||||
where: {
|
where: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
key: true,
|
key: true,
|
||||||
@ -191,14 +193,14 @@ class KVStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计指定命名空间下的键值对数量
|
* 统计指定设备下的键值对数量
|
||||||
* @param {string} namespace - 命名空间
|
* @param {number} deviceId - 设备ID
|
||||||
* @returns {number} 键值对数量
|
* @returns {number} 键值对数量
|
||||||
*/
|
*/
|
||||||
async count(namespace) {
|
async count(deviceId) {
|
||||||
const count = await prisma.kVStore.count({
|
const count = await prisma.kVStore.count({
|
||||||
where: {
|
where: {
|
||||||
namespace: namespace,
|
deviceId: deviceId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return count;
|
return count;
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
import kvStore from "./kvStore.js";
|
import kvStore from "./kvStore.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 系统保留UUID用于存储站点信息
|
||||||
|
const SYSTEM_DEVICE_UUID = "00000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
// 存储 readme 值的内存变量
|
// 存储 readme 值的内存变量
|
||||||
let readmeValue = null;
|
let readmeValue = null;
|
||||||
|
let systemDeviceId = null;
|
||||||
|
|
||||||
// 封装默认 readme 对象
|
// 封装默认 readme 对象
|
||||||
const defaultReadme = {
|
const defaultReadme = {
|
||||||
@ -9,16 +16,40 @@ const defaultReadme = {
|
|||||||
readme: "暂无 Readme 内容",
|
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 值
|
* 初始化 readme 值
|
||||||
* 在应用启动时调用此函数
|
* 在应用启动时调用此函数
|
||||||
*/
|
*/
|
||||||
export const initReadme = async () => {
|
export const initReadme = async () => {
|
||||||
try {
|
try {
|
||||||
const storedValue = await kvStore.get(
|
const deviceId = await getSystemDeviceId();
|
||||||
"00000000-0000-4000-8000-000000000000",
|
const storedValue = await kvStore.get(deviceId, "info");
|
||||||
"info"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 合并默认值与存储值,确保结构完整
|
// 合并默认值与存储值,确保结构完整
|
||||||
readmeValue = {
|
readmeValue = {
|
||||||
@ -53,11 +84,8 @@ export const getReadmeValue = () => {
|
|||||||
*/
|
*/
|
||||||
export const updateReadmeValue = async (newValue) => {
|
export const updateReadmeValue = async (newValue) => {
|
||||||
try {
|
try {
|
||||||
await kvStore.upsert(
|
const deviceId = await getSystemDeviceId();
|
||||||
"00000000-0000-4000-8000-000000000000",
|
await kvStore.upsert(deviceId, "info", newValue);
|
||||||
"info",
|
|
||||||
newValue
|
|
||||||
);
|
|
||||||
readmeValue = {
|
readmeValue = {
|
||||||
...defaultReadme,
|
...defaultReadme,
|
||||||
...newValue,
|
...newValue,
|
||||||
|
|||||||
@ -4,33 +4,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>
|
<title>Classworks 服务端</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>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>
|
<h1>Classworks 服务端</h1>
|
||||||
<%= readmeValue.title || "Classworks 服务端" %>
|
<p>服务运行中</p>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="readme-content">
|
|
||||||
<pre><%= readmeValue.readme %></pre>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user