Compare commits
4
Commits
e9d923fea9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
617b3e1b8c | ||
|
|
34a0c1c56f | ||
|
|
2cf807b59c | ||
|
|
1567a3dea3 |
No files matched your search
@@ -1,7 +1,11 @@
|
|||||||
# 数据文件(敏感信息)
|
# 数据文件(敏感信息)
|
||||||
data/*.json
|
data/*.json
|
||||||
|
data/tokens/
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Claude Code 配置
|
||||||
|
.claude/
|
||||||
|
|
||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,700 @@
|
|||||||
|
# Token 认证系统需求文档
|
||||||
|
|
||||||
|
## 1. 项目背景
|
||||||
|
|
||||||
|
当前数据同步服务使用单一密码 `admin123123123` 进行认证,存在以下问题:
|
||||||
|
- 无法区分不同客户端的访问
|
||||||
|
- 无法追踪访问来源和使用情况
|
||||||
|
- 泄露后需要全局修改所有客户端配置
|
||||||
|
- 无法对不同客户端进行细粒度的权限控制
|
||||||
|
|
||||||
|
## 2. 核心目标
|
||||||
|
|
||||||
|
在保持向后兼容的前提下,增加 Token 认证机制,实现:
|
||||||
|
- 多 Token 管理(不同客户端使用不同 Token)
|
||||||
|
- 访问审计(记录每个 Token 的使用情况)
|
||||||
|
- 权限控制(只读/读写)
|
||||||
|
- 安全增强(失败次数限制、限流、双因素验证)
|
||||||
|
|
||||||
|
## 3. 功能需求
|
||||||
|
|
||||||
|
### 3.1 Token 设计
|
||||||
|
|
||||||
|
#### Token 生成规则
|
||||||
|
- **格式**:`dss_` + 32位随机字符串(十六进制)
|
||||||
|
- **示例**:`dss_a1b2c3d4e5f6789012345678901234ab`
|
||||||
|
- **唯一性**:每个 Token 全局唯一
|
||||||
|
|
||||||
|
#### Token 属性
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "dss_a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-09-13 10:30:00",
|
||||||
|
"expires_at": null,
|
||||||
|
"stats": {
|
||||||
|
"total_requests": 1523,
|
||||||
|
"last_used_at": "2026-09-13 15:20:10",
|
||||||
|
"last_ip": "192.168.1.100",
|
||||||
|
"failed_attempts": 0
|
||||||
|
},
|
||||||
|
"access_log": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
- `token`: Token 字符串
|
||||||
|
- `name`: 用户自定义名称(必填,用于识别客户端)
|
||||||
|
- `permission`: 权限级别
|
||||||
|
- `readonly`: 只读(只能 GET)
|
||||||
|
- `readwrite`: 读写(可 GET/POST/DELETE)
|
||||||
|
- `status`: Token 状态
|
||||||
|
- `active`: 启用
|
||||||
|
- `disabled`: 禁用
|
||||||
|
- `expired`: 已过期
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `expires_at`: 过期时间(null 表示永久有效)
|
||||||
|
- `stats.total_requests`: 累计请求次数
|
||||||
|
- `stats.last_used_at`: 最后使用时间
|
||||||
|
- `stats.last_ip`: 最后使用的 IP 地址
|
||||||
|
- `stats.failed_attempts`: 连续失败次数(达到 5 次自动禁用)
|
||||||
|
- `access_log`: 访问日志数组(保留最近 20 条)
|
||||||
|
|
||||||
|
#### 访问日志格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-13 15:20:10",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/data/weibo:ykaayk",
|
||||||
|
"status": 200,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 API 设计
|
||||||
|
|
||||||
|
#### 认证方式
|
||||||
|
|
||||||
|
**新方式(推荐)**:
|
||||||
|
```http
|
||||||
|
GET /api/data/weibo:ykaayk
|
||||||
|
X-Token: dss_a1b2c3d4e5f6789012345678901234ab
|
||||||
|
```
|
||||||
|
|
||||||
|
**旧方式(兼容保留)**:
|
||||||
|
```http
|
||||||
|
GET /api/data/weibo:ykaayk
|
||||||
|
X-Password: admin123123123
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- `X-Password` 作为超级管理员密码,拥有所有权限,不计入审计日志
|
||||||
|
- `X-Token` 为普通 Token,受权限和限流控制,记录审计日志
|
||||||
|
- 两种方式可以并存,优先检查 `X-Token`
|
||||||
|
|
||||||
|
#### Token 管理 API
|
||||||
|
|
||||||
|
##### 1. 创建 Token
|
||||||
|
```http
|
||||||
|
POST /api/tokens
|
||||||
|
X-Password: admin123123123
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"expires_in": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**:
|
||||||
|
- `name`: Token 名称(必填)
|
||||||
|
- `permission`: 权限(必填,`readonly` 或 `readwrite`)
|
||||||
|
- `expires_in`: 过期时间(秒,可选,null 表示永久)
|
||||||
|
- 2592000 = 30天
|
||||||
|
- 7776000 = 90天
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "dss_a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"created_at": "2026-09-13 10:30:00",
|
||||||
|
"expires_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 2. 列出所有 Token
|
||||||
|
```http
|
||||||
|
GET /api/tokens
|
||||||
|
X-Password: admin123123123
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tokens": [
|
||||||
|
{
|
||||||
|
"token": "dss_a1b2c3...(前12位)",
|
||||||
|
"token_full": "dss_a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-09-13 10:30:00",
|
||||||
|
"expires_at": null,
|
||||||
|
"stats": {
|
||||||
|
"total_requests": 1523,
|
||||||
|
"last_used_at": "2026-09-13 15:20:10",
|
||||||
|
"last_ip": "192.168.1.100",
|
||||||
|
"failed_attempts": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 3. 获取 Token 详情
|
||||||
|
```http
|
||||||
|
GET /api/tokens/:token
|
||||||
|
X-Password: admin123123123
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "dss_a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-09-13 10:30:00",
|
||||||
|
"expires_at": null,
|
||||||
|
"stats": {
|
||||||
|
"total_requests": 1523,
|
||||||
|
"last_used_at": "2026-09-13 15:20:10",
|
||||||
|
"last_ip": "192.168.1.100",
|
||||||
|
"failed_attempts": 0
|
||||||
|
},
|
||||||
|
"access_log": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-13 15:20:10",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/data/weibo:ykaayk",
|
||||||
|
"status": 200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 4. 更新 Token 状态
|
||||||
|
```http
|
||||||
|
PATCH /api/tokens/:token
|
||||||
|
X-Password: admin123123123
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "disabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**可更新字段**:
|
||||||
|
- `status`: `active` 或 `disabled`
|
||||||
|
- `name`: 重命名
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "dss_a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"status": "disabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 5. 删除 Token(需要二次确认)
|
||||||
|
```http
|
||||||
|
DELETE /api/tokens/:token
|
||||||
|
X-Password: admin123123123
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"confirm_password": "admin123123123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Token deleted",
|
||||||
|
"token": "dss_a1b2c3d4e5f6789012345678901234ab"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 权限控制
|
||||||
|
|
||||||
|
#### 只读权限(readonly)
|
||||||
|
- ✅ `GET /api/data/:tag`
|
||||||
|
- ✅ `GET /api/data`
|
||||||
|
- ❌ `POST /api/data`
|
||||||
|
- ❌ `DELETE /api/data/:tag`
|
||||||
|
|
||||||
|
#### 读写权限(readwrite)
|
||||||
|
- ✅ 所有操作
|
||||||
|
|
||||||
|
#### 权限验证失败响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Permission denied",
|
||||||
|
"message": "This token has readonly permission",
|
||||||
|
"required": "readwrite"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 安全机制
|
||||||
|
|
||||||
|
#### 1. 限流(Rate Limiting)
|
||||||
|
- **规则**:每个 Token 每分钟最多 60 次请求
|
||||||
|
- **超出后响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Rate limit exceeded",
|
||||||
|
"message": "Maximum 60 requests per minute",
|
||||||
|
"retry_after": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **状态码**:429
|
||||||
|
|
||||||
|
#### 2. 失败次数限制
|
||||||
|
- **规则**:连续失败 5 次自动禁用 Token
|
||||||
|
- **失败场景**:
|
||||||
|
- Token 不存在
|
||||||
|
- Token 已过期
|
||||||
|
- Token 已禁用
|
||||||
|
- **自动禁用后响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Token disabled",
|
||||||
|
"message": "Token has been disabled due to too many failed attempts",
|
||||||
|
"failed_attempts": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 过期检测
|
||||||
|
- 每次请求时检查 `expires_at`
|
||||||
|
- 过期 Token 自动标记为 `expired` 状态
|
||||||
|
- 过期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Token expired",
|
||||||
|
"expires_at": "2026-09-10 10:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 双因素验证(敏感操作)
|
||||||
|
敏感操作需要同时提供 Token 和主密码:
|
||||||
|
- 删除 Token
|
||||||
|
- 修改 Token 权限(从 readonly 改为 readwrite)
|
||||||
|
|
||||||
|
### 3.5 数据存储
|
||||||
|
|
||||||
|
#### 文件结构
|
||||||
|
```
|
||||||
|
/data/
|
||||||
|
tokens.json # Token 索引和基本信息
|
||||||
|
tokens/
|
||||||
|
dss_a1b2c3...json # 单个 Token 的完整数据
|
||||||
|
```
|
||||||
|
|
||||||
|
#### tokens.json 格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tokens": {
|
||||||
|
"dss_a1b2c3d4e5f6789012345678901234ab": {
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-09-13 10:30:00",
|
||||||
|
"expires_at": null,
|
||||||
|
"file": "tokens/dss_a1b2c3d4e5f6789012345678901234ab.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 单个 Token 文件格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "dss_a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"name": "生产服务器A",
|
||||||
|
"permission": "readonly",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-09-13 10:30:00",
|
||||||
|
"expires_at": null,
|
||||||
|
"stats": {
|
||||||
|
"total_requests": 1523,
|
||||||
|
"last_used_at": "2026-09-13 15:20:10",
|
||||||
|
"last_ip": "192.168.1.100",
|
||||||
|
"failed_attempts": 0
|
||||||
|
},
|
||||||
|
"access_log": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-13 15:20:10",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/data/weibo:ykaayk",
|
||||||
|
"status": 200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Web 界面需求
|
||||||
|
|
||||||
|
### 4.1 Token 管理页面
|
||||||
|
|
||||||
|
#### 入口
|
||||||
|
在现有 Web 管理界面(`http://124.220.209.75:5001/`)顶部导航栏添加:
|
||||||
|
```
|
||||||
|
[数据管理] [Token 管理]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Token 列表页
|
||||||
|
|
||||||
|
**布局**:
|
||||||
|
```
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
| Token 管理 [+ 创建 Token] |
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
| 搜索: [__________] 状态: [全部 ▼] 权限: [全部 ▼] |
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
| 名称 | Token (前12位) | 权限 | 状态 | 最后使用 | 操作 |
|
||||||
|
|----------------|---------------|-------|------|-----------|------------|
|
||||||
|
| 生产服务器A | dss_a1b2c3... | 只读 | 启用 | 5分钟前 | [详情][禁用][删除] |
|
||||||
|
| 测试脚本 | dss_xyz789... | 读写 | 禁用 | 2天前 | [详情][启用][删除] |
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能**:
|
||||||
|
- 表格展示所有 Token
|
||||||
|
- 搜索框(按名称过滤)
|
||||||
|
- 下拉筛选(状态、权限)
|
||||||
|
- 快速操作按钮(详情、启用/禁用、删除)
|
||||||
|
- Token 列显示前 12 位,点击可复制完整 Token
|
||||||
|
|
||||||
|
#### 创建 Token 弹框
|
||||||
|
|
||||||
|
**触发**:点击 [+ 创建 Token] 按钮
|
||||||
|
|
||||||
|
**弹框内容**:
|
||||||
|
```
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| 创建 Token |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| Token 名称 * |
|
||||||
|
| [_______________________________________] |
|
||||||
|
| 用于识别客户端,如"生产服务器A" |
|
||||||
|
| |
|
||||||
|
| 权限范围 * |
|
||||||
|
| ○ 只读(仅查询数据) |
|
||||||
|
| ● 读写(可上传、删除数据) |
|
||||||
|
| |
|
||||||
|
| 过期设置 * |
|
||||||
|
| ● 永久有效 |
|
||||||
|
| ○ 30天后过期 |
|
||||||
|
| ○ 90天后过期 |
|
||||||
|
| ○ 自定义: [___] 天 |
|
||||||
|
| |
|
||||||
|
| [取消] [创建] |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证规则**:
|
||||||
|
- Token 名称:必填,1-50 字符
|
||||||
|
- 权限范围:必选
|
||||||
|
- 过期设置:必选
|
||||||
|
|
||||||
|
**成功后**:
|
||||||
|
- 关闭弹框
|
||||||
|
- 显示新创建的 Token 详情弹框(包含完整 Token,提示复制保存)
|
||||||
|
- 刷新列表
|
||||||
|
|
||||||
|
#### Token 详情弹框
|
||||||
|
|
||||||
|
**触发**:
|
||||||
|
1. 点击列表中的 [详情] 按钮
|
||||||
|
2. 创建 Token 成功后自动弹出
|
||||||
|
|
||||||
|
**弹框内容**:
|
||||||
|
```
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| Token 详情 |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| Token 值 |
|
||||||
|
| [••••••••••••••••••••••••••••] [👁 显示] [📋 复制] |
|
||||||
|
| |
|
||||||
|
| 基本信息 |
|
||||||
|
| 名称: 生产服务器A |
|
||||||
|
| 权限: 只读 |
|
||||||
|
| 状态: 🟢 启用中 |
|
||||||
|
| 创建时间: 2026-09-13 10:30:00 |
|
||||||
|
| 过期时间: 永久有效 |
|
||||||
|
| |
|
||||||
|
| 使用统计 |
|
||||||
|
| 总请求数: 1,523 次 |
|
||||||
|
| 最后使用: 2026-09-13 15:20:10 (5分钟前) |
|
||||||
|
| 最后 IP: 192.168.1.100 |
|
||||||
|
| 失败次数: 0 |
|
||||||
|
| |
|
||||||
|
| 访问日志 (最近 20 条) |
|
||||||
|
| +-------------------------------------------+ |
|
||||||
|
| | 时间 | IP | 操作 | 状态 | |
|
||||||
|
| |------------------|--------------|---------|-----| |
|
||||||
|
| | 15:20:10 | 192.168.1.100| GET ...| 200 | |
|
||||||
|
| | 15:15:05 | 192.168.1.100| GET ...| 200 | |
|
||||||
|
| +-------------------------------------------+ |
|
||||||
|
| |
|
||||||
|
| [关闭] [修改] [删除] |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能**:
|
||||||
|
- Token 值默认遮罩,点击 [👁 显示] 切换显示/隐藏
|
||||||
|
- [📋 复制] 按钮复制完整 Token
|
||||||
|
- 状态图标:🟢 启用中 / 🔴 已禁用 / ⏰ 已过期
|
||||||
|
- 访问日志表格可滚动
|
||||||
|
- [修改] 按钮打开编辑弹框
|
||||||
|
- [删除] 按钮触发删除确认
|
||||||
|
|
||||||
|
#### 编辑 Token 弹框
|
||||||
|
|
||||||
|
**触发**:点击详情弹框中的 [修改] 按钮
|
||||||
|
|
||||||
|
**弹框内容**:
|
||||||
|
```
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| 编辑 Token |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| Token 名称 * |
|
||||||
|
| [生产服务器A_____________________________] |
|
||||||
|
| |
|
||||||
|
| 状态 |
|
||||||
|
| ● 启用 |
|
||||||
|
| ○ 禁用 |
|
||||||
|
| |
|
||||||
|
| [取消] [保存] |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 权限和过期时间创建后不可修改
|
||||||
|
- 只能修改名称和状态
|
||||||
|
|
||||||
|
#### 删除 Token 确认弹框
|
||||||
|
|
||||||
|
**触发**:点击 [删除] 按钮
|
||||||
|
|
||||||
|
**弹框内容**:
|
||||||
|
```
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| ⚠️ 删除 Token |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| 确认要删除以下 Token 吗? |
|
||||||
|
| |
|
||||||
|
| 名称: 生产服务器A |
|
||||||
|
| Token: dss_a1b2c3... (前12位) |
|
||||||
|
| |
|
||||||
|
| ⚠️ 此操作不可恢复,使用此 Token 的客户端将无法访问 |
|
||||||
|
| |
|
||||||
|
| 输入主密码以确认: |
|
||||||
|
| [_______________________________________] |
|
||||||
|
| |
|
||||||
|
| [取消] [确认删除] |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- 必须输入正确的主密码(`admin123123123`)
|
||||||
|
- 密码错误显示红色提示:"密码错误,请重试"
|
||||||
|
|
||||||
|
### 4.2 UI/UX 规范
|
||||||
|
|
||||||
|
#### 通知提示
|
||||||
|
- **成功**:绿色,5 秒自动关闭
|
||||||
|
- "✅ Token 创建成功"
|
||||||
|
- "✅ Token 已禁用"
|
||||||
|
- "✅ Token 已删除"
|
||||||
|
- **错误**:红色,15 秒自动关闭
|
||||||
|
- "❌ Token 名称不能为空"
|
||||||
|
- "❌ 主密码错误"
|
||||||
|
- "❌ Token 不存在"
|
||||||
|
- **信息**:蓝色,8 秒自动关闭
|
||||||
|
- "📋 Token 已复制到剪贴板"
|
||||||
|
|
||||||
|
#### 表单验证
|
||||||
|
- 实时验证(输入时)
|
||||||
|
- 错误时红框 + 聚焦
|
||||||
|
- 验证失败不关闭弹框
|
||||||
|
|
||||||
|
#### 异步操作反馈
|
||||||
|
- 按钮状态:`[创建]` → `[创建中...]` + 禁用
|
||||||
|
- 操作开始时显示通知:"正在创建 Token..."
|
||||||
|
- 完成后更新状态和通知
|
||||||
|
|
||||||
|
#### 颜色规范
|
||||||
|
- 主色:`#667eea`(紫色)
|
||||||
|
- 成功:`#10b981`(绿色)
|
||||||
|
- 警告:`#f59e0b`(橙色)
|
||||||
|
- 错误:`#ef4444`(红色)
|
||||||
|
- 信息:`#3b82f6`(蓝色)
|
||||||
|
|
||||||
|
## 5. 实现技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **语言**:Lua (OpenResty)
|
||||||
|
- **存储**:JSON 文件系统
|
||||||
|
- **配置文件**:`nginx/nginx.conf`(需要修改)
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **框架**:纯 HTML/CSS/JavaScript(无框架依赖)
|
||||||
|
- **样式**:参考现有 `web/index.html` 风格
|
||||||
|
- **HTTP 客户端**:Fetch API
|
||||||
|
|
||||||
|
## 6. 实现步骤建议
|
||||||
|
|
||||||
|
### Phase 1: 后端 Token 验证逻辑
|
||||||
|
1. 在 `nginx.conf` 中添加 Token 验证函数
|
||||||
|
2. 修改现有 API 路由,增加 `X-Token` 认证支持
|
||||||
|
3. 实现权限检查(readonly vs readwrite)
|
||||||
|
4. 保留 `X-Password` 向后兼容
|
||||||
|
|
||||||
|
### Phase 2: Token 管理 API
|
||||||
|
1. 实现 `POST /api/tokens` 创建 Token
|
||||||
|
2. 实现 `GET /api/tokens` 列出所有 Token
|
||||||
|
3. 实现 `GET /api/tokens/:token` 获取详情
|
||||||
|
4. 实现 `PATCH /api/tokens/:token` 更新状态
|
||||||
|
5. 实现 `DELETE /api/tokens/:token` 删除 Token
|
||||||
|
|
||||||
|
### Phase 3: 审计和安全机制
|
||||||
|
1. 记录每次 Token 使用(时间、IP、操作)
|
||||||
|
2. 实现限流逻辑(60次/分钟)
|
||||||
|
3. 实现失败次数统计和自动禁用
|
||||||
|
4. 实现过期检测
|
||||||
|
|
||||||
|
### Phase 4: Web 界面
|
||||||
|
1. 在现有 `web/index.html` 顶部添加导航切换
|
||||||
|
2. 创建 Token 管理页面(列表 + 搜索过滤)
|
||||||
|
3. 实现创建 Token 弹框
|
||||||
|
4. 实现 Token 详情弹框
|
||||||
|
5. 实现编辑和删除功能
|
||||||
|
|
||||||
|
### Phase 5: 测试和文档
|
||||||
|
1. 手动测试所有 API 端点
|
||||||
|
2. 验证权限控制和安全机制
|
||||||
|
3. 更新 README.md 文档
|
||||||
|
4. 提供 Token 使用示例
|
||||||
|
|
||||||
|
## 7. 向后兼容性
|
||||||
|
|
||||||
|
### 现有客户端
|
||||||
|
- 使用 `X-Password: admin123123123` 的现有脚本和工具继续正常工作
|
||||||
|
- 无需立即迁移到 Token 机制
|
||||||
|
|
||||||
|
### 迁移路径
|
||||||
|
1. 部署新版本(增加 Token 功能)
|
||||||
|
2. 在 Web 界面创建新 Token
|
||||||
|
3. 逐步更新客户端配置(从 `X-Password` 改为 `X-Token`)
|
||||||
|
4. 最终可选择性禁用 `X-Password`(配置开关)
|
||||||
|
|
||||||
|
## 8. 测试用例
|
||||||
|
|
||||||
|
### API 测试
|
||||||
|
```bash
|
||||||
|
# 1. 创建 Token
|
||||||
|
curl -X POST http://localhost:5001/api/tokens \
|
||||||
|
-H "X-Password: admin123123123" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"测试Token","permission":"readonly","expires_in":null}'
|
||||||
|
|
||||||
|
# 2. 使用 Token 获取数据
|
||||||
|
curl http://localhost:5001/api/data/weibo:ykaayk \
|
||||||
|
-H "X-Token: dss_xxxxx"
|
||||||
|
|
||||||
|
# 3. 测试只读权限(应该失败)
|
||||||
|
curl -X POST http://localhost:5001/api/data \
|
||||||
|
-H "X-Token: dss_xxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tag":"test","type":"token","data":{}}'
|
||||||
|
|
||||||
|
# 4. 列出所有 Token
|
||||||
|
curl http://localhost:5001/api/tokens \
|
||||||
|
-H "X-Password: admin123123123"
|
||||||
|
|
||||||
|
# 5. 禁用 Token
|
||||||
|
curl -X PATCH http://localhost:5001/api/tokens/dss_xxxxx \
|
||||||
|
-H "X-Password: admin123123123" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"disabled"}'
|
||||||
|
|
||||||
|
# 6. 删除 Token
|
||||||
|
curl -X DELETE http://localhost:5001/api/tokens/dss_xxxxx \
|
||||||
|
-H "X-Password: admin123123123" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"confirm_password":"admin123123123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 验收标准
|
||||||
|
|
||||||
|
### 功能验收
|
||||||
|
- ✅ 可以通过 Web 界面创建 Token
|
||||||
|
- ✅ 创建后显示完整 Token 值(一次性展示)
|
||||||
|
- ✅ Token 列表正确显示所有 Token 及其状态
|
||||||
|
- ✅ 只读 Token 无法执行写操作
|
||||||
|
- ✅ 读写 Token 可以执行所有操作
|
||||||
|
- ✅ 访问日志正确记录(时间、IP、操作、状态)
|
||||||
|
- ✅ 限流生效(60次/分钟)
|
||||||
|
- ✅ 连续失败 5 次自动禁用
|
||||||
|
- ✅ 过期 Token 无法使用
|
||||||
|
- ✅ 删除 Token 需要二次确认
|
||||||
|
- ✅ 旧的 `X-Password` 认证继续工作
|
||||||
|
|
||||||
|
### UI/UX 验收
|
||||||
|
- ✅ 所有弹框居中显示,带遮罩层
|
||||||
|
- ✅ 表单验证实时生效,红框 + 聚焦
|
||||||
|
- ✅ 异步操作有加载状态和通知
|
||||||
|
- ✅ 通知自动关闭(成功5秒,错误15秒)
|
||||||
|
- ✅ Token 值支持显示/隐藏切换
|
||||||
|
- ✅ 点击复制成功后有反馈
|
||||||
|
- ✅ 响应式布局,移动端友好
|
||||||
|
|
||||||
|
### 安全验收
|
||||||
|
- ✅ Token 存储在服务器端,前端只展示一次
|
||||||
|
- ✅ 主密码验证正确(创建、删除时)
|
||||||
|
- ✅ 限流正确拦截过多请求
|
||||||
|
- ✅ 失败次数统计准确
|
||||||
|
- ✅ 权限控制无漏洞
|
||||||
|
|
||||||
|
## 10. 附录:错误码对照表
|
||||||
|
|
||||||
|
| 状态码 | 错误信息 | 场景 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| 200 | - | 请求成功 |
|
||||||
|
| 400 | Invalid request | 请求参数错误 |
|
||||||
|
| 401 | Invalid token | Token 不存在或无效 |
|
||||||
|
| 401 | Token disabled | Token 已被禁用 |
|
||||||
|
| 401 | Token expired | Token 已过期 |
|
||||||
|
| 403 | Permission denied | 权限不足(只读 Token 尝试写操作)|
|
||||||
|
| 404 | Token not found | Token 不存在 |
|
||||||
|
| 429 | Rate limit exceeded | 超过限流限制 |
|
||||||
|
| 500 | Internal server error | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**创建日期**:2026-09-13
|
||||||
|
**作者**:Hermes AI Agent
|
||||||
|
**项目路径**:`/home/hermes/projects/data-sync-service`
|
||||||
Regular → Executable
File mode changed.
+529
-5
@@ -26,7 +26,7 @@ http {
|
|||||||
# CORS 配置
|
# CORS 配置
|
||||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS' always;
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS' always;
|
||||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, X-Password' always;
|
add_header 'Access-Control-Allow-Headers' 'Content-Type, X-Password, X-Token' always;
|
||||||
|
|
||||||
# OPTIONS 请求
|
# OPTIONS 请求
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
@@ -350,9 +350,109 @@ http {
|
|||||||
|
|
||||||
-- DELETE 请求
|
-- DELETE 请求
|
||||||
if method == "DELETE" then
|
if method == "DELETE" then
|
||||||
-- 验证密码
|
-- 验证密码或 Token
|
||||||
local password = ngx.var.http_x_password
|
local password = ngx.var.http_x_password
|
||||||
if password ~= "admin123123123" then
|
local token = ngx.var.http_x_token
|
||||||
|
local authenticated = false
|
||||||
|
|
||||||
|
-- Try token authentication first
|
||||||
|
if token then
|
||||||
|
local token_index_file = io.open("/data/tokens.json", "r")
|
||||||
|
if token_index_file then
|
||||||
|
local token_index_content = token_index_file:read("*all")
|
||||||
|
token_index_file:close()
|
||||||
|
|
||||||
|
local ok, token_index = pcall(cjson.decode, token_index_content)
|
||||||
|
if ok and token_index.tokens and token_index.tokens[token] then
|
||||||
|
local token_file = io.open("/data/" .. token_index.tokens[token].file, "r")
|
||||||
|
if token_file then
|
||||||
|
local token_content = token_file:read("*all")
|
||||||
|
token_file:close()
|
||||||
|
|
||||||
|
local ok, token_data = pcall(cjson.decode, token_content)
|
||||||
|
if ok then
|
||||||
|
if token_data.status == "disabled" then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Token disabled"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if token_data.expires_at then
|
||||||
|
local expires_time = os.time({
|
||||||
|
year = tonumber(token_data.expires_at:sub(1, 4)),
|
||||||
|
month = tonumber(token_data.expires_at:sub(6, 7)),
|
||||||
|
day = tonumber(token_data.expires_at:sub(9, 10)),
|
||||||
|
hour = tonumber(token_data.expires_at:sub(12, 13)),
|
||||||
|
min = tonumber(token_data.expires_at:sub(15, 16)),
|
||||||
|
sec = tonumber(token_data.expires_at:sub(18, 19))
|
||||||
|
})
|
||||||
|
|
||||||
|
if expires_time < os.time() then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Token expired", expires_at = token_data.expires_at}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local limit_key = "rate_limit:" .. token
|
||||||
|
local dict = ngx.shared.data_cache
|
||||||
|
local count = dict:get(limit_key) or 0
|
||||||
|
|
||||||
|
if count >= 60 then
|
||||||
|
ngx.status = 429
|
||||||
|
ngx.say(cjson.encode({error = "Rate limit exceeded", message = "Maximum 60 requests per minute", retry_after = 60}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
dict:incr(limit_key, 1, 0, 60)
|
||||||
|
|
||||||
|
-- Check permission for DELETE (write operation)
|
||||||
|
if token_data.permission == "readonly" then
|
||||||
|
ngx.status = 403
|
||||||
|
ngx.say(cjson.encode({error = "Permission denied", message = "This token has readonly permission", required = "readwrite"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
authenticated = true
|
||||||
|
|
||||||
|
-- Update stats
|
||||||
|
token_data.stats = token_data.stats or {}
|
||||||
|
token_data.stats.total_requests = (token_data.stats.total_requests or 0) + 1
|
||||||
|
token_data.stats.last_used_at = os.date("%Y-%m-%d %H:%M:%S")
|
||||||
|
token_data.stats.last_ip = ngx.var.remote_addr
|
||||||
|
token_data.stats.failed_attempts = 0
|
||||||
|
|
||||||
|
-- Add to access log
|
||||||
|
token_data.access_log = token_data.access_log or {}
|
||||||
|
table.insert(token_data.access_log, 1, {
|
||||||
|
timestamp = os.date("%Y-%m-%d %H:%M:%S"),
|
||||||
|
ip = ngx.var.remote_addr,
|
||||||
|
method = "DELETE",
|
||||||
|
path = ngx.var.uri,
|
||||||
|
status = 200
|
||||||
|
})
|
||||||
|
|
||||||
|
while #token_data.access_log > 20 do
|
||||||
|
table.remove(token_data.access_log)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Save updated token data
|
||||||
|
local save_file = io.open("/data/" .. token_index.tokens[token].file, "w")
|
||||||
|
if save_file then
|
||||||
|
save_file:write(cjson.encode(token_data))
|
||||||
|
save_file:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not authenticated then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Invalid token"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
elseif password ~= "admin123123123" then
|
||||||
ngx.status = 401
|
ngx.status = 401
|
||||||
ngx.say(cjson.encode({error = "Invalid password"}))
|
ngx.say(cjson.encode({error = "Invalid password"}))
|
||||||
return
|
return
|
||||||
@@ -393,9 +493,101 @@ http {
|
|||||||
|
|
||||||
-- GET 请求
|
-- GET 请求
|
||||||
if method == "GET" then
|
if method == "GET" then
|
||||||
-- 验证密码
|
-- 验证密码或 Token
|
||||||
local password = ngx.var.http_x_password
|
local password = ngx.var.http_x_password
|
||||||
if password ~= "admin123123123" then
|
local token = ngx.var.http_x_token
|
||||||
|
local authenticated = false
|
||||||
|
|
||||||
|
-- Try token authentication first
|
||||||
|
if token then
|
||||||
|
local token_index_file = io.open("/data/tokens.json", "r")
|
||||||
|
if token_index_file then
|
||||||
|
local token_index_content = token_index_file:read("*all")
|
||||||
|
token_index_file:close()
|
||||||
|
|
||||||
|
local ok, token_index = pcall(cjson.decode, token_index_content)
|
||||||
|
if ok and token_index.tokens and token_index.tokens[token] then
|
||||||
|
local token_file = io.open("/data/" .. token_index.tokens[token].file, "r")
|
||||||
|
if token_file then
|
||||||
|
local token_content = token_file:read("*all")
|
||||||
|
token_file:close()
|
||||||
|
|
||||||
|
local ok, token_data = pcall(cjson.decode, token_content)
|
||||||
|
if ok then
|
||||||
|
if token_data.status == "disabled" then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Token disabled"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if token_data.expires_at then
|
||||||
|
local expires_time = os.time({
|
||||||
|
year = tonumber(token_data.expires_at:sub(1, 4)),
|
||||||
|
month = tonumber(token_data.expires_at:sub(6, 7)),
|
||||||
|
day = tonumber(token_data.expires_at:sub(9, 10)),
|
||||||
|
hour = tonumber(token_data.expires_at:sub(12, 13)),
|
||||||
|
min = tonumber(token_data.expires_at:sub(15, 16)),
|
||||||
|
sec = tonumber(token_data.expires_at:sub(18, 19))
|
||||||
|
})
|
||||||
|
|
||||||
|
if expires_time < os.time() then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Token expired", expires_at = token_data.expires_at}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local limit_key = "rate_limit:" .. token
|
||||||
|
local dict = ngx.shared.data_cache
|
||||||
|
local count = dict:get(limit_key) or 0
|
||||||
|
|
||||||
|
if count >= 60 then
|
||||||
|
ngx.status = 429
|
||||||
|
ngx.say(cjson.encode({error = "Rate limit exceeded", message = "Maximum 60 requests per minute", retry_after = 60}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
dict:incr(limit_key, 1, 0, 60)
|
||||||
|
authenticated = true
|
||||||
|
|
||||||
|
-- Update stats
|
||||||
|
token_data.stats = token_data.stats or {}
|
||||||
|
token_data.stats.total_requests = (token_data.stats.total_requests or 0) + 1
|
||||||
|
token_data.stats.last_used_at = os.date("%Y-%m-%d %H:%M:%S")
|
||||||
|
token_data.stats.last_ip = ngx.var.remote_addr
|
||||||
|
token_data.stats.failed_attempts = 0
|
||||||
|
|
||||||
|
-- Add to access log
|
||||||
|
token_data.access_log = token_data.access_log or {}
|
||||||
|
table.insert(token_data.access_log, 1, {
|
||||||
|
timestamp = os.date("%Y-%m-%d %H:%M:%S"),
|
||||||
|
ip = ngx.var.remote_addr,
|
||||||
|
method = "GET",
|
||||||
|
path = ngx.var.uri,
|
||||||
|
status = 200
|
||||||
|
})
|
||||||
|
|
||||||
|
while #token_data.access_log > 20 do
|
||||||
|
table.remove(token_data.access_log)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Save updated token data
|
||||||
|
local save_file = io.open("/data/" .. token_index.tokens[token].file, "w")
|
||||||
|
if save_file then
|
||||||
|
save_file:write(cjson.encode(token_data))
|
||||||
|
save_file:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not authenticated then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Invalid token"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
elseif password ~= "admin123123123" then
|
||||||
ngx.status = 401
|
ngx.status = 401
|
||||||
ngx.say(cjson.encode({error = "Invalid password"}))
|
ngx.say(cjson.encode({error = "Invalid password"}))
|
||||||
return
|
return
|
||||||
@@ -422,5 +614,337 @@ http {
|
|||||||
ngx.say(cjson.encode({error = "Method not allowed"}))
|
ngx.say(cjson.encode({error = "Method not allowed"}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Token Management API
|
||||||
|
location = /api/tokens {
|
||||||
|
content_by_lua_block {
|
||||||
|
local cjson = require "cjson"
|
||||||
|
local method = ngx.req.get_method()
|
||||||
|
|
||||||
|
-- Verify admin password
|
||||||
|
local password = ngx.var.http_x_password
|
||||||
|
if password ~= "admin123123123" then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Invalid password"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if method == "POST" then
|
||||||
|
-- Parse request
|
||||||
|
ngx.req.read_body()
|
||||||
|
local body = ngx.req.get_body_data()
|
||||||
|
local ok, data = pcall(cjson.decode, body)
|
||||||
|
|
||||||
|
if not ok or not data.name or not data.permission then
|
||||||
|
ngx.status = 400
|
||||||
|
ngx.say(cjson.encode({error = "Missing required fields: name, permission"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Validate permission
|
||||||
|
if data.permission ~= "readonly" and data.permission ~= "readwrite" then
|
||||||
|
ngx.status = 400
|
||||||
|
ngx.say(cjson.encode({error = "Invalid permission, must be readonly or readwrite"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Generate random token (dss_ + 32 hex chars)
|
||||||
|
math.randomseed(os.time() * 1000 + os.clock() * 1000000)
|
||||||
|
local charset = "0123456789abcdef"
|
||||||
|
local token_suffix = ""
|
||||||
|
for i = 1, 32 do
|
||||||
|
local rand = math.random(1, #charset)
|
||||||
|
token_suffix = token_suffix .. charset:sub(rand, rand)
|
||||||
|
end
|
||||||
|
local token = "dss_" .. token_suffix
|
||||||
|
|
||||||
|
-- Create token data
|
||||||
|
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
|
||||||
|
local expires_at = nil
|
||||||
|
if data.expires_in and type(data.expires_in) == "number" and data.expires_in > 0 then
|
||||||
|
expires_at = os.date("%Y-%m-%d %H:%M:%S", os.time() + data.expires_in)
|
||||||
|
end
|
||||||
|
|
||||||
|
local token_data = {
|
||||||
|
token = token,
|
||||||
|
name = data.name,
|
||||||
|
permission = data.permission,
|
||||||
|
status = "active",
|
||||||
|
created_at = timestamp,
|
||||||
|
expires_at = expires_at,
|
||||||
|
stats = {
|
||||||
|
total_requests = 0,
|
||||||
|
last_used_at = nil,
|
||||||
|
last_ip = nil,
|
||||||
|
failed_attempts = 0
|
||||||
|
},
|
||||||
|
access_log = cjson.empty_array
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Create tokens directory if needed
|
||||||
|
os.execute("mkdir -p /data/tokens")
|
||||||
|
|
||||||
|
-- Save token file
|
||||||
|
local filename = "tokens/" .. token .. ".json"
|
||||||
|
local filepath = "/data/" .. filename
|
||||||
|
local file = io.open(filepath, "w")
|
||||||
|
if not file then
|
||||||
|
ngx.status = 500
|
||||||
|
ngx.say(cjson.encode({error = "Failed to save token"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
file:write(cjson.encode(token_data))
|
||||||
|
file:close()
|
||||||
|
|
||||||
|
-- Update index
|
||||||
|
local index_file = io.open("/data/tokens.json", "r")
|
||||||
|
local index = {tokens = {}}
|
||||||
|
if index_file then
|
||||||
|
local content = index_file:read("*all")
|
||||||
|
index_file:close()
|
||||||
|
local ok, parsed = pcall(cjson.decode, content)
|
||||||
|
if ok then index = parsed end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not index.tokens then index.tokens = {} end
|
||||||
|
index.tokens[token] = {
|
||||||
|
name = data.name,
|
||||||
|
permission = data.permission,
|
||||||
|
status = "active",
|
||||||
|
created_at = timestamp,
|
||||||
|
expires_at = expires_at,
|
||||||
|
file = filename
|
||||||
|
}
|
||||||
|
|
||||||
|
local index_file = io.open("/data/tokens.json", "w")
|
||||||
|
if index_file then
|
||||||
|
index_file:write(cjson.encode(index))
|
||||||
|
index_file:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.status = 200
|
||||||
|
ngx.say(cjson.encode({
|
||||||
|
success = true,
|
||||||
|
token = token,
|
||||||
|
name = data.name,
|
||||||
|
permission = data.permission,
|
||||||
|
created_at = timestamp,
|
||||||
|
expires_at = token_data.expires_at
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if method == "GET" then
|
||||||
|
-- List tokens
|
||||||
|
local index_file = io.open("/data/tokens.json", "r")
|
||||||
|
if not index_file then
|
||||||
|
ngx.status = 200
|
||||||
|
ngx.say(cjson.encode({tokens = {}}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = index_file:read("*all")
|
||||||
|
index_file:close()
|
||||||
|
|
||||||
|
local ok, index = pcall(cjson.decode, content)
|
||||||
|
if not ok then
|
||||||
|
ngx.status = 500
|
||||||
|
ngx.say(cjson.encode({error = "Invalid token index"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local tokens_list = {}
|
||||||
|
for token, info in pairs(index.tokens or {}) do
|
||||||
|
local token_file = io.open("/data/" .. info.file, "r")
|
||||||
|
if token_file then
|
||||||
|
local token_content = token_file:read("*all")
|
||||||
|
token_file:close()
|
||||||
|
|
||||||
|
local ok, token_data = pcall(cjson.decode, token_content)
|
||||||
|
if ok then
|
||||||
|
table.insert(tokens_list, {
|
||||||
|
token = token:sub(1, 12) .. "...",
|
||||||
|
token_full = token,
|
||||||
|
name = info.name,
|
||||||
|
permission = info.permission,
|
||||||
|
status = token_data.status,
|
||||||
|
created_at = info.created_at,
|
||||||
|
expires_at = info.expires_at,
|
||||||
|
stats = token_data.stats
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.status = 200
|
||||||
|
ngx.say(cjson.encode({tokens = tokens_list}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.status = 405
|
||||||
|
ngx.say(cjson.encode({error = "Method not allowed"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Token detail operations
|
||||||
|
location ~ ^/api/tokens/([^/]+)$ {
|
||||||
|
content_by_lua_block {
|
||||||
|
local cjson = require "cjson"
|
||||||
|
local token = ngx.var[1]
|
||||||
|
local method = ngx.req.get_method()
|
||||||
|
|
||||||
|
-- Verify admin password
|
||||||
|
local password = ngx.var.http_x_password
|
||||||
|
if password ~= "admin123123123" then
|
||||||
|
ngx.status = 401
|
||||||
|
ngx.say(cjson.encode({error = "Invalid password"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Load token index
|
||||||
|
local index_file = io.open("/data/tokens.json", "r")
|
||||||
|
if not index_file then
|
||||||
|
ngx.status = 404
|
||||||
|
ngx.say(cjson.encode({error = "Token not found"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local index_content = index_file:read("*all")
|
||||||
|
index_file:close()
|
||||||
|
|
||||||
|
local ok, index = pcall(cjson.decode, index_content)
|
||||||
|
if not ok or not index.tokens or not index.tokens[token] then
|
||||||
|
ngx.status = 404
|
||||||
|
ngx.say(cjson.encode({error = "Token not found"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local token_filepath = "/data/" .. index.tokens[token].file
|
||||||
|
|
||||||
|
if method == "GET" then
|
||||||
|
-- Get token details
|
||||||
|
local token_file = io.open(token_filepath, "r")
|
||||||
|
if not token_file then
|
||||||
|
ngx.status = 404
|
||||||
|
ngx.say(cjson.encode({error = "Token file not found"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = token_file:read("*all")
|
||||||
|
token_file:close()
|
||||||
|
|
||||||
|
ngx.status = 200
|
||||||
|
ngx.say(content)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if method == "PATCH" then
|
||||||
|
-- Update token
|
||||||
|
ngx.req.read_body()
|
||||||
|
local body = ngx.req.get_body_data()
|
||||||
|
local ok, update_data = pcall(cjson.decode, body)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
ngx.status = 400
|
||||||
|
ngx.say(cjson.encode({error = "Invalid JSON"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Load current token data
|
||||||
|
local token_file = io.open(token_filepath, "r")
|
||||||
|
if not token_file then
|
||||||
|
ngx.status = 404
|
||||||
|
ngx.say(cjson.encode({error = "Token file not found"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = token_file:read("*all")
|
||||||
|
token_file:close()
|
||||||
|
|
||||||
|
local ok, token_data = pcall(cjson.decode, content)
|
||||||
|
if not ok then
|
||||||
|
ngx.status = 500
|
||||||
|
ngx.say(cjson.encode({error = "Invalid token data"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update allowed fields
|
||||||
|
if update_data.name then
|
||||||
|
token_data.name = update_data.name
|
||||||
|
index.tokens[token].name = update_data.name
|
||||||
|
end
|
||||||
|
|
||||||
|
if update_data.status then
|
||||||
|
if update_data.status == "active" or update_data.status == "disabled" then
|
||||||
|
token_data.status = update_data.status
|
||||||
|
index.tokens[token].status = update_data.status
|
||||||
|
|
||||||
|
-- Reset failed attempts when re-enabling
|
||||||
|
if update_data.status == "active" and token_data.stats then
|
||||||
|
token_data.stats.failed_attempts = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Save updated token
|
||||||
|
local file = io.open(token_filepath, "w")
|
||||||
|
if file then
|
||||||
|
file:write(cjson.encode(token_data))
|
||||||
|
file:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Save updated index
|
||||||
|
local index_file = io.open("/data/tokens.json", "w")
|
||||||
|
if index_file then
|
||||||
|
index_file:write(cjson.encode(index))
|
||||||
|
index_file:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.status = 200
|
||||||
|
ngx.say(cjson.encode({
|
||||||
|
success = true,
|
||||||
|
token = token,
|
||||||
|
status = token_data.status,
|
||||||
|
name = token_data.name
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if method == "DELETE" then
|
||||||
|
-- Delete token (requires password confirmation in body)
|
||||||
|
ngx.req.read_body()
|
||||||
|
local body = ngx.req.get_body_data()
|
||||||
|
local ok, delete_data = pcall(cjson.decode, body)
|
||||||
|
|
||||||
|
if not ok or not delete_data.confirm_password or delete_data.confirm_password ~= "admin123123123" then
|
||||||
|
ngx.status = 400
|
||||||
|
ngx.say(cjson.encode({error = "Confirmation password required"}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Delete token file
|
||||||
|
os.remove(token_filepath)
|
||||||
|
|
||||||
|
-- Update index
|
||||||
|
index.tokens[token] = nil
|
||||||
|
local index_file = io.open("/data/tokens.json", "w")
|
||||||
|
if index_file then
|
||||||
|
index_file:write(cjson.encode(index))
|
||||||
|
index_file:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.status = 200
|
||||||
|
ngx.say(cjson.encode({
|
||||||
|
success = true,
|
||||||
|
message = "Token deleted",
|
||||||
|
token = token
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.status = 405
|
||||||
|
ngx.say(cjson.encode({error = "Method not allowed"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+738
-97
@@ -405,50 +405,50 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.category-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tab {
|
||||||
|
padding: 10px 20px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 15px 20px;
|
border: 2px solid transparent;
|
||||||
margin: 15px 0 10px 0;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
font-size: 15px;
|
||||||
justify-content: space-between;
|
font-weight: 500;
|
||||||
align-items: center;
|
color: #666;
|
||||||
transition: background 0.3s;
|
transition: all 0.3s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header:hover {
|
.category-tab:hover {
|
||||||
background: #e9ecef;
|
background: #e9ecef;
|
||||||
}
|
|
||||||
|
|
||||||
.group-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-count {
|
.category-tab.active {
|
||||||
color: #999;
|
background: #667eea;
|
||||||
font-size: 14px;
|
color: white;
|
||||||
margin-left: 10px;
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-toggle {
|
.category-count {
|
||||||
font-size: 20px;
|
display: inline-block;
|
||||||
color: #667eea;
|
margin-left: 8px;
|
||||||
transition: transform 0.3s;
|
padding: 2px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-toggle.collapsed {
|
.category-tab.active .category-count {
|
||||||
transform: rotate(-90deg);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
|
||||||
|
|
||||||
.group-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-content.collapsed {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs {
|
.api-docs {
|
||||||
@@ -479,6 +479,40 @@
|
|||||||
.method-get { background: #28a745; color: white; }
|
.method-get { background: #28a745; color: white; }
|
||||||
.method-post { background: #007bff; color: white; }
|
.method-post { background: #007bff; color: white; }
|
||||||
.method-delete { background: #dc3545; color: white; }
|
.method-delete { background: #dc3545; color: white; }
|
||||||
|
|
||||||
|
/* Navigation tabs */
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
#token-page {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -487,8 +521,16 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🔄 通用数据同步服务</h1>
|
<h1>🔄 通用数据同步服务</h1>
|
||||||
<p class="subtitle">Cookie / Token / Credential 管理面板</p>
|
<p class="subtitle">Cookie / Token / Credential 管理面板</p>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="nav-tabs" style="margin-top: 20px;">
|
||||||
|
<button class="nav-tab active" onclick="switchPage('data')">📋 数据管理</button>
|
||||||
|
<button class="nav-tab" onclick="switchPage('tokens')">🔑 Token 管理</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Management Page -->
|
||||||
|
<div id="data-page">
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -524,6 +566,8 @@
|
|||||||
<input type="text" class="search-input" id="search-input" placeholder="🔍 搜索 Tag..." oninput="filterData()">
|
<input type="text" class="search-input" id="search-input" placeholder="🔍 搜索 Tag..." oninput="filterData()">
|
||||||
<span class="search-icon">🔍</span>
|
<span class="search-icon">🔍</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Category Tabs -->
|
||||||
|
<div class="category-tabs" id="category-tabs"></div>
|
||||||
<div id="data-container">
|
<div id="data-container">
|
||||||
<div class="loading">正在加载...</div>
|
<div class="loading">正在加载...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,6 +597,55 @@
|
|||||||
<code>/api/data/copy/:tag</code> - 获取复制配置
|
<code>/api/data/copy/:tag</code> - 获取复制配置
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- End Data Management Page -->
|
||||||
|
|
||||||
|
<!-- Token Management Page -->
|
||||||
|
<div id="token-page" style="display: none;">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🔑</div>
|
||||||
|
<div class="stat-label">总 Token</div>
|
||||||
|
<div class="stat-value" id="token-total-count">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">✅</div>
|
||||||
|
<div class="stat-label">启用</div>
|
||||||
|
<div class="stat-value" id="token-active-count">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🚫</div>
|
||||||
|
<div class="stat-label">禁用</div>
|
||||||
|
<div class="stat-value" id="token-disabled-count">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">⏰</div>
|
||||||
|
<div class="stat-label">已过期</div>
|
||||||
|
<div class="stat-value" id="token-expired-count">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token List -->
|
||||||
|
<div class="data-list">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h2 style="margin: 0;">🔑 Token 列表</h2>
|
||||||
|
<button class="btn btn-primary" onclick="openCreateTokenModal()">+ 创建 Token</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" class="search-input" id="token-search-input" placeholder="🔍 搜索 Token 名称..." oninput="filterTokens()">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="token-container">
|
||||||
|
<div class="loading">正在加载...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- End Token Management Page -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Refresh Button -->
|
<!-- Refresh Button -->
|
||||||
@@ -592,11 +685,177 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Token Modal -->
|
||||||
|
<div class="modal-overlay" id="create-token-modal">
|
||||||
|
<div class="modal" style="width: 500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">创建 Token</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('create-token-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 名称 *</label>
|
||||||
|
<input type="text" id="token-name" placeholder="如:生产服务器A" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
<p style="color: #999; font-size: 12px; margin-top: 5px;">用于识别客户端</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">权限范围 *</label>
|
||||||
|
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||||||
|
<input type="radio" name="token-permission" value="readonly" checked> 只读(仅查询数据)
|
||||||
|
</label>
|
||||||
|
<label style="display: block; cursor: pointer;">
|
||||||
|
<input type="radio" name="token-permission" value="readwrite"> 读写(可上传、删除数据)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">过期设置 *</label>
|
||||||
|
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||||||
|
<input type="radio" name="token-expiry" value="0" checked> 永久有效
|
||||||
|
</label>
|
||||||
|
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||||||
|
<input type="radio" name="token-expiry" value="2592000"> 30天后过期
|
||||||
|
</label>
|
||||||
|
<label style="display: block; cursor: pointer;">
|
||||||
|
<input type="radio" name="token-expiry" value="7776000"> 90天后过期
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('create-token-modal')">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitCreateToken()">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Details Modal -->
|
||||||
|
<div class="modal-overlay" id="token-details-modal">
|
||||||
|
<div class="modal" style="width: 900px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Token 详情</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('token-details-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<div style="margin-bottom: 25px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 值</label>
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<input type="password" id="token-value-display" readonly style="flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-family: monospace; background: #f8f9fa;">
|
||||||
|
<button class="btn btn-secondary" onclick="toggleTokenVisibility()" id="toggle-token-btn">👁 显示</button>
|
||||||
|
<button class="btn btn-success" onclick="copyTokenValue()">📋 复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 25px;">
|
||||||
|
<h3 style="margin-bottom: 10px;">基本信息</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||||
|
<p style="margin-bottom: 8px;"><strong>名称:</strong> <span id="token-detail-name">-</span></p>
|
||||||
|
<p style="margin-bottom: 8px;"><strong>权限:</strong> <span id="token-detail-permission">-</span></p>
|
||||||
|
<p style="margin-bottom: 8px;"><strong>状态:</strong> <span id="token-detail-status">-</span></p>
|
||||||
|
<p style="margin-bottom: 8px;"><strong>创建时间:</strong> <span id="token-detail-created">-</span></p>
|
||||||
|
<p><strong>过期时间:</strong> <span id="token-detail-expires">-</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 25px;">
|
||||||
|
<h3 style="margin-bottom: 10px;">使用统计</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||||
|
<p style="margin-bottom: 8px;"><strong>总请求数:</strong> <span id="token-detail-requests">-</span></p>
|
||||||
|
<p style="margin-bottom: 8px;"><strong>最后使用:</strong> <span id="token-detail-last-used">-</span></p>
|
||||||
|
<p style="margin-bottom: 8px;"><strong>最后 IP:</strong> <span id="token-detail-last-ip">-</span></p>
|
||||||
|
<p><strong>失败次数:</strong> <span id="token-detail-failed">-</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style="margin-bottom: 10px;">访问日志(最近 20 条)</h3>
|
||||||
|
<div style="overflow-x: auto;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f8f9fa; border-bottom: 2px solid #e0e0e0;">
|
||||||
|
<th style="padding: 10px; text-align: left;">时间</th>
|
||||||
|
<th style="padding: 10px; text-align: left;">IP</th>
|
||||||
|
<th style="padding: 10px; text-align: left;">方法</th>
|
||||||
|
<th style="padding: 10px; text-align: left;">路径</th>
|
||||||
|
<th style="padding: 10px; text-align: left;">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="token-access-log">
|
||||||
|
<tr><td colspan="5" style="padding: 20px; text-align: center; color: #999;">暂无访问记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('token-details-modal')">关闭</button>
|
||||||
|
<button class="btn btn-primary" onclick="openEditTokenModal()">修改</button>
|
||||||
|
<button class="btn btn-danger" onclick="confirmDeleteToken()">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Token Modal -->
|
||||||
|
<div class="modal-overlay" id="edit-token-modal">
|
||||||
|
<div class="modal" style="width: 500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">编辑 Token</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('edit-token-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 名称 *</label>
|
||||||
|
<input type="text" id="edit-token-name" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">状态</label>
|
||||||
|
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||||||
|
<input type="radio" name="edit-token-status" value="active" checked> 启用
|
||||||
|
</label>
|
||||||
|
<label style="display: block; cursor: pointer;">
|
||||||
|
<input type="radio" name="edit-token-status" value="disabled"> 禁用
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('edit-token-modal')">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitEditToken()">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Token Confirmation Modal -->
|
||||||
|
<div class="modal-overlay confirm-modal" id="delete-token-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">⚠️ 删除 Token</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('delete-token-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="confirm-message" style="margin-bottom: 15px;">确认要删除以下 Token 吗?</p>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
|
||||||
|
<p style="margin-bottom: 8px;"><strong>名称:</strong> <span id="delete-token-name">-</span></p>
|
||||||
|
<p><strong>Token:</strong> <span id="delete-token-value">-</span></p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #dc3545; margin-bottom: 15px;">⚠️ 此操作不可恢复,使用此 Token 的客户端将无法访问</p>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">输入主密码以确认:</label>
|
||||||
|
<input type="password" id="delete-token-password" placeholder="admin123123123" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('delete-token-modal')">取消</button>
|
||||||
|
<button class="btn btn-danger" onclick="submitDeleteToken()">确认删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentViewData = null;
|
let currentViewData = null;
|
||||||
let deleteCallback = null;
|
let deleteCallback = null;
|
||||||
let allDataItems = []; // 存储所有数据项
|
let allDataItems = []; // 存储所有数据项
|
||||||
let collapsedGroups = new Set(); // 存储折叠的分组
|
let currentCategory = '全部'; // 当前选中的分类
|
||||||
|
|
||||||
// Toast 通知
|
// Toast 通知
|
||||||
function showToast(message, type = 'success') {
|
function showToast(message, type = 'success') {
|
||||||
@@ -709,7 +968,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染数据列表(支持分组)
|
// 渲染数据列表(支持分类筛选)
|
||||||
function renderDataList(items) {
|
function renderDataList(items) {
|
||||||
const container = document.getElementById('data-container');
|
const container = document.getElementById('data-container');
|
||||||
|
|
||||||
@@ -718,90 +977,98 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按分组组织数据
|
// 按分类组织数据
|
||||||
const groups = {};
|
const categories = { '全部': items };
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const colonIndex = item.tag.indexOf(':');
|
const colonIndex = item.tag.indexOf(':');
|
||||||
const groupName = colonIndex > 0 ? item.tag.substring(0, colonIndex) : '未分组';
|
const categoryName = colonIndex > 0 ? item.tag.substring(0, colonIndex) : '未分组';
|
||||||
|
|
||||||
if (!groups[groupName]) {
|
if (!categories[categoryName]) {
|
||||||
groups[groupName] = [];
|
categories[categoryName] = [];
|
||||||
}
|
}
|
||||||
groups[groupName].push(item);
|
categories[categoryName].push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按分组名称排序
|
// 渲染分类标签
|
||||||
const sortedGroupNames = Object.keys(groups).sort((a, b) => {
|
renderCategoryTabs(categories);
|
||||||
|
|
||||||
|
// 根据当前选中分类过滤数据
|
||||||
|
const displayItems = currentCategory === '全部' ? items : (categories[currentCategory] || []);
|
||||||
|
|
||||||
|
// 渲染数据项
|
||||||
|
let html = '';
|
||||||
|
displayItems.forEach(item => {
|
||||||
|
const statusClass = `status-${item.status}`;
|
||||||
|
const statusText = {
|
||||||
|
'valid': '正常',
|
||||||
|
'warning': '即将过期',
|
||||||
|
'expired': '已过期'
|
||||||
|
}[item.status];
|
||||||
|
|
||||||
|
const escapedTag = item.tag.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-info">
|
||||||
|
<div class="data-tag">
|
||||||
|
${item.tag}
|
||||||
|
<span class="status-badge ${statusClass}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-meta">
|
||||||
|
类型: ${item.type} |
|
||||||
|
创建: ${item.created_at} |
|
||||||
|
过期: ${item.expires_at}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" onclick="viewData('${escapedTag}')">查看</button>
|
||||||
|
<button class="btn btn-success" onclick="copyTag('${escapedTag}')">📋 复制 Tag</button>
|
||||||
|
<button class="btn btn-success" onclick="copyDataJSON('${escapedTag}')">📄 复制 JSON</button>
|
||||||
|
<button class="btn btn-danger" onclick="confirmDelete('${escapedTag}')">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html || '<div class="empty">📭 该分类暂无数据</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染分类标签
|
||||||
|
function renderCategoryTabs(categories) {
|
||||||
|
const tabsContainer = document.getElementById('category-tabs');
|
||||||
|
|
||||||
|
// 按名称排序(全部放最前面)
|
||||||
|
const sortedCategories = Object.keys(categories).sort((a, b) => {
|
||||||
|
if (a === '全部') return -1;
|
||||||
|
if (b === '全部') return 1;
|
||||||
if (a === '未分组') return 1;
|
if (a === '未分组') return 1;
|
||||||
if (b === '未分组') return -1;
|
if (b === '未分组') return -1;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 渲染分组
|
|
||||||
let html = '';
|
let html = '';
|
||||||
sortedGroupNames.forEach(groupName => {
|
sortedCategories.forEach(categoryName => {
|
||||||
const groupItems = groups[groupName];
|
const count = categories[categoryName].length;
|
||||||
const isCollapsed = collapsedGroups.has(groupName);
|
const isActive = categoryName === currentCategory;
|
||||||
|
const escapedName = categoryName.replace(/'/g, "\\'");
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="group-header" onclick="toggleGroup('${groupName.replace(/'/g, "\\'")}')">
|
<button class="category-tab ${isActive ? 'active' : ''}"
|
||||||
<div>
|
onclick="switchCategory('${escapedName}')">
|
||||||
<span class="group-title">${groupName}</span>
|
${categoryName}
|
||||||
<span class="group-count">(${groupItems.length})</span>
|
<span class="category-count">${count}</span>
|
||||||
</div>
|
</button>
|
||||||
<span class="group-toggle ${isCollapsed ? 'collapsed' : ''}">▼</span>
|
|
||||||
</div>
|
|
||||||
<div class="group-content ${isCollapsed ? 'collapsed' : ''}" id="group-${groupName}">
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
groupItems.forEach(item => {
|
|
||||||
const statusClass = `status-${item.status}`;
|
|
||||||
const statusText = {
|
|
||||||
'valid': '正常',
|
|
||||||
'warning': '即将过期',
|
|
||||||
'expired': '已过期'
|
|
||||||
}[item.status];
|
|
||||||
|
|
||||||
const escapedTag = item.tag.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="data-item">
|
|
||||||
<div class="data-info">
|
|
||||||
<div class="data-tag">
|
|
||||||
${item.tag}
|
|
||||||
<span class="status-badge ${statusClass}">${statusText}</span>
|
|
||||||
</div>
|
|
||||||
<div class="data-meta">
|
|
||||||
类型: ${item.type} |
|
|
||||||
创建: ${item.created_at} |
|
|
||||||
过期: ${item.expires_at}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-primary" onclick="viewData('${escapedTag}')">查看</button>
|
|
||||||
<button class="btn btn-success" onclick="copyTag('${escapedTag}')">📋 复制 Tag</button>
|
|
||||||
<button class="btn btn-success" onclick="copyDataJSON('${escapedTag}')">📄 复制 JSON</button>
|
|
||||||
<button class="btn btn-danger" onclick="confirmDelete('${escapedTag}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = html;
|
tabsContainer.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换分组折叠/展开
|
// 切换分类
|
||||||
function toggleGroup(groupName) {
|
function switchCategory(categoryName) {
|
||||||
if (collapsedGroups.has(groupName)) {
|
currentCategory = categoryName;
|
||||||
collapsedGroups.delete(groupName);
|
|
||||||
} else {
|
|
||||||
collapsedGroups.add(groupName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新渲染当前显示的数据
|
// 应用搜索过滤(如果有)
|
||||||
const searchTerm = document.getElementById('search-input').value.trim().toLowerCase();
|
const searchTerm = document.getElementById('search-input').value.trim().toLowerCase();
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filterData();
|
filterData();
|
||||||
@@ -984,6 +1251,380 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= Token Management Functions =============
|
||||||
|
let allTokens = [];
|
||||||
|
let currentToken = null;
|
||||||
|
let tokenVisible = false;
|
||||||
|
|
||||||
|
// Page switching
|
||||||
|
function switchPage(page) {
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
|
||||||
|
|
||||||
|
if (page === 'data') {
|
||||||
|
document.querySelector('[onclick*="data"]').classList.add('active');
|
||||||
|
document.getElementById('data-page').style.display = 'block';
|
||||||
|
document.getElementById('token-page').style.display = 'none';
|
||||||
|
loadData();
|
||||||
|
} else if (page === 'tokens') {
|
||||||
|
document.querySelector('[onclick*="tokens"]').classList.add('active');
|
||||||
|
document.getElementById('data-page').style.display = 'none';
|
||||||
|
document.getElementById('token-page').style.display = 'block';
|
||||||
|
loadTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tokens
|
||||||
|
async function loadTokens() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tokens', {
|
||||||
|
headers: { 'X-Password': 'admin123123123' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
allTokens = data.tokens || [];
|
||||||
|
renderTokenList(allTokens);
|
||||||
|
updateTokenStats(allTokens);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load tokens error:', error);
|
||||||
|
showToast('加载 Token 失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update token stats
|
||||||
|
function updateTokenStats(tokens) {
|
||||||
|
let activeCount = 0;
|
||||||
|
let disabledCount = 0;
|
||||||
|
let expiredCount = 0;
|
||||||
|
|
||||||
|
tokens.forEach(token => {
|
||||||
|
if (token.status === 'active') activeCount++;
|
||||||
|
else if (token.status === 'disabled') disabledCount++;
|
||||||
|
else if (token.status === 'expired') expiredCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('token-total-count').textContent = tokens.length;
|
||||||
|
document.getElementById('token-active-count').textContent = activeCount;
|
||||||
|
document.getElementById('token-disabled-count').textContent = disabledCount;
|
||||||
|
document.getElementById('token-expired-count').textContent = expiredCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render token list
|
||||||
|
function renderTokenList(tokens) {
|
||||||
|
const container = document.getElementById('token-container');
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">📭 暂无 Token</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
tokens.forEach(token => {
|
||||||
|
const statusClass = token.status === 'active' ? 'status-valid' :
|
||||||
|
token.status === 'disabled' ? 'status-expired' : 'status-warning';
|
||||||
|
const statusText = token.status === 'active' ? '启用' :
|
||||||
|
token.status === 'disabled' ? '禁用' : '已过期';
|
||||||
|
const permissionText = token.permission === 'readonly' ? '只读' : '读写';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-info">
|
||||||
|
<div class="data-tag">
|
||||||
|
${token.name}
|
||||||
|
<span class="status-badge ${statusClass}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-meta">
|
||||||
|
Token: ${token.token} | 权限: ${permissionText} |
|
||||||
|
创建: ${token.created_at} |
|
||||||
|
请求数: ${token.stats.total_requests || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" onclick='viewTokenDetails("${token.token_full}")'>详情</button>
|
||||||
|
${token.status === 'active'
|
||||||
|
? `<button class="btn btn-secondary" onclick='toggleTokenStatus("${token.token_full}", "disabled")'>禁用</button>`
|
||||||
|
: `<button class="btn btn-success" onclick='toggleTokenStatus("${token.token_full}", "active")'>启用</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tokens
|
||||||
|
function filterTokens() {
|
||||||
|
const searchTerm = document.getElementById('token-search-input').value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
renderTokenList(allTokens);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allTokens.filter(token =>
|
||||||
|
token.name.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
renderTokenList(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open create token modal
|
||||||
|
function openCreateTokenModal() {
|
||||||
|
document.getElementById('token-name').value = '';
|
||||||
|
document.querySelector('input[name="token-permission"][value="readonly"]').checked = true;
|
||||||
|
document.querySelector('input[name="token-expiry"][value="0"]').checked = true;
|
||||||
|
openModal('create-token-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit create token
|
||||||
|
async function submitCreateToken() {
|
||||||
|
const name = document.getElementById('token-name').value.trim();
|
||||||
|
const permission = document.querySelector('input[name="token-permission"]:checked').value;
|
||||||
|
const expiresIn = parseInt(document.querySelector('input[name="token-expiry"]:checked').value);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast('请输入 Token 名称', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Password': 'admin123123123',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
permission: permission,
|
||||||
|
expires_in: expiresIn || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✅ Token 创建成功');
|
||||||
|
closeModal('create-token-modal');
|
||||||
|
await loadTokens();
|
||||||
|
viewTokenDetails(result.token);
|
||||||
|
} else {
|
||||||
|
showToast('❌ ' + (result.error || 'Token 创建失败'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('❌ 请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View token details
|
||||||
|
async function viewTokenDetails(token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tokens/${token}`, {
|
||||||
|
headers: { 'X-Password': 'admin123123123' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load token details');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await response.json();
|
||||||
|
currentToken = tokenData;
|
||||||
|
tokenVisible = false;
|
||||||
|
|
||||||
|
document.getElementById('token-value-display').value = tokenData.token;
|
||||||
|
document.getElementById('token-value-display').type = 'password';
|
||||||
|
document.getElementById('toggle-token-btn').textContent = '👁 显示';
|
||||||
|
|
||||||
|
document.getElementById('token-detail-name').textContent = tokenData.name;
|
||||||
|
document.getElementById('token-detail-permission').textContent =
|
||||||
|
tokenData.permission === 'readonly' ? '只读' : '读写';
|
||||||
|
document.getElementById('token-detail-status').textContent =
|
||||||
|
tokenData.status === 'active' ? '🟢 启用中' :
|
||||||
|
tokenData.status === 'disabled' ? '🔴 已禁用' : '⏰ 已过期';
|
||||||
|
document.getElementById('token-detail-created').textContent = tokenData.created_at;
|
||||||
|
document.getElementById('token-detail-expires').textContent = tokenData.expires_at || '永久有效';
|
||||||
|
|
||||||
|
const stats = tokenData.stats || {};
|
||||||
|
document.getElementById('token-detail-requests').textContent = stats.total_requests || 0;
|
||||||
|
document.getElementById('token-detail-last-used').textContent = stats.last_used_at || '从未使用';
|
||||||
|
document.getElementById('token-detail-last-ip').textContent = stats.last_ip || '-';
|
||||||
|
document.getElementById('token-detail-failed').textContent = stats.failed_attempts || 0;
|
||||||
|
|
||||||
|
// Render access log
|
||||||
|
const logBody = document.getElementById('token-access-log');
|
||||||
|
if (!tokenData.access_log || tokenData.access_log.length === 0) {
|
||||||
|
logBody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #999;">暂无访问记录</td></tr>';
|
||||||
|
} else {
|
||||||
|
logBody.innerHTML = tokenData.access_log.map(log => `
|
||||||
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
|
<td style="padding: 10px;">${log.timestamp}</td>
|
||||||
|
<td style="padding: 10px;">${log.ip}</td>
|
||||||
|
<td style="padding: 10px;">${log.method}</td>
|
||||||
|
<td style="padding: 10px; font-size: 12px;">${log.path}</td>
|
||||||
|
<td style="padding: 10px;">${log.status}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal('token-details-modal');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载 Token 详情失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle token visibility
|
||||||
|
function toggleTokenVisibility() {
|
||||||
|
const input = document.getElementById('token-value-display');
|
||||||
|
const btn = document.getElementById('toggle-token-btn');
|
||||||
|
|
||||||
|
if (tokenVisible) {
|
||||||
|
input.type = 'password';
|
||||||
|
btn.textContent = '👁 显示';
|
||||||
|
tokenVisible = false;
|
||||||
|
} else {
|
||||||
|
input.type = 'text';
|
||||||
|
btn.textContent = '🙈 隐藏';
|
||||||
|
tokenVisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy token value
|
||||||
|
function copyTokenValue() {
|
||||||
|
const tokenValue = document.getElementById('token-value-display').value;
|
||||||
|
copyToClipboard(tokenValue).then(success => {
|
||||||
|
if (success) {
|
||||||
|
showToast('Token 已复制到剪贴板');
|
||||||
|
} else {
|
||||||
|
showToast('复制失败,请手动选择复制', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit token modal
|
||||||
|
function openEditTokenModal() {
|
||||||
|
if (!currentToken) return;
|
||||||
|
|
||||||
|
document.getElementById('edit-token-name').value = currentToken.name;
|
||||||
|
document.querySelector(`input[name="edit-token-status"][value="${currentToken.status}"]`).checked = true;
|
||||||
|
|
||||||
|
closeModal('token-details-modal');
|
||||||
|
openModal('edit-token-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit edit token
|
||||||
|
async function submitEditToken() {
|
||||||
|
if (!currentToken) return;
|
||||||
|
|
||||||
|
const name = document.getElementById('edit-token-name').value.trim();
|
||||||
|
const status = document.querySelector('input[name="edit-token-status"]:checked').value;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast('请输入 Token 名称', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tokens/${currentToken.token}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'X-Password': 'admin123123123',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, status })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✅ Token 已更新');
|
||||||
|
closeModal('edit-token-modal');
|
||||||
|
await loadTokens();
|
||||||
|
viewTokenDetails(currentToken.token);
|
||||||
|
} else {
|
||||||
|
showToast('❌ ' + (result.error || '更新失败'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('❌ 请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle token status
|
||||||
|
async function toggleTokenStatus(token, newStatus) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tokens/${token}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'X-Password': 'admin123123123',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(newStatus === 'active' ? '✅ Token 已启用' : '✅ Token 已禁用');
|
||||||
|
loadTokens();
|
||||||
|
} else {
|
||||||
|
showToast('❌ 操作失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('❌ 请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm delete token
|
||||||
|
function confirmDeleteToken() {
|
||||||
|
if (!currentToken) return;
|
||||||
|
|
||||||
|
document.getElementById('delete-token-name').textContent = currentToken.name;
|
||||||
|
document.getElementById('delete-token-value').textContent = currentToken.token.substring(0, 12) + '...';
|
||||||
|
document.getElementById('delete-token-password').value = '';
|
||||||
|
|
||||||
|
closeModal('token-details-modal');
|
||||||
|
openModal('delete-token-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit delete token
|
||||||
|
async function submitDeleteToken() {
|
||||||
|
if (!currentToken) return;
|
||||||
|
|
||||||
|
const password = document.getElementById('delete-token-password').value;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
showToast('请输入主密码', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tokens/${currentToken.token}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-Password': 'admin123123123',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ confirm_password: password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✅ Token 已删除');
|
||||||
|
closeModal('delete-token-modal');
|
||||||
|
currentToken = null;
|
||||||
|
loadTokens();
|
||||||
|
} else {
|
||||||
|
showToast('❌ ' + (result.error || '删除失败'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('❌ 请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= End Token Management Functions =============
|
||||||
|
|
||||||
// 页面加载时获取数据
|
// 页面加载时获取数据
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user