Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andwhyour 0deebcfc88 Hide system log and login log tabs for non-admin users
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-11 16:53:19 +00:00
copilot-swe-agent[bot]andwhyour 07fcb09cc6 Add log isolation and admin-only access for system/login logs
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-11 16:36:12 +00:00
copilot-swe-agent[bot]andwhyour 6aefc61be6 Fix authentication for regular users by validating JWT tokens
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-10 18:10:16 +00:00
copilot-swe-agent[bot]andwhyour bf9be821ba Add ALTER TABLE statements for userId columns in db.ts
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-10 16:57:18 +00:00
copilot-swe-agent[bot]andwhyour d42074f76a Add data migration script and comprehensive migration guide
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-10 16:48:31 +00:00
copilot-swe-agent[bot]andwhyour 5c798a0e93 Add user management frontend interface for admins
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-10 16:38:26 +00:00
whyourandGitHub 15d94f469b Merge branch 'develop' into copilot/enable-multi-user-management 2025-11-10 23:53:10 +08:00
copilot-swe-agent[bot]andwhyour 777fd3fb23 Add user-scoped data filtering for subscription and dependence operations
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-09 16:37:37 +00:00
whyourandGitHub 4cf2858ab0 Merge branch 'develop' into copilot/enable-multi-user-management 2025-11-09 19:50:16 +08:00
copilot-swe-agent[bot]andwhyour 2ff7a186e7 Changes before error encountered
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:38:20 +00:00
copilot-swe-agent[bot]andwhyour b2b1777c6b Security improvements: Fix ownership checks, add password hashing with bcrypt
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:37:05 +00:00
copilot-swe-agent[bot]andwhyour f355b4e441 Add user-scoped data filtering for env operations
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:33:31 +00:00
copilot-swe-agent[bot]andwhyour 489454daa0 Add user-scoped data filtering for cron operations
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:30:04 +00:00
copilot-swe-agent[bot]andwhyour db93ca9aa9 Add multi-user backend infrastructure: User model, management service and API
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:24:00 +00:00
copilot-swe-agent[bot] 4758400df6 Initial plan 2025-11-07 16:12:26 +00:00
85 changed files with 2508 additions and 2780 deletions
+25 -34
View File
@@ -9,13 +9,15 @@ on:
- "develop"
tags:
- "v*"
schedule:
- cron: "00 20 * * *"
workflow_dispatch:
jobs:
code_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -30,7 +32,7 @@ jobs:
code_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -45,12 +47,12 @@ jobs:
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
cache: "pnpm"
@@ -76,12 +78,12 @@ jobs:
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -97,7 +99,7 @@ jobs:
needs: build-static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -110,7 +112,6 @@ jobs:
force_update: true
build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
@@ -120,21 +121,14 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
@@ -160,13 +154,19 @@ jobs:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
tags: |
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=schedule,pattern=nightly
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -208,21 +208,14 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
@@ -261,9 +254,7 @@ jobs:
context: .
file: ./docker/310.Dockerfile
push: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
tags: whyour/qinglong:python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
+252
View File
@@ -0,0 +1,252 @@
# Multi-User Data Migration Guide
This document explains how to migrate existing data to the multi-user system.
## Overview
When upgrading to the multi-user version of Qinglong, all existing data (cron tasks, environment variables, subscriptions, and dependencies) will be treated as "legacy data" that is accessible to all users.
To properly isolate data between users, you need to migrate existing data to specific user accounts.
## Migration Script
The `migrate-to-multiuser.js` script helps you assign existing legacy data to specific users.
### Prerequisites
- Node.js installed
- Sequelize and dotenv packages (already included in the project)
- At least one user created in the User Management interface
### Usage
#### 1. List All Users
First, see all available users in your system:
```bash
node migrate-to-multiuser.js --list-users
```
Output example:
```
Users in the system:
ID Username Role Status
-- -------- ---- ------
1 admin Admin Enabled
2 user1 User Enabled
3 user2 User Enabled
```
#### 2. Preview Migration (Dry Run)
Before making changes, preview what will be migrated:
```bash
node migrate-to-multiuser.js --userId=1 --dry-run
```
Or by username:
```bash
node migrate-to-multiuser.js --username=admin --dry-run
```
Output example:
```
Legacy Data Statistics:
Cron tasks: 15
Environment variables: 8
Subscriptions: 3
Dependencies: 5
DRY RUN: No changes will be made.
Would assign all legacy data to user ID 1
```
#### 3. Perform Migration
Once you're ready, run the migration without `--dry-run`:
**By User ID:**
```bash
node migrate-to-multiuser.js --userId=1
```
**By Username:**
```bash
node migrate-to-multiuser.js --username=admin
```
Output example:
```
Found user 'admin' with ID 1
Legacy Data Statistics:
Cron tasks: 15
Environment variables: 8
Subscriptions: 3
Dependencies: 5
Migrating data to user ID 1...
✓ Migrated 15 cron tasks
✓ Migrated 8 environment variables
✓ Migrated 3 subscriptions
✓ Migrated 5 dependencies
✓ Migration completed successfully!
```
### Command Line Options
| Option | Description |
|--------|-------------|
| `--userId=<id>` | Assign all legacy data to user with this ID |
| `--username=<name>` | Assign all legacy data to user with this username |
| `--list-users` | List all users in the system |
| `--dry-run` | Show what would be changed without making changes |
| `--help` | Show help message |
## Migration Strategy
### Scenario 1: Single User to Multi-User
If you're upgrading from single-user to multi-user and want to keep all existing data under one admin account:
1. Create an admin user in the User Management interface
2. Run migration: `node migrate-to-multiuser.js --username=admin`
### Scenario 2: Distribute Data to Multiple Users
If you want to distribute existing data to different users:
1. Create all necessary user accounts first
2. Identify which data belongs to which user (you may need to do this manually by checking the database)
3. For each user, manually update the `userId` field in the database tables (`Crontabs`, `Envs`, `Subscriptions`, `Dependences`)
**SQL Example:**
```sql
-- Assign specific cron tasks to user ID 2
UPDATE Crontabs
SET userId = 2
WHERE name LIKE '%user2%' AND userId IS NULL;
-- Assign specific environment variables to user ID 2
UPDATE Envs
SET userId = 2
WHERE name LIKE '%USER2%' AND userId IS NULL;
```
### Scenario 3: Keep as Shared Data
If you want certain data to remain accessible to all users:
- Simply don't run the migration script
- Data with `userId = NULL` remains as "legacy data" accessible to everyone
- This is useful for shared cron tasks or environment variables
## Important Notes
1. **Backup First**: Always backup your database before running migration scripts
```bash
cp data/database.sqlite data/database.sqlite.backup
```
2. **Test in Dry Run**: Always use `--dry-run` first to see what will change
3. **One-Time Operation**: The script only migrates data where `userId` is NULL
- Already migrated data won't be changed
- You can run it multiple times safely
4. **Transaction Safety**: The migration uses database transactions
- If any error occurs, all changes are rolled back
- Your data remains safe
5. **User Must Exist**: The target user must exist before migration
- Create users in the User Management interface first
- Use `--list-users` to verify users exist
## Troubleshooting
### Error: "User not found"
**Problem:** The specified user doesn't exist in the database.
**Solution:**
1. Run `node migrate-to-multiuser.js --list-users` to see available users
2. Create the user in User Management interface if needed
3. Use correct user ID or username
### Error: "Database connection failed"
**Problem:** Cannot connect to the database.
**Solution:**
1. Check that `data/database.sqlite` exists
2. Verify database file permissions
3. Check `QL_DATA_DIR` environment variable if using custom path
### Error: "Migration failed"
**Problem:** An error occurred during migration.
**Solution:**
1. Check the error message for details
2. Verify database is not corrupted
3. Restore from backup if needed
4. Check database file permissions
## Manual Migration
If you prefer to migrate data manually using SQL:
### Connect to Database
```bash
sqlite3 data/database.sqlite
```
### Check Legacy Data
```sql
-- Count legacy cron tasks
SELECT COUNT(*) FROM Crontabs WHERE userId IS NULL;
-- View legacy cron tasks
SELECT id, name, command FROM Crontabs WHERE userId IS NULL;
```
### Migrate Data
```sql
-- Migrate all legacy data to user ID 1
UPDATE Crontabs SET userId = 1 WHERE userId IS NULL;
UPDATE Envs SET userId = 1 WHERE userId IS NULL;
UPDATE Subscriptions SET userId = 1 WHERE userId IS NULL;
UPDATE Dependences SET userId = 1 WHERE userId IS NULL;
```
### Verify Migration
```sql
-- Check if any legacy data remains
SELECT
(SELECT COUNT(*) FROM Crontabs WHERE userId IS NULL) as legacy_crons,
(SELECT COUNT(*) FROM Envs WHERE userId IS NULL) as legacy_envs,
(SELECT COUNT(*) FROM Subscriptions WHERE userId IS NULL) as legacy_subs,
(SELECT COUNT(*) FROM Dependences WHERE userId IS NULL) as legacy_deps;
```
## Support
If you encounter issues with data migration:
1. Check this guide for solutions
2. Review the error messages carefully
3. Ensure you have a recent backup
4. Open an issue on GitHub with:
- Error messages
- Migration command used
- Database statistics (from dry run)
## Related Documentation
- [MULTI_USER_GUIDE.md](./MULTI_USER_GUIDE.md) - Complete multi-user feature guide
- [README.md](./README.md) - Main project documentation
+154
View File
@@ -0,0 +1,154 @@
# 多用户管理功能说明 (Multi-User Management Guide)
## 功能概述 (Overview)
青龙面板现已支持多用户管理和数据隔离功能。管理员可以创建多个用户账号,每个用户只能看到和操作自己的数据。
Qinglong now supports multi-user management with data isolation. Administrators can create multiple user accounts, and each user can only see and operate their own data.
## 用户角色 (User Roles)
### 管理员 (Admin)
- 可以访问所有用户的数据
- 可以创建、编辑、删除用户
- 可以管理系统设置
- Can access all users' data
- Can create, edit, and delete users
- Can manage system settings
### 普通用户 (Regular User)
- 只能访问自己创建的数据
- 可以管理自己的定时任务、环境变量、订阅和依赖
- 无法访问其他用户的数据
- Can only access their own data
- Can manage their own cron jobs, environment variables, subscriptions, and dependencies
- Cannot access other users' data
## API 使用 (API Usage)
### 用户管理接口 (User Management Endpoints)
所有用户管理接口需要管理员权限。
All user management endpoints require admin privileges.
#### 获取用户列表 (Get User List)
```
GET /api/user-management?searchValue=keyword
```
#### 创建用户 (Create User)
```
POST /api/user-management
{
"username": "user1",
"password": "password123",
"role": 1, // 0: admin, 1: user
"status": 0 // 0: active, 1: disabled
}
```
#### 更新用户 (Update User)
```
PUT /api/user-management
{
"id": 1,
"username": "user1",
"password": "newpassword",
"role": 1,
"status": 0
}
```
#### 删除用户 (Delete Users)
```
DELETE /api/user-management
[1, 2, 3] // User IDs to delete
```
## 数据隔离 (Data Isolation)
### 定时任务 (Cron Jobs)
- 每个用户创建的定时任务会自动关联到该用户
- 用户只能查看、编辑、运行、删除自己的定时任务
- 管理员可以查看所有用户的定时任务
### 环境变量 (Environment Variables)
- 每个用户的环境变量相互隔离
- 用户只能查看和修改自己的环境变量
- 管理员可以查看所有环境变量
### 订阅和依赖 (Subscriptions and Dependencies)
- 用户数据完全隔离
- Only accessible by the owning user and admins
## 密码安全 (Password Security)
- 所有密码使用 bcrypt 加密存储
- 密码长度最少为 6 位
- 建议使用强密码
- All passwords are hashed with bcrypt
- Minimum password length is 6 characters
- Strong passwords are recommended
## 数据迁移 (Data Migration)
### 迁移工具 (Migration Tool)
项目提供了数据迁移脚本,可以将现有数据分配给特定用户。
A migration script is provided to assign existing data to specific users.
#### 使用方法 (Usage)
1. **列出所有用户 (List all users)**
```bash
node migrate-to-multiuser.js --list-users
```
2. **预览迁移(不实际执行)(Dry run)**
```bash
node migrate-to-multiuser.js --userId=1 --dry-run
```
3. **将数据迁移到指定用户ID (Migrate to user ID)**
```bash
node migrate-to-multiuser.js --userId=1
```
4. **将数据迁移到指定用户名 (Migrate to username)**
```bash
node migrate-to-multiuser.js --username=admin
```
#### 注意事项 (Important Notes)
- 迁移脚本只会处理 `userId` 为空的数据(遗留数据)
- 已分配给用户的数据不会被修改
- 建议先使用 `--dry-run` 预览变更
- 迁移过程中如果出错会自动回滚
- The script only migrates data where `userId` is NULL (legacy data)
- Data already assigned to users will not be changed
- It's recommended to use `--dry-run` first to preview changes
- Changes are automatically rolled back if an error occurs
## 向后兼容 (Backward Compatibility)
- 原有的单用户系统管理员账号继续有效
- 已存在的数据可以被所有用户访问(遗留数据)
- 新创建的数据会自动关联到创建者
- The original system admin account remains valid
- Existing data is accessible by all users (legacy data)
- Newly created data is automatically associated with the creator
## 注意事项 (Notes)
1. **首次使用**:首次使用多用户功能时,建议先创建一个管理员账号作为备份
2. **密码管理**:请妥善保管用户密码,忘记密码需要管理员重置
3. **数据迁移**:使用提供的 `migrate-to-multiuser.js` 脚本将现有数据分配给特定用户
4. **权限控制**:删除用户不会删除该用户的数据,数据会变为遗留数据
1. **First Use**: When first using multi-user functionality, it's recommended to create an admin account as a backup
2. **Password Management**: Please keep user passwords safe; forgotten passwords need admin reset
3. **Data Migration**: Use the provided `migrate-to-multiuser.js` script to assign existing data to specific users
4. **Permission Control**: Deleting a user doesn't delete their data; the data becomes legacy data
-2
View File
@@ -41,8 +41,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
-2
View File
@@ -43,8 +43,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
+35 -28
View File
@@ -145,7 +145,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.crontabs(req.query as any);
const data = await cronService.crontabs({
...req.query as any,
userId: req.user?.userId
});
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
@@ -177,7 +180,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
const data = await cronService.create({
...req.body,
userId: req.user?.userId
});
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -194,10 +200,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.run(req.body);
const data = await cronService.run(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -211,10 +217,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.stop(req.body);
const data = await cronService.stop(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -234,10 +240,11 @@ export default (app: Router) => {
const data = await cronService.removeLabels(
req.body.ids,
req.body.labels,
req.user?.userId,
);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -254,10 +261,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.addLabels(req.body.ids, req.body.labels);
const data = await cronService.addLabels(req.body.ids, req.body.labels, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -271,10 +278,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.disabled(req.body);
const data = await cronService.disabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -288,10 +295,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.enabled(req.body);
const data = await cronService.enabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -344,10 +351,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.remove(req.body);
const data = await cronService.remove(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -361,10 +368,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.pin(req.body);
const data = await cronService.pin(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -378,10 +385,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.unPin(req.body);
const data = await cronService.unPin(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
+15 -15
View File
@@ -16,13 +16,13 @@ export default (app: Router) => {
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.dependencies(req.query as any);
const data = await dependenceService.dependencies(req.query as any, [], {}, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
@@ -45,7 +45,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.create(req.body);
const data = await dependenceService.create(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -82,10 +82,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body);
const data = await dependenceService.remove(req.body, false, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -98,10 +98,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body, true);
const data = await dependenceService.remove(req.body, true, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -132,10 +132,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.reInstall(req.body);
const data = await dependenceService.reInstall(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -148,10 +148,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
await dependenceService.cancel(req.body);
await dependenceService.cancel(req.body, req.user?.userId);
return res.send({ code: 200 });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
+14 -14
View File
@@ -26,7 +26,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.envs(req.query.searchValue as string);
const data = await envService.envs(req.query.searchValue as string, {}, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
@@ -54,7 +54,7 @@ export default (app: Router) => {
if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' });
}
const data = await envService.create(req.body);
const data = await envService.create(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -93,10 +93,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.remove(req.body);
const data = await envService.remove(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -132,10 +132,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.disabled(req.body);
const data = await envService.disabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -149,10 +149,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.enabled(req.body);
const data = await envService.enabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -169,10 +169,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.updateNames(req.body);
const data = await envService.updateNames(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
+2
View File
@@ -11,6 +11,7 @@ import system from './system';
import subscription from './subscription';
import update from './update';
import health from './health';
import userManagement from './userManagement';
export default () => {
const app = Router();
@@ -26,6 +27,7 @@ export default () => {
subscription(app);
update(app);
health(app);
userManagement(app);
return app;
};
+151
View File
@@ -8,8 +8,12 @@ import {
readDirs,
removeAnsi,
rmPath,
IFile,
} from '../config/util';
import LogService from '../services/log';
import CronService from '../services/cron';
import { UserRole } from '../data/user';
import { Crontab } from '../data/cron';
const route = Router();
const blacklist = ['.tmp'];
@@ -20,6 +24,39 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const result = await readDirs(config.logPath, config.logPath, blacklist);
// Filter logs based on user permissions
if (req.user?.role !== UserRole.admin && req.user?.userId) {
const cronService = Container.get(CronService);
const { data: userCrons } = await cronService.crontabs({
searchValue: '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
userId: req.user.userId,
});
// Build a set of log paths that the user has access to
const allowedLogPaths = new Set(
userCrons
.filter((cron: Crontab) => cron.log_name && cron.log_name !== '/dev/null')
.map((cron: Crontab) => cron.log_name)
);
// Filter the result to only include logs the user owns
const filteredResult = (result as IFile[]).filter((item: IFile) =>
item.type === 'directory' && (allowedLogPaths.has(item.title) || allowedLogPaths.has(`${item.title}/`))
);
res.send({
code: 200,
data: filteredResult,
});
return;
}
res.send({
code: 200,
data: result,
@@ -45,6 +82,35 @@ export default (app: Router) => {
message: '暂无权限',
});
}
// Check if user has permission to view this log
if (req.user?.role !== UserRole.admin && req.user?.userId) {
const cronService = Container.get(CronService);
const { data: userCrons } = await cronService.crontabs({
searchValue: '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
userId: req.user.userId,
});
const logPath = (req.query.path as string) || '';
const hasAccess = userCrons.some((cron: Crontab) =>
cron.log_name &&
cron.log_name !== '/dev/null' &&
(logPath.startsWith(cron.log_name) || cron.log_name.startsWith(logPath))
);
if (!hasAccess) {
return res.send({
code: 403,
message: '暂无权限',
});
}
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) });
} catch (e) {
@@ -68,6 +134,35 @@ export default (app: Router) => {
message: '暂无权限',
});
}
// Check if user has permission to view this log
if (req.user?.role !== UserRole.admin && req.user?.userId) {
const cronService = Container.get(CronService);
const { data: userCrons } = await cronService.crontabs({
searchValue: '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
userId: req.user.userId,
});
const logPath = (req.query.path as string) || '';
const hasAccess = userCrons.some((cron: Crontab) =>
cron.log_name &&
cron.log_name !== '/dev/null' &&
(logPath.startsWith(cron.log_name) || cron.log_name.startsWith(logPath))
);
if (!hasAccess) {
return res.send({
code: 403,
message: '暂无权限',
});
}
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
} catch (e) {
@@ -99,6 +194,34 @@ export default (app: Router) => {
message: '暂无权限',
});
}
// Check if user has permission to delete this log
if (req.user?.role !== UserRole.admin && req.user?.userId) {
const cronService = Container.get(CronService);
const { data: userCrons } = await cronService.crontabs({
searchValue: '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
userId: req.user.userId,
});
const hasAccess = userCrons.some((cron: Crontab) =>
cron.log_name &&
cron.log_name !== '/dev/null' &&
(path.startsWith(cron.log_name) || cron.log_name.startsWith(path))
);
if (!hasAccess) {
return res.send({
code: 403,
message: '暂无权限',
});
}
}
await rmPath(finalPath);
res.send({ code: 200 });
} catch (e) {
@@ -129,6 +252,34 @@ export default (app: Router) => {
message: '暂无权限',
});
}
// Check if user has permission to download this log
if (req.user?.role !== UserRole.admin && req.user?.userId) {
const cronService = Container.get(CronService);
const { data: userCrons } = await cronService.crontabs({
searchValue: '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
userId: req.user.userId,
});
const hasAccess = userCrons.some((cron: Crontab) =>
cron.log_name &&
cron.log_name !== '/dev/null' &&
(path.startsWith(cron.log_name) || cron.log_name.startsWith(path))
);
if (!hasAccess) {
return res.send({
code: 403,
message: '暂无权限',
});
}
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
+4 -6
View File
@@ -29,7 +29,7 @@ export default (app: Router) => {
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -79,7 +79,7 @@ export default (app: Router) => {
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -103,7 +103,7 @@ export default (app: Router) => {
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -129,8 +129,7 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -176,7 +175,6 @@ export default (app: Router) => {
path,
`${originFilename.replace(/\//g, '')}`,
);
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath);
if (fileExists) {
+23 -19
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import cron_parser from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -16,6 +16,7 @@ export default (app: Router) => {
const data = await subscriptionService.list(
req.query.searchValue as string,
req.query.ids as string,
req.user?.userId,
);
return res.send({ code: 200, data });
} catch (e) {
@@ -60,10 +61,13 @@ export default (app: Router) => {
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
const data = await subscriptionService.create({
...req.body,
userId: req.user?.userId,
});
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
@@ -83,10 +87,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.run(req.body);
const data = await subscriptionService.run(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -100,10 +104,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.stop(req.body);
const data = await subscriptionService.stop(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -117,10 +121,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.disabled(req.body);
const data = await subscriptionService.disabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -134,10 +138,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.enabled(req.body);
const data = await subscriptionService.enabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@@ -193,7 +197,7 @@ export default (app: Router) => {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
@@ -220,10 +224,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.remove(req.body, req.query);
const data = await subscriptionService.remove(req.body, req.query, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
+19 -23
View File
@@ -14,7 +14,7 @@ import {
} from '../config/util';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
import { UserRole } from '../data/user';
const route = Router();
const storage = multer.diskStorage({
@@ -277,19 +277,17 @@ export default (app: Router) => {
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
},
onError: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
await fs.appendFile(absolutePath, message);
},
onLog: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
await fs.appendFile(absolutePath, message);
},
},
);
@@ -360,6 +358,14 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Only admin can view system logs
if (req.user?.role !== UserRole.admin) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const systemService = Container.get(SystemService);
await systemService.getSystemLog(
res,
@@ -378,6 +384,14 @@ export default (app: Router) => {
'/log',
async (req: Request, res: Response, next: NextFunction) => {
try {
// Only admin can delete system logs
if (req.user?.role !== UserRole.admin) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const systemService = Container.get(SystemService);
await systemService.deleteSystemLog();
res.send({ code: 200 });
@@ -426,24 +440,6 @@ export default (app: Router) => {
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
+12 -3
View File
@@ -8,7 +8,8 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config';
import { isDemoEnv, getToken } from '../config/util';
import { isDemoEnv } from '../config/util';
import { UserRole } from '../data/user';
const route = Router();
const storage = multer.diskStorage({
@@ -56,8 +57,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const token = getToken(req);
await userService.logout(req.platform, token);
await userService.logout(req.platform);
res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -98,6 +98,7 @@ export default (app: Router) => {
username: authInfo.username,
avatar: authInfo.avatar,
twoFactorActivated: authInfo.twoFactorActivated,
role: req.user?.role,
},
});
} catch (e) {
@@ -179,6 +180,14 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
// Only admin can view login logs
if (req.user?.role !== UserRole.admin) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const userService = Container.get(UserService);
const data = await userService.getLoginLog();
res.send({ code: 200, data });
+114
View File
@@ -0,0 +1,114 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { celebrate, Joi } from 'celebrate';
import UserManagementService from '../services/userManagement';
import { UserRole } from '../data/user';
const route = Router();
// Middleware to check if user is admin
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
if (req.user && req.user.role === UserRole.admin) {
return next();
}
return res.status(403).send({ code: 403, message: '需要管理员权限' });
};
export default (app: Router) => {
app.use('/user-management', route);
// List all users (admin only)
route.get(
'/',
requireAdmin,
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.list(req.query.searchValue as string);
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
// Get a specific user (admin only)
route.get(
'/:id',
requireAdmin,
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.get(Number(req.params.id));
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
// Create a new user (admin only)
route.post(
'/',
requireAdmin,
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
role: Joi.number().valid(UserRole.admin, UserRole.user).default(UserRole.user),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.create(req.body);
res.send({ code: 200, data, message: '创建用户成功' });
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
// Update a user (admin only)
route.put(
'/',
requireAdmin,
celebrate({
body: Joi.object({
id: Joi.number().required(),
username: Joi.string().required(),
password: Joi.string().required(),
role: Joi.number().valid(UserRole.admin, UserRole.user),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.update(req.body);
res.send({ code: 200, data, message: '更新用户成功' });
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
// Delete users (admin only)
route.delete(
'/',
requireAdmin,
celebrate({
body: Joi.array().items(Joi.number()).required(),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const count = await userManagementService.delete(req.body);
res.send({ code: 200, data: count, message: '删除用户成功' });
} catch (e) {
return next(e);
}
},
);
};
+16 -80
View File
@@ -24,7 +24,6 @@ class Application {
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() {
this.app = express();
@@ -54,54 +53,21 @@ class Application {
}
private startMasterProcess() {
// Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error('✌️ Failed to wait for gRPC worker:', error);
process.exit(1);
});
this.forkWorker('http');
this.forkWorker('grpc');
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('✌️ Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error('✌️ Failed to send reregister-crons message:', error);
}
}
})
.catch((error) => {
Logger.error('✌️ Failed to restart gRPC worker:', error);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
const newWorker = this.forkWorker(metadata.serviceType);
Logger.info(
`Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`,
);
}
this.workerMetadataMap.delete(worker.id);
@@ -111,25 +77,6 @@ class Application {
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
@@ -169,7 +116,7 @@ class Application {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`);
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
@@ -177,7 +124,7 @@ class Application {
worker.send('shutdown');
} catch (error) {
Logger.warn(
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
`Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
}
@@ -192,14 +139,14 @@ class Application {
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached');
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error('✌️ Error during worker shutdown:', error);
Logger.error('Error during worker shutdown:', error);
process.exit(1);
}
};
@@ -211,7 +158,7 @@ class Application {
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
Logger.error('Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
@@ -226,7 +173,7 @@ class Application {
process.send?.('ready');
} catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error);
Logger.error(`${serviceType} worker failed:`, error);
process.exit(1);
}
}
@@ -259,20 +206,9 @@ class Application {
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => {
process.on('message', (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('✌️ Cron jobs re-registered successfully');
} catch (error) {
Logger.error('✌️ Failed to re-register cron jobs:', error);
}
}
});
@@ -293,7 +229,7 @@ class Application {
}
process.exit(0);
} catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
Logger.error(`[${serviceType}] Error during shutdown:`, error);
process.exit(1);
}
}
@@ -301,6 +237,6 @@ class Application {
const app = new Application();
app.start().catch((error) => {
Logger.error('🙅‍♀️ Application failed to start:', error);
Logger.error('Application failed to start:', error);
process.exit(1);
});
-19
View File
@@ -9,8 +9,6 @@ dotenv.config({
interface Config {
port: number;
grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
@@ -33,8 +31,6 @@ interface Config {
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
@@ -68,19 +64,6 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
@@ -133,7 +116,6 @@ if (envFound.error) {
export default {
...config,
jwt: config.jwt,
baseUrl,
rootPath,
tmpPath,
dataPath,
@@ -194,5 +176,4 @@ export default {
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
-21
View File
@@ -417,27 +417,6 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
+3 -3
View File
@@ -22,7 +22,7 @@ export class Crontab {
task_before?: string;
task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
userId?: number;
constructor(options: Crontab) {
this.name = options.name;
@@ -48,7 +48,7 @@ export class Crontab {
this.task_before = options.task_before;
this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
this.userId = options.userId;
}
}
@@ -89,5 +89,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
userId: { type: DataTypes.NUMBER, allowNull: true },
});
+3
View File
@@ -9,6 +9,7 @@ export class Dependence {
name: string;
log?: string[];
remark?: string;
userId?: number;
constructor(options: Dependence) {
this.id = options.id;
@@ -21,6 +22,7 @@ export class Dependence {
this.name = options.name.trim();
this.log = options.log || [];
this.remark = options.remark || '';
this.userId = options.userId;
}
}
@@ -59,5 +61,6 @@ export const DependenceModel = sequelize.define<DependenceInstance>(
status: DataTypes.NUMBER,
log: DataTypes.JSON,
remark: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
},
);
+3
View File
@@ -9,6 +9,7 @@ export class Env {
position?: number;
name?: string;
remarks?: string;
userId?: number;
isPinned?: 1 | 0;
constructor(options: Env) {
@@ -22,6 +23,7 @@ export class Env {
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks || '';
this.userId = options.userId;
this.isPinned = options.isPinned || 0;
}
}
@@ -44,5 +46,6 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
isPinned: DataTypes.NUMBER,
});
+1 -10
View File
@@ -20,7 +20,6 @@ export enum NotificationMode {
'chronocat' = 'Chronocat',
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
}
abstract class NotificationBaseInfo {
@@ -143,7 +142,6 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo {
public larkKey = '';
public larkSecret = '';
}
export class NtfyNotification extends NotificationBaseInfo {
@@ -162,12 +160,6 @@ export class WxPusherBotNotification extends NotificationBaseInfo {
public wxPusherBotUids = '';
}
export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkAppToken = '';
public openiLinkHubUrl = '';
public openiLinkContextToken = '';
}
export interface NotificationInfo
extends GoCqHttpBotNotification,
GotifyNotification,
@@ -189,5 +181,4 @@ export interface NotificationInfo
ChronocatNotification,
LarkNotification,
NtfyNotification,
WxPusherBotNotification,
OpeniLinkNotification {}
WxPusherBotNotification {}
+3
View File
@@ -31,6 +31,7 @@ export class Subscription {
proxy?: string;
autoAddCron?: 1 | 0;
autoDelCron?: 1 | 0;
userId?: number;
constructor(options: Subscription) {
this.id = options.id;
@@ -60,6 +61,7 @@ export class Subscription {
this.proxy = options.proxy;
this.autoAddCron = options.autoAddCron ? 1 : 0;
this.autoDelCron = options.autoDelCron ? 1 : 0;
this.userId = options.userId;
}
}
@@ -111,5 +113,6 @@ export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
proxy: { type: DataTypes.STRING, allowNull: true },
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
userId: { type: DataTypes.NUMBER, allowNull: true },
},
);
+1 -15
View File
@@ -38,7 +38,6 @@ export interface SystemConfigInfo {
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
@@ -49,19 +48,6 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
@@ -72,7 +58,7 @@ export interface AuthInfo {
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string | TokenInfo[]>;
tokens: Record<string, string>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
+56
View File
@@ -0,0 +1,56 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export class User {
id?: number;
username: string;
password: string;
role: UserRole;
status: UserStatus;
createdAt?: Date;
updatedAt?: Date;
constructor(options: User) {
this.id = options.id;
this.username = options.username;
this.password = options.password;
this.role = options.role || UserRole.user;
this.status =
typeof options.status === 'number' && UserStatus[options.status]
? options.status
: UserStatus.active;
this.createdAt = options.createdAt;
this.updatedAt = options.updatedAt;
}
}
export enum UserRole {
'admin' = 0,
'user' = 1,
}
export enum UserStatus {
'active' = 0,
'disabled' = 1,
}
export interface UserInstance extends Model<User, User>, User {}
export const UserModel = sequelize.define<UserInstance>('User', {
username: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
role: {
type: DataTypes.NUMBER,
defaultValue: UserRole.user,
},
status: {
type: DataTypes.NUMBER,
defaultValue: UserStatus.active,
},
});
+61 -32
View File
@@ -6,6 +6,7 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { UserModel } from '../data/user';
import { sequelize } from '../data';
export default async () => {
@@ -17,40 +18,68 @@ export default async () => {
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await UserModel.sync();
// 初始化新增字段
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
];
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
try {
await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column proxy VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query('alter table CrontabViews add column type NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoAddCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoDelCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column log_name VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query('alter table Envs add column isPinned NUMBER');
} catch (error) {}
// Multi-user support: Add userId columns
try {
await sequelize.query('alter table Crontabs add column userId NUMBER');
} catch (error) {}
try {
await sequelize.query('alter table Envs add column userId NUMBER');
} catch (error) {}
try {
await sequelize.query('alter table Subscriptions add column userId NUMBER');
} catch (error) {}
try {
await sequelize.query('alter table Dependences add column userId NUMBER');
} catch (error) {}
Logger.info('✌️ DB loaded');
} catch (error) {
+3 -15
View File
@@ -1,9 +1,8 @@
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar';
import config from '../config/index';
import Logger from './logger';
import { fileExist, promiseExec, rmPath } from '../config/util';
async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -18,18 +17,8 @@ async function linkToNodeModule(src: string, dst?: string) {
}
async function linkCommand() {
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const commandPath = await promiseExec('which node');
const commandDir = path.dirname(commandPath);
const linkShell = [
{
src: 'update.sh',
@@ -53,7 +42,6 @@ async function linkCommandToDir(commandDir: string) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
+29 -44
View File
@@ -9,39 +9,11 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
@@ -56,7 +28,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}),
);
@@ -70,21 +42,32 @@ export default ({ app }: { app: Application }) => {
return next();
});
// Extract userId and role from JWT
app.use((req: Request, res, next) => {
if (req.auth) {
const payload = req.auth as any;
req.user = {
userId: payload.userId,
role: payload.role,
};
}
return next();
});
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
@@ -105,11 +88,21 @@ export default ({ app }: { app: Application }) => {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
// If JWT has been successfully verified by expressjwt middleware, allow the request
// This handles regular users whose tokens are not stored in authInfo
if (req.auth) {
return next();
}
// For system admin, also check against stored token
const authInfo = await shareStore.getAuthInfo();
if (authInfo && headerToken) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? 'jwt malformed'
@@ -119,15 +112,7 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
return next();
}
const authInfo =
+2 -2
View File
@@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import { createRandomString, fileExist, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
@@ -50,7 +50,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info || isDemoEnv()) {
if (!authConfig?.info) {
let authInfo = {
username: 'admin',
password: 'admin',
-2
View File
@@ -20,7 +20,6 @@ const uploadPath = path.join(dataPath, 'upload/');
const bakPath = path.join(dataPath, 'bak/');
const samplePath = path.join(rootPath, 'sample/');
const tmpPath = path.join(logPath, '.tmp/');
const rootTmpPath = path.join(rootPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
@@ -45,7 +44,6 @@ const directories = [
preloadPath,
logPath,
tmpPath,
rootTmpPath,
uploadPath,
sshPath,
bakPath,
-7
View File
@@ -2,7 +2,6 @@ import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
@@ -11,7 +10,6 @@ export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `ts-node-transpile-only ${join(
@@ -59,11 +57,6 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
await subscriptionService.setSshConfig();
+26 -4
View File
@@ -4,11 +4,11 @@ import { Container } from 'typedi';
import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import jwt from 'jsonwebtoken';
import config from '../config';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
const sockService = Container.get(SockService);
echo.on('connection', async (conn) => {
@@ -16,11 +16,33 @@ export default async ({ server }: { server: Server }) => {
conn.close('404');
}
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
let isAuthenticated = false;
if (isValidToken(authInfo, headerToken, platform)) {
// First try to verify JWT token (for regular users)
if (headerToken) {
try {
jwt.verify(headerToken, config.jwt.secret, { algorithms: ['HS384'] });
isAuthenticated = true;
} catch (error) {
// JWT verification failed, will try authInfo check next
}
}
// Also check against stored token for system admin
if (!isAuthenticated) {
const authInfo = await shareStore.getAuthInfo();
if (authInfo) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) {
isAuthenticated = true;
}
}
}
if (isAuthenticated) {
sockService.addClient(conn);
conn.on('data', (message) => {
-18
View File
@@ -97,18 +97,6 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
@@ -231,7 +219,6 @@ message NotificationInfo {
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
@@ -267,9 +254,4 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
+1 -490
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/api.proto
/* eslint-disable */
@@ -281,26 +281,6 @@ export interface DeleteCronsRequest {
ids: number[];
}
export interface GetCronsRequest {
searchValue?: string | undefined;
}
export interface GetCronByIdRequest {
id: number;
}
export interface EnableCronsRequest {
ids: number[];
}
export interface DisableCronsRequest {
ids: number[];
}
export interface RunCronsRequest {
ids: number[];
}
export interface CronsResponse {
code: number;
data: CronItem[];
@@ -382,7 +362,6 @@ export interface NotificationInfo {
webhookMethod?: string | undefined;
webhookContentType?: string | undefined;
larkKey?: string | undefined;
larkSecret?: string | undefined;
ntfyUrl?: string | undefined;
ntfyTopic?: string | undefined;
ntfyPriority?: string | undefined;
@@ -2228,332 +2207,6 @@ export const DeleteCronsRequest: MessageFns<DeleteCronsRequest> = {
},
};
function createBaseGetCronsRequest(): GetCronsRequest {
return { searchValue: undefined };
}
export const GetCronsRequest: MessageFns<GetCronsRequest> = {
encode(message: GetCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.searchValue !== undefined) {
writer.uint32(10).string(message.searchValue);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.searchValue = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronsRequest {
return { searchValue: isSet(object.searchValue) ? globalThis.String(object.searchValue) : undefined };
},
toJSON(message: GetCronsRequest): unknown {
const obj: any = {};
if (message.searchValue !== undefined) {
obj.searchValue = message.searchValue;
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronsRequest>, I>>(base?: I): GetCronsRequest {
return GetCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronsRequest>, I>>(object: I): GetCronsRequest {
const message = createBaseGetCronsRequest();
message.searchValue = object.searchValue ?? undefined;
return message;
},
};
function createBaseGetCronByIdRequest(): GetCronByIdRequest {
return { id: 0 };
}
export const GetCronByIdRequest: MessageFns<GetCronByIdRequest> = {
encode(message: GetCronByIdRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== 0) {
writer.uint32(8).int32(message.id);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronByIdRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronByIdRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.id = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronByIdRequest {
return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 };
},
toJSON(message: GetCronByIdRequest): unknown {
const obj: any = {};
if (message.id !== 0) {
obj.id = Math.round(message.id);
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(base?: I): GetCronByIdRequest {
return GetCronByIdRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(object: I): GetCronByIdRequest {
const message = createBaseGetCronByIdRequest();
message.id = object.id ?? 0;
return message;
},
};
function createBaseEnableCronsRequest(): EnableCronsRequest {
return { ids: [] };
}
export const EnableCronsRequest: MessageFns<EnableCronsRequest> = {
encode(message: EnableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): EnableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseEnableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): EnableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: EnableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(base?: I): EnableCronsRequest {
return EnableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(object: I): EnableCronsRequest {
const message = createBaseEnableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseDisableCronsRequest(): DisableCronsRequest {
return { ids: [] };
}
export const DisableCronsRequest: MessageFns<DisableCronsRequest> = {
encode(message: DisableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DisableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDisableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DisableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: DisableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(base?: I): DisableCronsRequest {
return DisableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(object: I): DisableCronsRequest {
const message = createBaseDisableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseRunCronsRequest(): RunCronsRequest {
return { ids: [] };
}
export const RunCronsRequest: MessageFns<RunCronsRequest> = {
encode(message: RunCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RunCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRunCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RunCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: RunCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<RunCronsRequest>, I>>(base?: I): RunCronsRequest {
return RunCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RunCronsRequest>, I>>(object: I): RunCronsRequest {
const message = createBaseRunCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseCronsResponse(): CronsResponse {
return { code: 0, data: [], message: undefined };
}
@@ -2948,7 +2601,6 @@ function createBaseNotificationInfo(): NotificationInfo {
webhookMethod: undefined,
webhookContentType: undefined,
larkKey: undefined,
larkSecret: undefined,
ntfyUrl: undefined,
ntfyTopic: undefined,
ntfyPriority: undefined,
@@ -3138,9 +2790,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) {
writer.uint32(466).string(message.larkKey);
}
if (message.larkSecret !== undefined) {
writer.uint32(554).string(message.larkSecret);
}
if (message.ntfyUrl !== undefined) {
writer.uint32(474).string(message.ntfyUrl);
}
@@ -3645,14 +3294,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.larkKey = reader.string();
continue;
}
case 69: {
if (tag !== 554) {
break;
}
message.larkSecret = reader.string();
continue;
}
case 59: {
if (tag !== 474) {
break;
@@ -3810,7 +3451,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined,
webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined,
larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined,
larkSecret: isSet(object.larkSecret) ? globalThis.String(object.larkSecret) : undefined,
ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined,
ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined,
ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined,
@@ -4004,9 +3644,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) {
obj.larkKey = message.larkKey;
}
if (message.larkSecret !== undefined) {
obj.larkSecret = message.larkSecret;
}
if (message.ntfyUrl !== undefined) {
obj.ntfyUrl = message.ntfyUrl;
}
@@ -4103,7 +3740,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.webhookMethod = object.webhookMethod ?? undefined;
message.webhookContentType = object.webhookContentType ?? undefined;
message.larkKey = object.larkKey ?? undefined;
message.larkSecret = object.larkSecret ?? undefined;
message.ntfyUrl = object.ntfyUrl ?? undefined;
message.ntfyTopic = object.ntfyTopic ?? undefined;
message.ntfyPriority = object.ntfyPriority ?? undefined;
@@ -4340,51 +3976,6 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCrons: {
path: "/com.ql.api.Api/GetCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronsRequest) => Buffer.from(GetCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronsRequest.decode(value),
responseSerialize: (value: CronsResponse) => Buffer.from(CronsResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronsResponse.decode(value),
},
getCronById: {
path: "/com.ql.api.Api/GetCronById",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronByIdRequest) => Buffer.from(GetCronByIdRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronByIdRequest.decode(value),
responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronResponse.decode(value),
},
enableCrons: {
path: "/com.ql.api.Api/EnableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: EnableCronsRequest) => Buffer.from(EnableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => EnableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
disableCrons: {
path: "/com.ql.api.Api/DisableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: DisableCronsRequest) => Buffer.from(DisableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DisableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
runCrons: {
path: "/com.ql.api.Api/RunCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: RunCronsRequest) => Buffer.from(RunCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RunCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
} as const;
export interface ApiServer extends UntypedServiceImplementation {
@@ -4402,11 +3993,6 @@ export interface ApiServer extends UntypedServiceImplementation {
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>;
getCrons: handleUnaryCall<GetCronsRequest, CronsResponse>;
getCronById: handleUnaryCall<GetCronByIdRequest, CronResponse>;
enableCrons: handleUnaryCall<EnableCronsRequest, Response>;
disableCrons: handleUnaryCall<DisableCronsRequest, Response>;
runCrons: handleUnaryCall<RunCronsRequest, Response>;
}
export interface ApiClient extends Client {
@@ -4620,81 +4206,6 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
}
export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as {
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/cron.proto
/* eslint-disable */
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/health.proto
/* eslint-disable */
-119
View File
@@ -30,12 +30,6 @@ import {
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
@@ -329,116 +323,3 @@ export const deleteCrons = async (
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+1 -1
View File
@@ -10,7 +10,7 @@ import config from '../config';
class Client {
private client = new CronClient(
`localhost:${config.grpcPort}`,
`0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);
+6 -3
View File
@@ -17,11 +17,14 @@ const check = async (
return callback(null, { status: 1 });
}
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
);
return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
);
default:
+60 -55
View File
@@ -4,12 +4,11 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import cron_parser from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
@@ -25,12 +24,35 @@ import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { }
private addUserIdFilter(query: any, userId?: number) {
if (userId !== undefined) {
query.userId = userId;
}
return query;
}
private async checkOwnership(ids: number[], userId?: number): Promise<void> {
if (userId === undefined) {
// Admin can access all crons
return;
}
const crons = await CrontabModel.findAll({
where: { id: ids },
attributes: ['id', 'userId'],
});
const unauthorized = crons.filter(
(cron) => cron.userId !== undefined && cron.userId !== userId
);
if (unauthorized.length > 0) {
throw new Error('无权限操作该定时任务');
}
}
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
@@ -58,9 +80,7 @@ export default class CronService {
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
@@ -179,21 +199,26 @@ export default class CronService {
}
}
public async remove(ids: number[]) {
public async remove(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.setCrontab();
}
public async pin(ids: number[]) {
public async pin(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
public async unPin(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async addLabels(ids: string[], labels: string[]) {
public async addLabels(ids: string[], labels: string[], userId?: number) {
const numIds = ids.map(Number);
await this.checkOwnership(numIds, userId);
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await CrontabModel.update(
@@ -205,7 +230,9 @@ export default class CronService {
}
}
public async removeLabels(ids: string[], labels: string[]) {
public async removeLabels(ids: string[], labels: string[], userId?: number) {
const numIds = ids.map(Number);
await this.checkOwnership(numIds, userId);
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await CrontabModel.update(
@@ -400,6 +427,7 @@ export default class CronService {
sorter: string;
filters: string;
queryString: string;
userId?: number;
}): Promise<{ data: Crontab[]; total: number }> {
const searchText = params?.searchValue;
const page = Number(params?.page || '0');
@@ -420,6 +448,7 @@ export default class CronService {
this.formatSearchText(query, searchText);
this.formatFilterQuery(query, filterQuery);
this.formatViewSort(order, viewQuery);
this.addUserIdFilter(query, params?.userId);
if (sorterQuery) {
const { field, type } = sorterQuery;
@@ -452,7 +481,8 @@ export default class CronService {
return doc.get({ plain: true });
}
public async run(ids: number[]) {
public async run(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: ids } },
@@ -462,23 +492,16 @@ export default class CronService {
});
}
public async stop(ids: number[]) {
public async stop(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
// Kill all running instances of this task
try {
if (doc.pid) {
if (doc.pid) {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
}
}
@@ -509,10 +532,7 @@ export default class CronService {
let { id, command, log_name } = cron;
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const uniqPath = log_name === '/dev/null' ? (await getUniqPath(command, `${id}`)) : log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
@@ -531,7 +551,7 @@ export default class CronService {
{ where: { id } },
);
cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString());
await fs.appendFile(absolutePath, data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.info(
@@ -539,7 +559,7 @@ export default class CronService {
command,
data.toString(),
);
await logStreamManager.write(absolutePath, data.toString());
await fs.appendFile(absolutePath, data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
@@ -547,7 +567,7 @@ export default class CronService {
command,
err,
);
await logStreamManager.write(absolutePath, JSON.stringify(err));
await fs.appendFile(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code) => {
@@ -556,8 +576,6 @@ export default class CronService {
JSON.stringify(params),
code,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
@@ -568,13 +586,15 @@ export default class CronService {
});
}
public async disabled(ids: number[]) {
public async disabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.setCrontab();
}
public async enabled(ids: number[]) {
public async enabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs
@@ -644,11 +664,7 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
let commandVariable = `real_time=${Boolean(realTime)} log_name=${tab.log_name} no_tee=true ID=${tab.id} `;
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''")
@@ -690,23 +706,12 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string);
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
execSync(`crontab ${config.crontabFile}`);
await CrontabModel.update({ saved: true }, { where: {} });
}
public importCrontab() {
exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
exec('crontab -l', (error, stdout, stderr) => {
const lines = stdout.split('\n');
const namePrefix = new Date().getTime();
@@ -720,7 +725,7 @@ export default class CronService {
if (
command &&
schedule &&
CronExpressionParser.parse(schedule).hasNext()
cron_parser.parseExpression(schedule).hasNext()
) {
const name = namePrefix + '_' + index;
+35 -6
View File
@@ -30,9 +30,33 @@ export default class DependenceService {
private sockService: SockService,
) { }
public async create(payloads: Dependence[]): Promise<Dependence[]> {
private addUserIdFilter(query: any, userId?: number) {
if (userId !== undefined) {
query.userId = userId;
}
return query;
}
private async checkOwnership(ids: number[], userId?: number): Promise<void> {
if (userId === undefined) {
// Admin can access all dependencies
return;
}
const dependencies = await DependenceModel.findAll({
where: { id: ids },
attributes: ['id', 'userId'],
});
const unauthorized = dependencies.filter(
(dep) => dep.userId !== undefined && dep.userId !== userId
);
if (unauthorized.length > 0) {
throw new Error('无权限操作该依赖');
}
}
public async create(payloads: Dependence[], userId?: number): Promise<Dependence[]> {
const tabs = payloads.map((x) => {
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
const tab = new Dependence({ ...x, status: DependenceStatus.queued, userId });
return tab;
});
const docs = await this.insert(tabs);
@@ -65,7 +89,8 @@ export default class DependenceService {
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[], force = false): Promise<Dependence[]> {
public async remove(ids: number[], force = false, userId?: number): Promise<Dependence[]> {
await this.checkOwnership(ids, userId);
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
@@ -105,9 +130,11 @@ export default class DependenceService {
},
sort: any = [],
query: any = {},
userId?: number,
): Promise<Dependence[]> {
let condition = query;
if (type && DependenceTypes[type] !== undefined) {
this.addUserIdFilter(condition, userId);
if (DependenceTypes[type]) {
condition.type = DependenceTypes[type];
}
if (status) {
@@ -141,7 +168,8 @@ export default class DependenceService {
return taskLimit.waitDependencyQueueDone();
}
public async reInstall(ids: number[]): Promise<Dependence[]> {
public async reInstall(ids: number[], userId?: number): Promise<Dependence[]> {
await this.checkOwnership(ids, userId);
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
@@ -155,7 +183,8 @@ export default class DependenceService {
return docs;
}
public async cancel(ids: number[]) {
public async cancel(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
+42 -14
View File
@@ -13,25 +13,48 @@ import {
stepPosition,
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
constructor(@Inject('logger') private logger: winston.Logger) {}
public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs();
private addUserIdFilter(query: any, userId?: number) {
if (userId !== undefined) {
query.userId = userId;
}
return query;
}
private async checkOwnership(ids: number[], userId?: number): Promise<void> {
if (userId === undefined) {
// Admin can access all envs
return;
}
const envs = await EnvModel.findAll({
where: { id: ids },
attributes: ['id', 'userId'],
});
const unauthorized = envs.filter(
(env) => env.userId !== undefined && env.userId !== userId
);
if (unauthorized.length > 0) {
throw new Error('无权限操作该环境变量');
}
}
public async create(payloads: Env[], userId?: number): Promise<Env[]> {
const envs = await this.envs('', {}, userId);
let position = initPosition;
if (
envs &&
envs.length > 0 &&
typeof envs[envs.length - 1].position === 'number'
) {
position = this.getPrecisionPosition(envs[envs.length - 1].position!);
position = envs[envs.length - 1].position!;
}
const tabs = payloads.map((x) => {
position = this.getPrecisionPosition(position - stepPosition);
const tab = new Env({ ...x, position });
position = position - stepPosition;
const tab = new Env({ ...x, position, userId });
return tab;
});
const docs = await this.insert(tabs);
@@ -62,7 +85,8 @@ export default class EnvService {
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
public async remove(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.destroy({ where: { id: ids } });
await this.set_envs();
}
@@ -116,11 +140,12 @@ export default class EnvService {
}
private getPrecisionPosition(position: number): number {
return Math.trunc(parseFloat(position.toPrecision(16)));
return parseFloat(position.toPrecision(16));
}
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
public async envs(searchText: string = '', query: any = {}, userId?: number): Promise<Env[]> {
let condition = { ...query };
this.addUserIdFilter(condition, userId);
if (searchText) {
const encodeText = encodeURI(searchText);
const reg = {
@@ -147,7 +172,7 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
['isPinned', 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
@@ -173,7 +198,8 @@ export default class EnvService {
return doc.get({ plain: true });
}
public async disabled(ids: number[]) {
public async disabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.update(
{ status: EnvStatus.disabled },
{ where: { id: ids } },
@@ -181,12 +207,14 @@ export default class EnvService {
await this.set_envs();
}
public async enabled(ids: number[]) {
public async enabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
public async updateNames({ ids, name }: { ids: number[]; name: string }, userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}
+9 -30
View File
@@ -16,13 +16,6 @@ import { Service } from 'typedi';
export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
private formatGrpcAddress(host: string, port: number): string {
if (host === '::') {
return `[::]:${port}`;
}
return `${host}:${port}`;
}
async initialize() {
try {
this.server.addService(HealthService, { check });
@@ -30,32 +23,18 @@ export class GrpcServerService {
this.server.addService(ApiService, Api);
const grpcPort = config.grpcPort;
const hostsToTry = [
config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
await bindAsync(
`0.0.0.0:${grpcPort}`,
ServerCredentials.createInsecure(),
);
Logger.debug(`✌️ gRPC service started successfully`);
let lastError: Error | null = null;
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
});
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, ServerCredentials.createInsecure());
Logger.debug(`✌️ gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
}
}
Logger.error('Failed to start gRPC service on all hosts');
throw lastError || new Error('Failed to start gRPC service');
return grpcPort;
} catch (err) {
Logger.error('Failed to start gRPC service:', err);
throw err;
+17 -37
View File
@@ -3,51 +3,31 @@ import Logger from '../loaders/logger';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { Server } from 'http';
import config from '../config';
@Service()
export class HttpServerService {
private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [
config.bindHost,
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`✌️ HTTP service started successfully on ${host}:${port}`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
try {
return new Promise((resolve, reject) => {
this.server = expressApp.listen(port, '0.0.0.0', () => {
Logger.debug(`✌️ HTTP service started successfully`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
});
resolve(this.server);
});
this.server = server;
return server;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
}
this.server?.on('error', (err: Error) => {
Logger.error('Failed to start HTTP service:', err);
reject(err);
});
});
} catch (err) {
Logger.error('Failed to start HTTP service:', err);
throw err;
}
Logger.error('Failed to start HTTP service on all hosts');
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
resolve(server);
});
server.on('error', (err: Error) => {
server.close();
reject(err);
});
});
}
async shutdown() {
+6 -61
View File
@@ -34,7 +34,6 @@ export default class NotificationService {
['chronocat', this.chronocat],
['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot],
['openiLink', this.openiLink],
]);
private title = '';
@@ -91,14 +90,6 @@ export default class NotificationService {
return true;
}
private parseMailRecipients(value?: string) {
const recipients = (value || '')
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean);
return recipients.length > 0 ? recipients : undefined;
}
private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try {
@@ -559,33 +550,19 @@ export default class NotificationService {
}
private async lark() {
let { larkKey, larkSecret } = this.params;
let { larkKey } = this.params;
if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
}
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try {
const res = await httpClient.post(larkKey, {
...this.gotOption,
json: body,
json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' },
});
if (res.StatusCode === 0 || res.code === 0) {
@@ -600,7 +577,6 @@ export default class NotificationService {
private async email() {
const { emailPass, emailService, emailUser, emailTo } = this.params;
const recipients = this.parseMailRecipients(emailTo) || emailUser;
try {
const transporter = nodemailer.createTransport({
@@ -613,7 +589,7 @@ export default class NotificationService {
const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`,
to: recipients,
to: emailTo ? emailTo.split(';') : emailUser,
subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`,
});
@@ -868,35 +844,4 @@ export default class NotificationService {
}
return {};
}
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
content: `${this.title}\n\n${this.content}`,
};
if (openiLinkContextToken) {
body.context_token = openiLinkContextToken;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${openiLinkAppToken}`,
},
});
if (res.ok) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
}
-28
View File
@@ -131,32 +131,4 @@ export default class SshKeyService {
}
}
}
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
}
+45 -18
View File
@@ -31,7 +31,6 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class SubscriptionService {
@@ -43,11 +42,37 @@ export default class SubscriptionService {
private crontabService: CrontabService,
) {}
private addUserIdFilter(query: any, userId?: number) {
if (userId !== undefined) {
query.userId = userId;
}
return query;
}
private async checkOwnership(ids: number[], userId?: number): Promise<void> {
if (userId === undefined) {
// Admin can access all subscriptions
return;
}
const subscriptions = await SubscriptionModel.findAll({
where: { id: ids },
attributes: ['id', 'userId'],
});
const unauthorized = subscriptions.filter(
(sub) => sub.userId !== undefined && sub.userId !== userId
);
if (unauthorized.length > 0) {
throw new Error('无权限操作该订阅');
}
}
public async list(
searchText?: string,
ids?: string,
userId?: number,
): Promise<SubscriptionInstance[]> {
let query = {};
let query: any = {};
this.addUserIdFilter(query, userId);
const subIds = JSON.parse(ids || '[]');
if (searchText) {
const reg = {
@@ -137,7 +162,7 @@ export default class SubscriptionService {
let beforeStr = '';
try {
if (doc.sub_before) {
await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`);
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
beforeStr = await promiseExec(doc.sub_before);
}
} catch (error: any) {
@@ -145,7 +170,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (beforeStr) {
await logStreamManager.write(absolutePath, `${beforeStr}\n`);
await fs.appendFile(absolutePath, `${beforeStr}\n`);
}
},
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -164,7 +189,7 @@ export default class SubscriptionService {
let afterStr = '';
try {
if (sub.sub_after) {
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`);
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
afterStr = await promiseExec(sub.sub_after);
}
} catch (error: any) {
@@ -172,19 +197,16 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
await fs.appendFile(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
await fs.appendFile(
absolutePath,
`\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
@@ -199,12 +221,12 @@ export default class SubscriptionService {
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
await logStreamManager.write(absolutePath, `\n${message}`);
await fs.appendFile(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
await logStreamManager.write(absolutePath, `\n${message}`);
await fs.appendFile(absolutePath, `\n${message}`);
},
};
}
@@ -266,7 +288,8 @@ export default class SubscriptionService {
);
}
public async remove(ids: number[], query: { force?: boolean }) {
public async remove(ids: number[], query: { force?: boolean }, userId?: number) {
await this.checkOwnership(ids, userId);
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false);
@@ -277,7 +300,7 @@ export default class SubscriptionService {
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
await this.crontabService.remove(crons.map((x) => x.id!), userId);
}
for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias);
@@ -298,7 +321,8 @@ export default class SubscriptionService {
return doc.get({ plain: true });
}
public async run(ids: number[]) {
public async run(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await SubscriptionModel.update(
{ status: SubscriptionStatus.queued },
{ where: { id: ids } },
@@ -308,7 +332,8 @@ export default class SubscriptionService {
});
}
public async stop(ids: number[]) {
public async stop(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
if (doc.pid) {
@@ -343,7 +368,8 @@ export default class SubscriptionService {
});
}
public async disabled(ids: number[]) {
public async disabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } });
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig();
@@ -352,7 +378,8 @@ export default class SubscriptionService {
}
}
public async enabled(ids: number[]) {
public async enabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } });
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig();
-21
View File
@@ -530,27 +530,6 @@ export default class SystemService {
}
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
+54 -151
View File
@@ -11,7 +11,6 @@ import {
SystemModelInfo,
LoginStatus,
AuthInfo,
TokenInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
@@ -25,12 +24,17 @@ import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store';
import UserManagementService from './userManagement';
import { UserRole } from '../data/user';
@Service()
export default class UserService {
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
@Inject((type) => UserManagementService)
private userManagementService!: UserManagementService;
constructor(
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
@@ -94,38 +98,57 @@ export default class UserService {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) {
// Check if this is a regular user (not admin) trying to login
let authenticatedUser = null;
let userId: number | undefined = undefined;
let userRole = UserRole.admin;
// First check if it's the system admin
const isSystemAdmin = username === cUsername && password === cPassword;
if (!isSystemAdmin) {
// Try to authenticate as a regular user
try {
authenticatedUser = await this.userManagementService.authenticate(username, password);
if (authenticatedUser) {
userId = authenticatedUser.id;
userRole = authenticatedUser.role;
}
} catch (e: any) {
// User disabled or other error
return { code: 400, message: e.message };
}
}
if (isSystemAdmin || authenticatedUser) {
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
const expiration = (isSystemAdmin && twoFactorActivated) ? '60d' : '20d';
let token = jwt.sign(
{ data, userId, role: userRole },
config.jwt.secret,
{
expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384',
});
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
token,
tokens: updatedTokens,
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
// Only update authInfo for system admin
if (isSystemAdmin) {
await this.updateAuthInfo(content, {
token,
tokens: {
...tokens,
[req.platform]: token,
},
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
}
this.notificationService.notify(
'登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${
@@ -192,37 +215,11 @@ export default class UserService {
}
}
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
public async logout(platform: string): Promise<any> {
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
token: '',
tokens: { ...authInfo.tokens, [platform]: '' },
});
}
@@ -402,100 +399,6 @@ export default class UserService {
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo();
+125
View File
@@ -0,0 +1,125 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { User, UserModel, UserRole, UserStatus } from '../data/user';
import { Op } from 'sequelize';
import bcrypt from 'bcrypt';
@Service()
export default class UserManagementService {
constructor(@Inject('logger') private logger: winston.Logger) {}
private async hashPassword(password: string): Promise<string> {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
private async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
public async list(searchText?: string): Promise<User[]> {
let query: any = {};
if (searchText) {
query = {
username: { [Op.like]: `%${searchText}%` },
};
}
const docs = await UserModel.findAll({ where: query });
return docs.map((x) => x.get({ plain: true }));
}
public async get(id: number): Promise<User> {
const doc = await UserModel.findByPk(id);
if (!doc) {
throw new Error('用户不存在');
}
return doc.get({ plain: true });
}
public async getByUsername(username: string): Promise<User | null> {
const doc = await UserModel.findOne({ where: { username } });
if (!doc) {
return null;
}
return doc.get({ plain: true });
}
public async create(payload: User): Promise<User> {
const existingUser = await this.getByUsername(payload.username);
if (existingUser) {
throw new Error('用户名已存在');
}
if (payload.password.length < 6) {
throw new Error('密码长度至少为6位');
}
// Hash the password before storing
const hashedPassword = await this.hashPassword(payload.password);
const userWithHashedPassword = { ...payload, password: hashedPassword };
const doc = await UserModel.create(userWithHashedPassword);
return doc.get({ plain: true });
}
public async update(payload: User): Promise<User> {
if (!payload.id) {
throw new Error('缺少用户ID');
}
const existingUser = await this.get(payload.id);
if (!existingUser) {
throw new Error('用户不存在');
}
if (payload.password && payload.password.length < 6) {
throw new Error('密码长度至少为6位');
}
// Check if username is being changed and if new username already exists
if (payload.username !== existingUser.username) {
const userWithSameUsername = await this.getByUsername(payload.username);
if (userWithSameUsername && userWithSameUsername.id !== payload.id) {
throw new Error('用户名已存在');
}
}
// Hash the password if it's being updated
const updatePayload = { ...payload };
if (payload.password) {
updatePayload.password = await this.hashPassword(payload.password);
}
const [, [updated]] = await UserModel.update(updatePayload, {
where: { id: payload.id },
returning: true,
});
return updated.get({ plain: true });
}
public async delete(ids: number[]): Promise<number> {
const count = await UserModel.destroy({ where: { id: ids } });
return count;
}
public async authenticate(
username: string,
password: string,
): Promise<User | null> {
const user = await this.getByUsername(username);
if (!user) {
return null;
}
const isPasswordValid = await this.verifyPassword(password, user.password);
if (!isPasswordValid) {
return null;
}
if (user.status === UserStatus.disabled) {
throw new Error('用户已被禁用');
}
return user;
}
}
-46
View File
@@ -1,46 +0,0 @@
import { AuthInfo, TokenInfo } from '../data/system';
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
-110
View File
@@ -1,110 +0,0 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
*/
export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
}
}
/**
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
}
/**
* Get the number of open streams
*/
getOpenStreamCount(): number {
return this.streams.size;
}
}
// Export a singleton instance for shared use
export const logStreamManager = new LogStreamManager();
-35
View File
@@ -2,45 +2,10 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
+5
View File
@@ -6,6 +6,11 @@ declare global {
namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
user?: {
userId?: number;
role?: number;
};
auth?: any;
}
}
}
+3 -8
View File
@@ -1,5 +1,5 @@
import { Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
@@ -13,7 +13,7 @@ const validateSchedule = (value: string, helpers: any) => {
}
try {
if (CronExpressionParser.parse(value).hasNext()) {
if (cron_parser.parseExpression(value).hasNext()) {
return value;
}
} catch (e) {
@@ -64,11 +64,7 @@ export const commonCronSchema = {
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
@@ -81,5 +77,4 @@ export const commonCronSchema = {
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
};
+3 -4
View File
@@ -69,10 +69,9 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+3 -4
View File
@@ -69,10 +69,9 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+7 -24
View File
@@ -2,53 +2,36 @@
dir_shell=/ql/shell
. $dir_shell/share.sh
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
. $dir_shell/env.sh
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
# Fix DNS resolution issues in Alpine Linux
# Alpine uses musl libc which has known DNS resolver issues with certain domains
# Adding ndots:0 prevents unnecessary search domain appending
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
fix_config
# Try to initialize PM2, but don't fail if it doesn't work
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
pm2 l &>/dev/null
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* Multi-User Data Migration Script
*
* This script migrates existing data (Cron, Env, Subscription, Dependence)
* to be associated with specific users.
*
* Usage:
* node migrate-to-multiuser.js --userId=1
* node migrate-to-multiuser.js --username=admin
* node migrate-to-multiuser.js --list-users
*
* Options:
* --userId=<id> Assign all legacy data to user with this ID
* --username=<name> Assign all legacy data to user with this username
* --list-users List all users in the system
* --dry-run Show what would be changed without making changes
* --help Show this help message
*/
const path = require('path');
const fs = require('fs');
const Sequelize = require('sequelize');
// Load environment variables
require('dotenv').config();
// Configuration
const config = {
dbPath: process.env.QL_DATA_DIR || path.join(__dirname, '../data'),
rootPath: __dirname,
};
// Initialize Sequelize
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: path.join(config.dbPath, 'database.sqlite'),
logging: false,
});
// Define models
const UserModel = sequelize.define('User', {
username: Sequelize.STRING,
password: Sequelize.STRING,
role: Sequelize.NUMBER,
status: Sequelize.NUMBER,
});
const CrontabModel = sequelize.define('Crontab', {
name: Sequelize.STRING,
command: Sequelize.STRING,
schedule: Sequelize.STRING,
userId: Sequelize.NUMBER,
});
const EnvModel = sequelize.define('Env', {
name: Sequelize.STRING,
value: Sequelize.STRING,
userId: Sequelize.NUMBER,
});
const SubscriptionModel = sequelize.define('Subscription', {
name: Sequelize.STRING,
url: Sequelize.STRING,
userId: Sequelize.NUMBER,
});
const DependenceModel = sequelize.define('Dependence', {
name: Sequelize.STRING,
type: Sequelize.NUMBER,
userId: Sequelize.NUMBER,
});
// Parse command line arguments
function parseArgs() {
const args = {
userId: null,
username: null,
listUsers: false,
dryRun: false,
help: false,
};
process.argv.slice(2).forEach(arg => {
if (arg.startsWith('--userId=')) {
args.userId = parseInt(arg.split('=')[1]);
} else if (arg.startsWith('--username=')) {
args.username = arg.split('=')[1];
} else if (arg === '--list-users') {
args.listUsers = true;
} else if (arg === '--dry-run') {
args.dryRun = true;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
}
});
return args;
}
// Show help
function showHelp() {
console.log(`
Multi-User Data Migration Script
This script migrates existing data (Cron, Env, Subscription, Dependence)
to be associated with specific users.
Usage:
node migrate-to-multiuser.js --userId=1
node migrate-to-multiuser.js --username=admin
node migrate-to-multiuser.js --list-users
Options:
--userId=<id> Assign all legacy data to user with this ID
--username=<name> Assign all legacy data to user with this username
--list-users List all users in the system
--dry-run Show what would be changed without making changes
--help Show this help message
Examples:
# List all users
node migrate-to-multiuser.js --list-users
# Migrate all data to user ID 1 (dry run)
node migrate-to-multiuser.js --userId=1 --dry-run
# Migrate all data to user 'admin'
node migrate-to-multiuser.js --username=admin
Note: This script will only migrate data where userId is NULL or undefined.
Data already assigned to users will not be changed.
`);
}
// List all users
async function listUsers() {
const users = await UserModel.findAll();
if (users.length === 0) {
console.log('\nNo users found in the database.');
console.log('Please create users first using the User Management interface.');
return;
}
console.log('\nUsers in the system:');
console.log('ID\tUsername\tRole\t\tStatus');
console.log('--\t--------\t----\t\t------');
users.forEach(user => {
const role = user.role === 0 ? 'Admin' : 'User';
const status = user.status === 0 ? 'Enabled' : 'Disabled';
console.log(`${user.id}\t${user.username}\t\t${role}\t\t${status}`);
});
console.log('');
}
// Get statistics of legacy data
async function getStatistics() {
const stats = {
crons: await CrontabModel.count({ where: { userId: null } }),
envs: await EnvModel.count({ where: { userId: null } }),
subscriptions: await SubscriptionModel.count({ where: { userId: null } }),
dependences: await DependenceModel.count({ where: { userId: null } }),
};
return stats;
}
// Migrate data to a specific user
async function migrateData(userId, dryRun = false) {
const stats = await getStatistics();
console.log('\nLegacy Data Statistics:');
console.log(` Cron tasks: ${stats.crons}`);
console.log(` Environment variables: ${stats.envs}`);
console.log(` Subscriptions: ${stats.subscriptions}`);
console.log(` Dependencies: ${stats.dependences}`);
console.log('');
if (stats.crons + stats.envs + stats.subscriptions + stats.dependences === 0) {
console.log('No legacy data found. All data is already assigned to users.');
return;
}
if (dryRun) {
console.log('DRY RUN: No changes will be made.\n');
console.log(`Would assign all legacy data to user ID ${userId}`);
return;
}
console.log(`Migrating data to user ID ${userId}...`);
const transaction = await sequelize.transaction();
try {
// Migrate crons
if (stats.crons > 0) {
await CrontabModel.update(
{ userId },
{ where: { userId: null }, transaction }
);
console.log(`✓ Migrated ${stats.crons} cron tasks`);
}
// Migrate envs
if (stats.envs > 0) {
await EnvModel.update(
{ userId },
{ where: { userId: null }, transaction }
);
console.log(`✓ Migrated ${stats.envs} environment variables`);
}
// Migrate subscriptions
if (stats.subscriptions > 0) {
await SubscriptionModel.update(
{ userId },
{ where: { userId: null }, transaction }
);
console.log(`✓ Migrated ${stats.subscriptions} subscriptions`);
}
// Migrate dependences
if (stats.dependences > 0) {
await DependenceModel.update(
{ userId },
{ where: { userId: null }, transaction }
);
console.log(`✓ Migrated ${stats.dependences} dependencies`);
}
await transaction.commit();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
await transaction.rollback();
console.error('\n✗ Migration failed:', error.message);
throw error;
}
}
// Main function
async function main() {
const args = parseArgs();
if (args.help) {
showHelp();
return;
}
try {
// Test database connection
await sequelize.authenticate();
console.log('Database connection established.');
if (args.listUsers) {
await listUsers();
return;
}
// Validate arguments
if (!args.userId && !args.username) {
console.error('\nError: You must specify either --userId or --username');
console.log('Use --help for usage information.');
process.exit(1);
}
// Get user ID
let userId = args.userId;
if (args.username) {
const user = await UserModel.findOne({
where: { username: args.username }
});
if (!user) {
console.error(`\nError: User '${args.username}' not found.`);
console.log('Use --list-users to see available users.');
process.exit(1);
}
userId = user.id;
console.log(`Found user '${args.username}' with ID ${userId}`);
} else {
// Verify user exists
const user = await UserModel.findByPk(userId);
if (!user) {
console.error(`\nError: User with ID ${userId} not found.`);
console.log('Use --list-users to see available users.');
process.exit(1);
}
console.log(`Found user '${user.username}' with ID ${userId}`);
}
// Perform migration
await migrateData(userId, args.dryRun);
} catch (error) {
console.error('\nError:', error.message);
process.exit(1);
} finally {
await sequelize.close();
}
}
// Run the script
if (require.main === module) {
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
}
module.exports = { main, listUsers, migrateData, getStatistics };
+25 -24
View File
@@ -1,6 +1,5 @@
{
"private": true,
"packageManager": "pnpm@8.3.1",
"scripts": {
"start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts",
@@ -55,14 +54,18 @@
}
},
"dependencies": {
"@bufbuild/protobuf": "^2.10.0",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1",
"bcrypt": "^6.0.0",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"cron-parser": "^5.4.0",
"cron-parser": "^4.9.0",
"cross-spawn": "^7.0.6",
"dayjs": "^1.11.13",
"dotenv": "^16.4.6",
@@ -70,51 +73,49 @@
"express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3",
"undici": "^7.9.0",
"helmet": "^8.1.0",
"hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3",
"iconv-lite": "^0.6.3",
"ip2region": "2.3.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.17.21",
"multer": "2.1.1",
"multer": "1.4.5-lts.1",
"node-schedule": "^2.1.0",
"nodemailer": "^8.0.1",
"nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2",
"request-ip": "3.3.0",
"sequelize": "^6.37.5",
"sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"request-ip": "3.3.0",
"ip2region": "2.3.0",
"keyv": "^5.2.3",
"@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^8.1.0"
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"moment": "2.30.1",
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22",
"@codemirror/view": "^6.34.1",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2",
"react-router-dom": "6.26.1",
"@types/bcrypt": "^6.0.0",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2",
"@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4",
"@types/file-saver": "2.0.2",
"@types/helmet": "^4.0.0",
"@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185",
@@ -122,17 +123,17 @@
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6",
"@types/request-ip": "0.0.41",
"@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4",
"@types/request-ip": "0.0.41",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4",
@@ -144,9 +145,9 @@
"axios": "^1.4.0",
"compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0",
"react-hotkeys-hook": "^4.6.1",
"file-saver": "2.0.2",
"lint-staged": "^13.0.3",
"moment": "2.30.1",
"monaco-editor": "0.33.0",
"nodemon": "^3.0.1",
"prettier": "^2.5.1",
@@ -162,7 +163,9 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-intl-universal": "^2.12.0",
"react-router-dom": "6.26.1",
"react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0",
"ts-node": "^10.9.2",
@@ -170,8 +173,6 @@
"tslib": "^2.4.0",
"typescript": "5.2.2",
"vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0",
"@types/compression": "^1.7.2",
"@types/helmet": "^4.0.0"
"virtualizedtableforantd4": "1.3.0"
}
}
+288 -578
View File
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -195,14 +195,12 @@ export SMTP_SERVER=""
## SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false
export SMTP_SSL=""
## smtp_email 填写 SMTP 发件邮箱
## smtp_email 填写 SMTP 发件邮箱,通知将会由自己发给自己
export SMTP_EMAIL=""
## smtp_password 填写 SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
export SMTP_PASSWORD=""
## smtp_name 填写 SMTP 收发件人姓名,可随意填写
export SMTP_NAME=""
## smtp_email_to 填写 SMTP 收件邮箱,多个用英文;分隔,不填默认发给发件邮箱
export SMTP_EMAIL_TO=""
## 17. PushMe
## 官方说明文档:https://push.i-i.me/
@@ -261,13 +259,4 @@ export WEBHOOK_METHOD=""
## 支持 text/plain、application/json、multipart/form-data、application/x-www-form-urlencoded
export WEBHOOK_CONTENT_TYPE=""
## 23. OpeniLink
## 官方文档: https://openilink.com/docs/hub/apps
## 在 OpeniLink Hub 后台安装 App 后获取 app_token
export OPENILINK_APP_TOKEN=""
## OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址
export OPENILINK_HUB_URL=""
## OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取
export OPENILINK_CONTEXT_TOKEN=""
## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可
+9 -113
View File
@@ -52,7 +52,6 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg
@@ -121,8 +120,7 @@ const push_config = {
SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json
SMTP_EMAIL: '', // SMTP 发件邮箱
SMTP_TO: '', // SMTP 收件邮箱,兼容旧参数名,默认通知将会发给发件邮箱
SMTP_EMAIL_TO: '', // SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱
SMTP_TO: '', // SMTP 收件邮箱,默认通知将会发给发件邮箱
SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写
@@ -152,11 +150,6 @@ const push_config = {
WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken
WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
// 官方文档: https://openilink.com/docs/hub/apps
OPENILINK_APP_TOKEN: '', // OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取
OPENILINK_HUB_URL: '', // OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址
OPENILINK_CONTEXT_TOKEN: '', // OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取
};
for (const key in push_config) {
@@ -488,13 +481,9 @@ function tgBotNotify(text, desp) {
timeout,
};
if (TG_PROXY_HOST && TG_PROXY_PORT) {
let proxyHost = TG_PROXY_HOST;
if (TG_PROXY_AUTH && !TG_PROXY_HOST.includes('@')) {
proxyHost = `${TG_PROXY_AUTH}@${TG_PROXY_HOST}`;
}
let agent;
agent = new ProxyAgent({
uri: `http://${proxyHost}:${TG_PROXY_PORT}`,
uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
});
options.dispatcher = agent;
}
@@ -1000,29 +989,11 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) {
return new Promise((resolve) => {
const { FSKEY, FSSECRET } = push_config;
const { FSKEY } = push_config;
if (FSKEY) {
const body = {
msg_type: 'text',
content: { text: `${text}\n\n${desp}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (FSSECRET) {
const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${FSSECRET}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: body,
json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } },
headers: {
'Content-Type': 'application/json',
},
@@ -1052,14 +1023,8 @@ function fsBotNotify(text, desp) {
}
async function smtpNotify(text, desp) {
const {
SMTP_EMAIL,
SMTP_TO,
SMTP_EMAIL_TO,
SMTP_PASSWORD,
SMTP_SERVICE,
SMTP_NAME,
} = push_config;
const { SMTP_EMAIL, SMTP_TO, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } =
push_config;
if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) {
return;
}
@@ -1075,20 +1040,9 @@ async function smtpNotify(text, desp) {
});
const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL;
const recipients = [SMTP_EMAIL_TO, SMTP_TO].reduce((list, value) => {
if (!value) {
return list;
}
return list.concat(
value
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean),
);
}, []);
const info = await transporter.sendMail({
from: addr,
to: recipients.length ? recipients : SMTP_EMAIL,
to: SMTP_TO ? SMTP_TO.split(';') : addr,
subject: text,
html: `${desp.replace(/\n/g, '<br/>')}`,
});
@@ -1308,15 +1262,7 @@ function ntfyNotify(text, desp) {
}
return new Promise((resolve) => {
const {
NTFY_URL,
NTFY_TOPIC,
NTFY_PRIORITY,
NTFY_TOKEN,
NTFY_USERNAME,
NTFY_PASSWORD,
NTFY_ACTIONS,
} = push_config;
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
if (NTFY_TOPIC) {
const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1331,8 +1277,7 @@ function ntfyNotify(text, desp) {
if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] =
`Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
}
if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
@@ -1431,54 +1376,6 @@ function wxPusherNotify(text, desp) {
});
}
function openiLinkNotify(text, desp) {
return new Promise((resolve) => {
const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } =
push_config;
if (OPENILINK_APP_TOKEN) {
const baseUrl = OPENILINK_HUB_URL
? OPENILINK_HUB_URL.replace(/\/$/, '')
: 'https://hub.openilink.com';
const body = {
type: 'text',
content: `${text}\n\n${desp}`,
};
if (OPENILINK_CONTEXT_TOKEN) {
body.context_token = OPENILINK_CONTEXT_TOKEN;
}
const options = {
url: `${baseUrl}/bot/v1/message/send`,
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENILINK_APP_TOKEN}`,
},
timeout,
};
$.post(options, (err, resp, data) => {
try {
if (err) {
console.log('OpeniLink 发送通知消息失败!\n', err);
} else {
if (data.ok) {
console.log('OpeniLink 发送通知消息成功!');
} else {
console.log(`OpeniLink 发送通知消息异常:${data.error}`);
}
}
} catch (e) {
$.logErr(e, resp);
} finally {
resolve(data);
}
});
} else {
resolve();
}
});
}
function parseString(input, valueFormatFn) {
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g;
const matches = {};
@@ -1609,7 +1506,6 @@ async function sendNotify(text, desp, params = {}) {
qmsgNotify(text, desp), // 自定义通知
ntfyNotify(text, desp), // Ntfy
wxPusherNotify(text, desp), // wxpusher
openiLinkNotify(text, desp), // OpeniLink
]);
}
+8 -66
View File
@@ -49,7 +49,6 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -107,8 +106,7 @@ push_config = {
'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465
'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false
'SMTP_EMAIL': '', # SMTP 发件邮箱
'SMTP_EMAIL_TO': '', # SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱
'SMTP_EMAIL': '', # SMTP 发件邮箱,通知将会由自己发给自己
'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写
@@ -136,10 +134,6 @@ push_config = {
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'WXPUSHER_UIDS': '', # wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'OPENILINK_APP_TOKEN': '', # OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取 官方文档: https://openilink.com/docs/hub/apps
'OPENILINK_HUB_URL': '', # OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址
'OPENILINK_CONTEXT_TOKEN': '', # OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取
}
# fmt: on
@@ -239,20 +233,6 @@ def feishu_bot(title: str, content: str) -> None:
url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
# Add signature if secret is provided
# Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
# and signs an empty message, which differs from typical HMAC usage
if push_config.get("FSSECRET"):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}'
hmac_code = hmac.new(
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode("utf-8")
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0:
@@ -695,10 +675,6 @@ def smtp(title: str, content: str) -> None:
return
print("SMTP 邮件 服务启动")
email_to = push_config.get("SMTP_EMAIL_TO") or push_config.get("SMTP_EMAIL")
email_to_list = [
item.strip() for item in re.split(r"[;]", email_to) if item.strip()
]
message = MIMEText(content, "plain", "utf-8")
message["From"] = formataddr(
(
@@ -706,7 +682,12 @@ def smtp(title: str, content: str) -> None:
push_config.get("SMTP_EMAIL"),
)
)
message["To"] = ",".join(email_to_list)
message["To"] = formataddr(
(
Header(push_config.get("SMTP_NAME"), "utf-8").encode(),
push_config.get("SMTP_EMAIL"),
)
)
message["Subject"] = Header(title, "utf-8")
try:
@@ -720,7 +701,7 @@ def smtp(title: str, content: str) -> None:
)
smtp_server.sendmail(
push_config.get("SMTP_EMAIL"),
email_to_list,
push_config.get("SMTP_EMAIL"),
message.as_bytes(),
)
smtp_server.close()
@@ -902,43 +883,6 @@ def wxpusher_bot(title: str, content: str) -> None:
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
def openilink(title: str, content: str) -> None:
"""
通过 OpeniLink 推送消息
支持的环境变量:
- OPENILINK_APP_TOKEN: OpeniLink Hub 后台安装 App 后获取的 app_token
- OPENILINK_HUB_URL: OpeniLink Hub 地址默认为 https://hub.openilink.com
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token可从消息事件中获取
"""
if not push_config.get("OPENILINK_APP_TOKEN"):
return
print("OpeniLink 服务启动")
base_url = (
push_config.get("OPENILINK_HUB_URL", "").rstrip("/")
or "https://hub.openilink.com"
)
url = f"{base_url}/bot/v1/message/send"
headers = {
"Content-Type": "application/json",
"Authorization": f'Bearer {push_config.get("OPENILINK_APP_TOKEN")}',
}
data = {
"type": "text",
"content": f"{title}\n\n{content}",
}
if push_config.get("OPENILINK_CONTEXT_TOKEN"):
data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN")
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("ok"):
print("OpeniLink 推送成功!")
else:
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
def parse_headers(headers):
if not headers:
return {}
@@ -1104,8 +1048,6 @@ def add_notify_function():
push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS")
):
notify_function.append(wxpusher_bot)
if push_config.get("OPENILINK_APP_TOKEN"):
notify_function.append(openilink)
if not notify_function:
print(f"无推送渠道,请检查通知变量是否正确")
return notify_function
-28
View File
@@ -12,32 +12,4 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x);
});
// 查询定时任务 (Query cron tasks)
QLAPI.getCrons({ searchValue: 'test' }).then((x) => {
console.log('getCrons', x);
});
// 通过ID查询定时任务 (Get cron by ID)
QLAPI.getCronById({ id: 1 }).then((x) => {
console.log('getCronById', x);
}).catch((err) => {
console.log('getCronById error', err);
});
// 启用定时任务 (Enable cron tasks)
QLAPI.enableCrons({ ids: [1, 2] }).then((x) => {
console.log('enableCrons', x);
});
// 禁用定时任务 (Disable cron tasks)
QLAPI.disableCrons({ ids: [1, 2] }).then((x) => {
console.log('disableCrons', x);
});
// 手动执行定时任务 (Run cron tasks manually)
QLAPI.runCrons({ ids: [1] }).then((x) => {
console.log('runCrons', x);
});
console.log('test desc');
+8 -8
View File
@@ -41,7 +41,7 @@ add_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
@@ -71,7 +71,7 @@ update_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -98,7 +98,7 @@ update_cron_command_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -118,7 +118,7 @@ del_cron_api() {
local ids="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -143,7 +143,7 @@ update_cron() {
local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -165,7 +165,7 @@ notify_api() {
local content="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -185,7 +185,7 @@ find_cron_api() {
local params="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
--compressed
@@ -204,7 +204,7 @@ update_auth_config() {
local tip="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
+3 -3
View File
@@ -31,7 +31,7 @@ pm2_log() {
}
check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}")
local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700")
echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n"
@@ -42,10 +42,10 @@ check_pm2() {
pm2_log
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H "Referer: http://0.0.0.0:${ql_port}/crontab" \
-H 'Referer: http://0.0.0.0:5700/crontab' \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed
)
+1 -6
View File
@@ -5,7 +5,7 @@ const { join } = require('path');
class GrpcClient {
static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`,
serverAddress: '0.0.0.0:5500',
protoOptions: {
keepCase: true,
longs: String,
@@ -33,11 +33,6 @@ class GrpcClient {
'createCron',
'updateCron',
'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
];
#client;
+11 -32
View File
@@ -59,10 +59,15 @@ list_own_user=$dir_list_tmp/own_user.list
list_own_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=(
task
ql
)
original_name=(
task.sh
update.sh
)
init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null)
@@ -79,20 +84,15 @@ init_env() {
export PYTHONUNBUFFERED=1
}
load_ql_envs() {
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
ql_grpc_port=${QlGrpcPort:-"5500"}
current_branch=${QL_BRANCH:-""}
}
import_config() {
[[ -f $file_config_user ]] && . $file_config_user
load_ql_envs
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}"
@@ -272,35 +272,14 @@ random_range() {
delete_pm2() {
cd $dir_root
# Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
pkill -f "node.*static/build/app.js" 2>/dev/null || true
pm2 delete ecosystem.config.js
}
reload_pm2() {
cd $dir_root
restore_env_vars
# Try to start PM2, but handle failures gracefully
if pm2 flush &>/dev/null && pm2 startOrGracefulReload ecosystem.config.js --update-env; then
return 0
else
local exit_code=$?
echo "警告: PM2 启动失败 (退出码: $exit_code),可能是由于硬件不兼容"
echo "正在尝试直接使用 Node.js 启动服务..."
# Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true
# Start node directly in the background
nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 &
local node_pid=$!
echo "已使用 Node.js 直接启动服务 (PID: $node_pid)"
echo "注意: 使用此模式时,部分 PM2 管理功能将不可用"
return 0
fi
pm2 flush &>/dev/null
pm2 startOrGracefulReload ecosystem.config.js --update-env
}
diff_time() {
-1
View File
@@ -3,7 +3,6 @@
dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh
send_mark=$dir_shell/send_mark
+11 -16
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, Typography, message } from 'antd';
import { Tooltip, Typography } from 'antd';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,21 +10,16 @@ const Copy = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
const copyIdRef = useRef<number>();
const handleCopy = (text: string, result: boolean) => {
if (result) {
setCopied(true);
message.success(intl.get('复制成功'));
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}
};
const handleClick = (e?: React.MouseEvent) => {
const copyText = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
setCopied(true);
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
};
const cleanCopyId = () => {
@@ -32,8 +27,8 @@ const Copy = ({ text }: { text: string }) => {
};
return (
<Link onClick={handleClick} style={{ marginLeft: 4 }}>
<CopyToClipboard text={text} onCopy={handleCopy}>
<Link onClick={copyText} style={{ marginLeft: 1 }}>
<CopyToClipboard text={text}>
<Tooltip
key="copy"
title={copied ? intl.get('复制成功') : intl.get('复制')}
+17 -25
View File
@@ -104,7 +104,7 @@
"序号": "Number",
"备注": "Remarks",
"更新时间": "Update Time",
"创建时间": "Created Time",
"创建时间": "Creation Time",
"确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install",
@@ -252,7 +252,7 @@
"登录日志": "Login Logs",
"其他设置": "Other Settings",
"关于": "About",
"成功": "Successfully",
"成功": "Success",
"失败": "Failure",
"登录时间": "Login Time",
"登录地址": "Login Address",
@@ -389,7 +389,6 @@
"消息接收人": "message recipient",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings",
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱地址": "Email Address",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
@@ -532,26 +531,19 @@
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters",
"未启用": "Not enabled",
"默认为 CPU 个数": "Default is the number of CPUs",
"Minimum is 4": "Minimum is 4",
"实例模式": "Instance Mode",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously",
"请选择实例模式": "Please select instance mode",
"单实例": "Single Instance",
"多实例": "Multi-Instance",
"SSH密钥": "SSH Keys",
"别名": "Alias",
"编辑SSH密钥": "Edit SSH Key",
"创建SSH密钥": "Create SSH Key",
"更新SSH密钥成功": "SSH key updated successfully",
"创建SSH密钥成功": "SSH key created successfully",
"请输入SSH密钥别名": "Please enter SSH key alias",
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
"用户管理": "User Management",
"用户名": "Username",
"密码": "Password",
"角色": "Role",
"管理员": "Admin",
"普通用户": "User",
"启用": "Enabled",
"禁用": "Disabled",
"创建时间": "Created At",
"确认删除选中的用户吗": "Are you sure to delete selected users?",
"请输入用户名": "Please enter username",
"请输入密码": "Please enter password",
"密码长度至少为6位": "Password must be at least 6 characters",
"新增用户": "Add User",
"编辑用户": "Edit User"
}
+15 -23
View File
@@ -389,7 +389,6 @@
"消息接收人": "消息接收人",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得",
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱地址": "邮箱地址",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定",
@@ -532,26 +531,19 @@
"请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符",
"日志名称不能超过100个字符": "日志名称不能超过100个字符",
"未启用": "未启用",
"默认为 CPU 个数": "默认为 CPU 个数",
"最小是 4": "最小是 4",
"实例模式": "实例模式",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例",
"请选择实例模式": "请选择实例模式",
"单实例": "单实例",
"多实例": "多实例",
"SSH密钥": "SSH密钥",
"别名": "别名",
"编辑SSH密钥": "编辑SSH密钥",
"创建SSH密钥": "创建SSH密钥",
"更新SSH密钥成功": "更新SSH密钥成功",
"创建SSH密钥成功": "创建SSH密钥成功",
"请输入SSH密钥别名": "请输入SSH密钥别名",
"请输入SSH私钥": "请输入SSH私钥",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)",
"确认删除SSH密钥": "确认删除SSH密钥",
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
"用户管理": "用户管理",
"用户名": "用户名",
"密码": "密码",
"角色": "角色",
"管理员": "管理员",
"普通用户": "普通用户",
"启用": "启用",
"禁用": "禁用",
"创建时间": "创建时间",
"确认删除选中的用户吗": "确认删除选中的用户吗",
"请输入用户名": "请输入用户名",
"请输入密码": "请输入密码",
"密码长度至少为6位": "密码长度至少为6位",
"新增用户": "新增用户",
"编辑用户": "编辑用户"
}
+17 -32
View File
@@ -66,7 +66,6 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [
{
title: intl.get('名称'),
@@ -248,8 +247,8 @@ const Crontab = () => {
>
{record.last_execution_time
? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</span>
);
@@ -273,12 +272,6 @@ const Crontab = () => {
title: intl.get('关联订阅'),
width: 185,
render: (text, record: any) => record?.subscription?.name || '-',
key: 'sub_id',
dataIndex: 'sub_id',
filters: allSubscriptions.map((sub) => ({
text: sub.name || sub.alias,
value: sub.id,
})),
},
{
title: intl.get('操作'),
@@ -368,10 +361,11 @@ const Crontab = () => {
const getCrons = () => {
setLoading(true);
const { page, size, sorter, filters } = pageConf;
let url = `${config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
let url = `${
config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({
field: sorter.column.key,
@@ -529,8 +523,9 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: `确认${
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认')}
@@ -545,7 +540,8 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable'
`${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`,
[record.id],
)
@@ -569,8 +565,9 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
@@ -585,7 +582,8 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin'
`${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
@@ -801,20 +799,8 @@ const Crontab = () => {
}
}, [viewConf, enabledCronViews]);
const getAllSubscriptions = () => {
request
.get(`${config.apiPrefix}subscriptions`)
.then(({ code, data }) => {
if (code === 200) {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
};
useEffect(() => {
getCronViews();
getAllSubscriptions();
}, []);
const viewAction = (key: string) => {
@@ -1028,7 +1014,6 @@ const Crontab = () => {
)}
<Table
columns={columns}
sortDirections={['descend', 'ascend']}
pagination={{
current: pageConf.page,
pageSize: pageConf.size,
+5 -25
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd';
import CronExpressionParser from 'cron-parser';
import cronParse from 'cron-parser';
import { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,14 +91,10 @@ const CronModal = ({
{ required: true },
{
validator: (_, value) => {
try {
if (!value || CronExpressionParser.parse(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
} catch (e) {
return Promise.reject(intl.get('Cron表达式格式有误'));
if (!value || cronParse.parseExpression(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
},
},
]}
@@ -184,18 +180,6 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
<Form.Item
name="allow_multiple_instances"
label={intl.get('实例模式')}
tooltip={intl.get(
'单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例',
)}
>
<Select placeholder={intl.get('请选择实例模式')}>
<Select.Option value={0}>{intl.get('单实例')}</Select.Option>
<Select.Option value={1}>{intl.get('多实例')}</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="log_name"
label={intl.get('日志名称')}
@@ -210,11 +194,7 @@ const CronModal = ({
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
);
-1
View File
@@ -37,7 +37,6 @@ export interface ICrontab {
nextRunTime: Date;
sub_id: number;
extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
}
export enum ScheduleType {
+1 -1
View File
@@ -16,7 +16,7 @@ const SaveModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const payload = { ...values, originFilename: file.title, content: file.content };
const payload = { ...file, ...values, originFilename: file.title };
request
.post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => {
+24 -11
View File
@@ -35,6 +35,7 @@ import './index.less';
import useResizeObserver from '@react-hook/resize-observer';
import SystemLog from './systemLog';
import Dependence from './dependence';
import UserManagement from './userManagement';
const { Text } = Typography;
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
@@ -49,7 +50,6 @@ const Setting = () => {
reloadTheme,
systemInfo,
} = useOutletContext<SharedContext>();
console.log('user',user)
const columns = [
{
title: intl.get('名称'),
@@ -334,16 +334,29 @@ const Setting = () => {
label: intl.get('通知设置'),
children: <NotificationSetting data={notificationInfo} />,
},
{
key: 'syslog',
label: intl.get('系统日志'),
children: <SystemLog height={height} theme={theme} />,
},
{
key: 'login',
label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />,
},
...(user?.role === 0
? [
{
key: 'syslog',
label: intl.get('系统日志'),
children: <SystemLog height={height} theme={theme} />,
},
{
key: 'login',
label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />,
},
]
: []),
...(user?.role === 0 && !isDemoEnv
? [
{
key: 'user-management',
label: intl.get('用户管理'),
children: <UserManagement height={height} />,
},
]
: []),
{
key: 'dependence',
label: intl.get('依赖设置'),
+1 -31
View File
@@ -30,7 +30,6 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency',
timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
};
const exportModules = [
@@ -55,7 +54,6 @@ const Other = ({
logRemoveFrequency?: number | null;
cronConcurrency?: number | null;
timezone?: string | null;
globalSshKey?: string | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
@@ -242,7 +240,6 @@ const Other = ({
addonBefore={intl.get('每')}
addonAfter={intl.get('天')}
style={{ width: 180 }}
placeholder={intl.get('未启用')}
min={0}
value={systemConfig?.logRemoveFrequency}
onChange={(value) => {
@@ -264,9 +261,8 @@ const Other = ({
<Input.Group compact>
<InputNumber
style={{ width: 180 }}
min={4}
min={1}
value={systemConfig?.cronConcurrency}
placeholder={intl.get('默认为 CPU 个数')}
onChange={(value) => {
setSystemConfig({ ...systemConfig, cronConcurrency: value });
}}
@@ -310,32 +306,6 @@ const Other = ({
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('全局SSH私钥')}
name="globalSshKey"
tooltip={intl.get('用于访问所有私有仓库的全局SSH私钥')}
>
<Input.Group compact>
<Input.TextArea
value={systemConfig?.globalSshKey || ''}
style={{ width: 264 }}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={intl.get('请输入完整的SSH私钥内容')}
onChange={(e) => {
setSystemConfig({ ...systemConfig, globalSshKey: e.target.value });
}}
/>
</Input.Group>
<Button
type="primary"
onClick={() => {
updateSystemConfig('global-ssh-key');
}}
style={{ width: 264, marginTop: 8 }}
>
{intl.get('确认')}
</Button>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang">
<Select
defaultValue={localStorage.getItem('lang') || ''}
+264
View File
@@ -0,0 +1,264 @@
import intl from 'react-intl-universal';
import React, { useState, useEffect } from 'react';
import {
Button,
Table,
Space,
Modal,
Form,
Input,
Select,
message,
Tag,
} from 'antd';
import {
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { request } from '@/utils/http';
import config from '@/utils/config';
const { Option } = Select;
interface User {
id: number;
username: string;
password?: string;
role: number;
status: number;
createdAt: string;
updatedAt: string;
}
const UserManagement: React.FC<{ height: number }> = ({ height }) => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const columns = [
{
title: intl.get('用户名'),
dataIndex: 'username',
key: 'username',
},
{
title: intl.get('角色'),
dataIndex: 'role',
key: 'role',
render: (role: number) => (
<Tag color={role === 0 ? 'red' : 'blue'}>
{role === 0 ? intl.get('管理员') : intl.get('普通用户')}
</Tag>
),
},
{
title: intl.get('状态'),
dataIndex: 'status',
key: 'status',
render: (status: number) => (
<Tag color={status === 0 ? 'green' : 'default'}>
{status === 0 ? intl.get('启用') : intl.get('禁用')}
</Tag>
),
},
{
title: intl.get('创建时间'),
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => text ? new Date(text).toLocaleString() : '-',
},
{
title: intl.get('操作'),
key: 'action',
render: (_: any, record: User) => (
<Space size="middle">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
{intl.get('编辑')}
</Button>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete([record.id])}
>
{intl.get('删除')}
</Button>
</Space>
),
},
];
const fetchUsers = async () => {
setLoading(true);
try {
const { code, data } = await request.get(
`${config.apiPrefix}user-management`
);
if (code === 200) {
setUsers(data);
}
} catch (error) {
message.error('Failed to fetch users');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleAdd = () => {
setEditingUser(null);
form.resetFields();
setIsModalVisible(true);
};
const handleEdit = (record: User) => {
setEditingUser(record);
form.setFieldsValue({
username: record.username,
role: record.role,
status: record.status,
});
setIsModalVisible(true);
};
const handleDelete = (ids: number[]) => {
Modal.confirm({
title: intl.get('确认删除'),
content: intl.get('确认删除选中的用户吗'),
onOk: async () => {
try {
const { code, message: msg } = await request.delete(
`${config.apiPrefix}user-management`,
{ data: ids }
);
if (code === 200) {
message.success(msg || intl.get('删除成功'));
fetchUsers();
} else {
message.error(msg || intl.get('删除失败'));
}
} catch (error: any) {
message.error(error.message || intl.get('删除失败'));
}
},
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingUser) {
// Update user
const { code, message: msg } = await request.put(
`${config.apiPrefix}user-management`,
{ ...values, id: editingUser.id }
);
if (code === 200) {
message.success(msg || intl.get('更新成功'));
setIsModalVisible(false);
fetchUsers();
} else {
message.error(msg || intl.get('更新失败'));
}
} else {
// Create user
const { code, message: msg } = await request.post(
`${config.apiPrefix}user-management`,
values
);
if (code === 200) {
message.success(msg || intl.get('创建成功'));
setIsModalVisible(false);
fetchUsers();
} else {
message.error(msg || intl.get('创建失败'));
}
}
} catch (error: any) {
message.error(error.message || intl.get('操作失败'));
}
};
return (
<>
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
{intl.get('新增用户')}
</Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ y: height - 120 }}
pagination={false}
/>
<Modal
title={editingUser ? intl.get('编辑用户') : intl.get('新增用户')}
open={isModalVisible}
onOk={handleSubmit}
onCancel={() => setIsModalVisible(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="username"
label={intl.get('用户名')}
rules={[{ required: true, message: intl.get('请输入用户名') }]}
>
<Input placeholder={intl.get('请输入用户名')} />
</Form.Item>
<Form.Item
name="password"
label={intl.get('密码')}
rules={[
{ required: !editingUser, message: intl.get('请输入密码') },
{ min: 6, message: intl.get('密码长度至少为6位') },
]}
>
<Input.Password placeholder={intl.get('请输入密码')} />
</Form.Item>
<Form.Item
name="role"
label={intl.get('角色')}
rules={[{ required: true, message: intl.get('请选择角色') }]}
initialValue={1}
>
<Select>
<Option value={0}>{intl.get('管理员')}</Option>
<Option value={1}>{intl.get('普通用户')}</Option>
</Select>
</Form.Item>
<Form.Item
name="status"
label={intl.get('状态')}
rules={[{ required: true, message: intl.get('请选择状态') }]}
initialValue={0}
>
<Select>
<Option value={0}>{intl.get('启用')}</Option>
<Option value={1}>{intl.get('禁用')}</Option>
</Select>
</Form.Item>
</Form>
</Modal>
</>
);
};
export default UserManagement;
+10 -14
View File
@@ -12,7 +12,7 @@ import {
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import CronExpressionParser from 'cron-parser';
import cron_parser from 'cron-parser';
import isNil from 'lodash/isNil';
const { Option } = Select;
@@ -224,8 +224,8 @@ const SubscriptionModal = ({
type === 'raw'
? 'file'
: url.startsWith('http')
? 'public-repo'
: 'private-repo';
? 'public-repo'
: 'private-repo';
form.setFieldsValue({
type: _type,
@@ -378,17 +378,13 @@ const SubscriptionModal = ({
{ required: true },
{
validator: (rule, value) => {
try {
if (
scheduleType === 'interval' ||
!value ||
CronExpressionParser.parse(value).hasNext()
) {
return Promise.resolve();
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
} catch (e) {
if (
scheduleType === 'interval' ||
!value ||
cron_parser.parseExpression(value).hasNext()
) {
return Promise.resolve();
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
},
-28
View File
@@ -98,7 +98,6 @@ export default {
{ value: 'pushPlus', label: 'PushPlus' },
{ value: 'wePlusBot', label: intl.get('微加机器人') },
{ value: 'wxPusherBot', label: 'wxPusher' },
{ value: 'openiLink', label: 'OpeniLink' },
{ value: 'chat', label: intl.get('群晖chat') },
{ value: 'email', label: intl.get('邮箱') },
{ value: 'lark', label: intl.get('飞书机器人') },
@@ -388,27 +387,6 @@ export default {
required: false,
},
],
openiLink: [
{
label: 'openiLinkAppToken',
tip: intl.get(
'OpeniLink的app_token,在OpeniLink Hub后台安装App后获取,参考 https://openilink.com/docs/hub/apps',
),
required: true,
},
{
label: 'openiLinkHubUrl',
tip: intl.get(
'OpeniLink Hub地址,默认为 https://hub.openilink.com,自建Hub时填写自己的地址',
),
},
{
label: 'openiLinkContextToken',
tip: intl.get(
'OpeniLink的context_token,用于标识消息会话上下文,可从消息事件中获取',
),
},
],
lark: [
{
label: 'larkKey',
@@ -417,12 +395,6 @@ export default {
),
required: true,
},
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
},
],
email: [
{
+6 -6
View File
@@ -84,12 +84,12 @@ let _request = axios.create({
});
const apiWhiteList = [
`${config.baseUrl}api/user/login`,
`${config.baseUrl}open/auth/token`,
`${config.baseUrl}api/user/two-factor/login`,
`${config.baseUrl}api/system`,
`${config.baseUrl}api/user/init`,
`${config.baseUrl}api/user/notification/init`,
'/api/user/login',
'/open/auth/token',
'/api/user/two-factor/login',
'/api/system',
'/api/user/init',
'/api/user/notification/init',
];
_request.interceptors.request.use((_config) => {
+6 -6
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import { LANG_MAP, LOG_END_SYMBOL } from './const';
import CronExpressionParser from 'cron-parser';
import cron_parser from 'cron-parser';
import { ICrontab } from '@/pages/crontab/type';
export default function browserType() {
@@ -155,9 +155,9 @@ export default function browserType() {
shell === 'none'
? {}
: {
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
);
console.log(
@@ -333,11 +333,11 @@ export function getCommandScript(
export function parseCrontab(schedule: string): Date | null {
try {
const time = CronExpressionParser.parse(schedule);
const time = cron_parser.parseExpression(schedule);
if (time) {
return time.next().toDate();
}
} catch (error) { }
} catch (error) {}
return null;
}
+9 -5
View File
@@ -1,6 +1,10 @@
version: 2.20.2
changeLogLink: https://t.me/jiao_long/434
publishTime: 2026-03-01 1800
version: 2.19.2
changeLogLink: https://t.me/jiao_long/431
publishTime: 2025-06-27 23:59
changeLog: |
1. 修复 path 安全漏洞(重要)
1. 备份数据支持选择模块,支持清除依赖缓存
2. QLAPI 和 openapi 的 systemNotify 支持自定义通知类型和参数
3. ntfy 增加可选的认证与用户动作,感谢 https://github.com/liheji
4. 修复取消安装依赖
5. 修复环境变量过大解析报错
6. 修改服务启动方式