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 2464 additions and 3192 deletions
+24 -33
View File
@@ -9,13 +9,15 @@ on:
- "develop" - "develop"
tags: tags:
- "v*" - "v*"
schedule:
- cron: "00 20 * * *"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
code_gitlab: code_gitlab:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -30,7 +32,7 @@ jobs:
code_gitee: code_gitee:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -45,12 +47,12 @@ jobs:
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v3
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
cache: "pnpm" cache: "pnpm"
@@ -81,7 +83,7 @@ jobs:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -97,7 +99,7 @@ jobs:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -110,7 +112,6 @@ jobs:
force_update: true force_update: true
build: build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -120,21 +121,14 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v3
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
cache: "pnpm" 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 - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -160,13 +154,19 @@ jobs:
images: | images: |
${{ github.repository }} ${{ github.repository }}
ghcr.io/${{ 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: | flavor: |
latest=false latest=false
tags: | 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=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={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -208,21 +208,14 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v3
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
cache: "pnpm" 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 - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -261,9 +254,7 @@ jobs:
context: . context: .
file: ./docker/310.Dockerfile file: ./docker/310.Dockerfile
push: true push: true
tags: | tags: whyour/qinglong:python3.10
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max 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. 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 ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian 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` 镜像 `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
+35 -81
View File
@@ -3,7 +3,6 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import CronService from '../services/cron'; import CronService from '../services/cron';
import CronViewService from '../services/cronView'; import CronViewService from '../services/cronView';
import CronStatsService from '../services/cronStats';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule'; import { commonCronSchema } from '../validation/schedule';
@@ -142,63 +141,14 @@ export default (app: Router) => {
}, },
); );
route.get(
'/stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.stats();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/stats/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.trend();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/stats/top-duration',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.topDuration();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/stats/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.topCount();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
@@ -230,7 +180,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -247,10 +200,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -264,10 +217,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -287,10 +240,11 @@ export default (app: Router) => {
const data = await cronService.removeLabels( const data = await cronService.removeLabels(
req.body.ids, req.body.ids,
req.body.labels, req.body.labels,
req.user?.userId,
); );
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -307,10 +261,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -324,10 +278,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -341,10 +295,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -397,10 +351,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -414,10 +368,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -431,10 +385,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); 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(''), searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''), type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''), status: Joi.string().optional().allow(''),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
@@ -45,7 +45,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const dependenceService = Container.get(DependenceService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -82,10 +82,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const dependenceService = Container.get(DependenceService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -98,10 +98,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const dependenceService = Container.get(DependenceService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -132,10 +132,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const dependenceService = Container.get(DependenceService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -148,10 +148,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
await dependenceService.cancel(req.body); await dependenceService.cancel(req.body, req.user?.userId);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e: any) {
return next(e); 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'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
@@ -54,7 +54,7 @@ export default (app: Router) => {
if (!req.body?.length) { if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' }); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -93,10 +93,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -132,10 +132,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -149,10 +149,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -169,10 +169,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
+2
View File
@@ -11,6 +11,7 @@ import system from './system';
import subscription from './subscription'; import subscription from './subscription';
import update from './update'; import update from './update';
import health from './health'; import health from './health';
import userManagement from './userManagement';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -26,6 +27,7 @@ export default () => {
subscription(app); subscription(app);
update(app); update(app);
health(app); health(app);
userManagement(app);
return app; return app;
}; };
+151
View File
@@ -8,8 +8,12 @@ import {
readDirs, readDirs,
removeAnsi, removeAnsi,
rmPath, rmPath,
IFile,
} from '../config/util'; } from '../config/util';
import LogService from '../services/log'; import LogService from '../services/log';
import CronService from '../services/cron';
import { UserRole } from '../data/user';
import { Crontab } from '../data/cron';
const route = Router(); const route = Router();
const blacklist = ['.tmp']; const blacklist = ['.tmp'];
@@ -20,6 +24,39 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const result = await readDirs(config.logPath, config.logPath, blacklist); 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({ res.send({
code: 200, code: 200,
data: result, data: result,
@@ -45,6 +82,35 @@ export default (app: Router) => {
message: '暂无权限', 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); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) }); res.send({ code: 200, data: removeAnsi(content) });
} catch (e) { } catch (e) {
@@ -68,6 +134,35 @@ export default (app: Router) => {
message: '暂无权限', 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); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
} catch (e) { } catch (e) {
@@ -99,6 +194,34 @@ export default (app: Router) => {
message: '暂无权限', 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); await rmPath(finalPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
@@ -129,6 +252,34 @@ export default (app: Router) => {
message: '暂无权限', 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) => { return res.download(filePath, filename, (err) => {
if (err) { if (err) {
return next(err); return next(err);
+4 -6
View File
@@ -29,7 +29,7 @@ export default (app: Router) => {
celebrate({ celebrate({
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
@@ -79,7 +79,7 @@ export default (app: Router) => {
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
file: Joi.string().required(), file: Joi.string().required(),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -103,7 +103,7 @@ export default (app: Router) => {
}), }),
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -129,8 +129,7 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''), }),
}).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -176,7 +175,6 @@ export default (app: Router) => {
path, path,
`${originFilename.replace(/\//g, '')}`, `${originFilename.replace(/\//g, '')}`,
); );
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`); const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath); const fileExists = await fileExist(filePath);
if (fileExists) { if (fileExists) {
+23 -19
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser'; import cron_parser from 'cron-parser';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -16,6 +16,7 @@ export default (app: Router) => {
const data = await subscriptionService.list( const data = await subscriptionService.list(
req.query.searchValue as string, req.query.searchValue as string,
req.query.ids as string, req.query.ids as string,
req.user?.userId,
); );
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
@@ -60,10 +61,13 @@ export default (app: Router) => {
try { try {
if ( if (
!req.body.schedule || !req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext() cron_parser.parseExpression(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} else { } else {
return res.send({ code: 400, message: 'param schedule error' }); return res.send({ code: 400, message: 'param schedule error' });
@@ -83,10 +87,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -100,10 +104,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -117,10 +121,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -134,10 +138,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
@@ -193,7 +197,7 @@ export default (app: Router) => {
if ( if (
!req.body.schedule || !req.body.schedule ||
typeof req.body.schedule === 'object' || typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext() cron_parser.parseExpression(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body); const data = await subscriptionService.update(req.body);
@@ -220,10 +224,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e: any) {
return next(e); return res.send({ code: 400, message: e.message });
} }
}, },
); );
+19 -23
View File
@@ -14,7 +14,7 @@ import {
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager'; import { UserRole } from '../data/user';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -277,19 +277,17 @@ export default (app: Router) => {
res.setHeader('QL-Task-Log', `${logPath}`); res.setHeader('QL-Task-Log', `${logPath}`);
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end(); res.end();
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message); await fs.appendFile(absolutePath, message);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); 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) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
// Only admin can view system logs
if (req.user?.role !== UserRole.admin) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
await systemService.getSystemLog( await systemService.getSystemLog(
res, res,
@@ -378,6 +384,14 @@ export default (app: Router) => {
'/log', '/log',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
// Only admin can delete system logs
if (req.user?.role !== UserRole.admin) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
await systemService.deleteSystemLog(); await systemService.deleteSystemLog();
res.send({ code: 200 }); 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( route.put(
'/config/dependence-clean', '/config/dependence-clean',
celebrate({ celebrate({
+12 -3
View File
@@ -8,7 +8,8 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { isDemoEnv, getToken } from '../config/util'; import { isDemoEnv } from '../config/util';
import { UserRole } from '../data/user';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -56,8 +57,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
const token = getToken(req); await userService.logout(req.platform);
await userService.logout(req.platform, token);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -98,6 +98,7 @@ export default (app: Router) => {
username: authInfo.username, username: authInfo.username,
avatar: authInfo.avatar, avatar: authInfo.avatar,
twoFactorActivated: authInfo.twoFactorActivated, twoFactorActivated: authInfo.twoFactorActivated,
role: req.user?.role,
}, },
}); });
} catch (e) { } catch (e) {
@@ -179,6 +180,14 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { 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 userService = Container.get(UserService);
const data = await userService.getLoginLog(); const data = await userService.getLoginLog();
res.send({ code: 200, data }); 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 grpcServerService?: GrpcServerService;
private isShuttingDown = false; private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>(); private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() { constructor() {
this.app = express(); this.app = express();
@@ -54,54 +53,21 @@ class Application {
} }
private startMasterProcess() { private startMasterProcess() {
// Fork gRPC worker first and wait for it to be ready this.forkWorker('http');
const grpcWorker = this.forkWorker('grpc'); 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);
});
cluster.on('exit', (worker, code, signal) => { cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id); const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) { if (metadata) {
if (!this.isShuttingDown) { if (!this.isShuttingDown) {
Logger.error( Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code `${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`, }). Restarting...`,
); );
// If gRPC worker died, restart it and wait for it to be ready const newWorker = this.forkWorker(metadata.serviceType);
if (metadata.serviceType === 'grpc') { Logger.info(
const newGrpcWorker = this.forkWorker('grpc'); `Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`,
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})`);
}
} }
this.workerMetadataMap.delete(worker.id); this.workerMetadataMap.delete(worker.id);
@@ -111,25 +77,6 @@ class Application {
this.setupMasterShutdown(); 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 { private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType }); const worker = cluster.fork({ SERVICE_TYPE: serviceType });
@@ -169,7 +116,7 @@ class Application {
if (worker) { if (worker) {
const exitPromise = new Promise<void>((resolve) => { const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => { worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`); Logger.info(`Worker ${worker.process.pid} exited`);
resolve(); resolve();
}); });
@@ -177,7 +124,7 @@ class Application {
worker.send('shutdown'); worker.send('shutdown');
} catch (error) { } catch (error) {
Logger.warn( Logger.warn(
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`, `Failed to send shutdown to worker ${worker.process.pid}:`,
error, error,
); );
} }
@@ -192,14 +139,14 @@ class Application {
Promise.all(workerPromises), Promise.all(workerPromises),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
setTimeout(() => { setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached'); Logger.warn('Worker shutdown timeout reached');
resolve(); resolve();
}, 10000); }, 10000);
}), }),
]); ]);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error('✌️ Error during worker shutdown:', error); Logger.error('Error during worker shutdown:', error);
process.exit(1); process.exit(1);
} }
}; };
@@ -211,7 +158,7 @@ class Application {
private async startWorkerProcess() { private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE; const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) { if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType); Logger.error('Invalid SERVICE_TYPE:', serviceType);
process.exit(1); process.exit(1);
} }
@@ -226,7 +173,7 @@ class Application {
process.send?.('ready'); process.send?.('ready');
} catch (error) { } catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error); Logger.error(`${serviceType} worker failed:`, error);
process.exit(1); process.exit(1);
} }
} }
@@ -259,20 +206,9 @@ class Application {
} }
private setupWorkerShutdown(serviceType: string) { private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => { process.on('message', (msg) => {
if (msg === 'shutdown') { if (msg === 'shutdown') {
this.gracefulShutdown(serviceType); 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); process.exit(0);
} catch (error) { } catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error); Logger.error(`[${serviceType}] Error during shutdown:`, error);
process.exit(1); process.exit(1);
} }
} }
@@ -301,6 +237,6 @@ class Application {
const app = new Application(); const app = new Application();
app.start().catch((error) => { app.start().catch((error) => {
Logger.error('🙅‍♀️ Application failed to start:', error); Logger.error('Application failed to start:', error);
process.exit(1); process.exit(1);
}); });
-15
View File
@@ -64,19 +64,6 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`; 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 rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') }); const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
@@ -129,7 +116,6 @@ if (envFound.error) {
export default { export default {
...config, ...config,
jwt: config.jwt, jwt: config.jwt,
baseUrl,
rootPath, rootPath,
tmpPath, tmpPath,
dataPath, dataPath,
@@ -190,5 +176,4 @@ export default {
sshdPath, sshdPath,
systemLogPath, systemLogPath,
dependenceCachePath, 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; 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 { interface IVersion {
version: string; version: string;
changeLogLink: string; changeLogLink: string;
+3 -3
View File
@@ -22,7 +22,7 @@ export class Crontab {
task_before?: string; task_before?: string;
task_after?: string; task_after?: string;
log_name?: string; log_name?: string;
allow_multiple_instances?: 1 | 0; userId?: number;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@@ -48,7 +48,7 @@ export class Crontab {
this.task_before = options.task_before; this.task_before = options.task_before;
this.task_after = options.task_after; this.task_after = options.task_after;
this.log_name = options.log_name; 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_before: DataTypes.STRING,
task_after: DataTypes.STRING, task_after: DataTypes.STRING,
log_name: DataTypes.STRING, log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER, userId: { type: DataTypes.NUMBER, allowNull: true },
}); });
-31
View File
@@ -1,31 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export class CronLog {
id?: number;
cron_id: number;
cron_name: string;
start_time: number;
duration: number;
constructor(options: CronLog) {
this.cron_id = options.cron_id;
this.cron_name = options.cron_name;
this.start_time = options.start_time;
this.duration = options.duration;
}
}
export interface CronLogInstance extends Model<CronLog, CronLog>, CronLog {}
export const CronLogModel = sequelize.define<CronLogInstance>(
'CronLog',
{
cron_id: DataTypes.NUMBER,
cron_name: DataTypes.STRING,
start_time: DataTypes.NUMBER,
duration: DataTypes.NUMBER,
},
{
indexes: [{ fields: ['cron_id'] }, { fields: ['start_time'] }],
},
);
+3
View File
@@ -9,6 +9,7 @@ export class Dependence {
name: string; name: string;
log?: string[]; log?: string[];
remark?: string; remark?: string;
userId?: number;
constructor(options: Dependence) { constructor(options: Dependence) {
this.id = options.id; this.id = options.id;
@@ -21,6 +22,7 @@ export class Dependence {
this.name = options.name.trim(); this.name = options.name.trim();
this.log = options.log || []; this.log = options.log || [];
this.remark = options.remark || ''; this.remark = options.remark || '';
this.userId = options.userId;
} }
} }
@@ -59,5 +61,6 @@ export const DependenceModel = sequelize.define<DependenceInstance>(
status: DataTypes.NUMBER, status: DataTypes.NUMBER,
log: DataTypes.JSON, log: DataTypes.JSON,
remark: DataTypes.STRING, remark: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
}, },
); );
+3
View File
@@ -9,6 +9,7 @@ export class Env {
position?: number; position?: number;
name?: string; name?: string;
remarks?: string; remarks?: string;
userId?: number;
isPinned?: 1 | 0; isPinned?: 1 | 0;
constructor(options: Env) { constructor(options: Env) {
@@ -22,6 +23,7 @@ export class Env {
this.position = options.position; this.position = options.position;
this.name = options.name; this.name = options.name;
this.remarks = options.remarks || ''; this.remarks = options.remarks || '';
this.userId = options.userId;
this.isPinned = options.isPinned || 0; this.isPinned = options.isPinned || 0;
} }
} }
@@ -44,5 +46,6 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER, position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
isPinned: DataTypes.NUMBER, isPinned: DataTypes.NUMBER,
}); });
-1
View File
@@ -142,7 +142,6 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo { export class LarkNotification extends NotificationBaseInfo {
public larkKey = ''; public larkKey = '';
public larkSecret = '';
} }
export class NtfyNotification extends NotificationBaseInfo { export class NtfyNotification extends NotificationBaseInfo {
+3
View File
@@ -31,6 +31,7 @@ export class Subscription {
proxy?: string; proxy?: string;
autoAddCron?: 1 | 0; autoAddCron?: 1 | 0;
autoDelCron?: 1 | 0; autoDelCron?: 1 | 0;
userId?: number;
constructor(options: Subscription) { constructor(options: Subscription) {
this.id = options.id; this.id = options.id;
@@ -60,6 +61,7 @@ export class Subscription {
this.proxy = options.proxy; this.proxy = options.proxy;
this.autoAddCron = options.autoAddCron ? 1 : 0; this.autoAddCron = options.autoAddCron ? 1 : 0;
this.autoDelCron = options.autoDelCron ? 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 }, proxy: { type: DataTypes.STRING, allowNull: true },
autoAddCron: { type: DataTypes.NUMBER, allowNull: true }, autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
autoDelCron: { 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; pythonMirror?: string;
linuxMirror?: string; linuxMirror?: string;
timezone?: string; timezone?: string;
globalSshKey?: string;
} }
export interface LoginLogInfo { export interface LoginLogInfo {
@@ -49,19 +48,6 @@ export interface LoginLogInfo {
status?: LoginStatus; 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 { export interface AuthInfo {
username: string; username: string;
password: string; password: string;
@@ -72,7 +58,7 @@ export interface AuthInfo {
platform: string; platform: string;
isTwoFactorChecking: boolean; isTwoFactorChecking: boolean;
token: string; token: string;
tokens: Record<string, string | TokenInfo[]>; tokens: Record<string, string>;
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: 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,
},
});
+60 -33
View File
@@ -6,7 +6,7 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system'; import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription'; import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView'; import { CrontabViewModel } from '../data/cronView';
import { CronLogModel } from '../data/cronLog'; import { UserModel } from '../data/user';
import { sequelize } from '../data'; import { sequelize } from '../data';
export default async () => { export default async () => {
@@ -18,41 +18,68 @@ export default async () => {
await EnvModel.sync(); await EnvModel.sync();
await SubscriptionModel.sync(); await SubscriptionModel.sync();
await CrontabViewModel.sync(); await CrontabViewModel.sync();
await CronLogModel.sync(); await UserModel.sync();
// 初始化新增字段 // 初始化新增字段
const migrations = [ try {
{ await sequelize.query(
table: 'CrontabViews', 'alter table CrontabViews add column filterRelation VARCHAR(255)',
column: 'filterRelation', );
type: 'VARCHAR(255)', } catch (error) {}
}, try {
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, await sequelize.query(
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' }, 'alter table Subscriptions add column proxy VARCHAR(255)',
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, );
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, } catch (error) {}
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, try {
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, await sequelize.query('alter table CrontabViews add column type NUMBER');
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' }, } catch (error) {}
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' }, try {
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, await sequelize.query(
{ 'alter table Subscriptions add column autoAddCron NUMBER',
table: 'Crontabs', );
column: 'allow_multiple_instances', } catch (error) {}
type: 'NUMBER', try {
}, await sequelize.query(
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' }, '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) {}
for (const migration of migrations) { // Multi-user support: Add userId columns
try { try {
await sequelize.query( await sequelize.query('alter table Crontabs add column userId NUMBER');
`alter table ${migration.table} add column ${migration.column} ${migration.type}`, } catch (error) {}
); try {
} catch (error) { await sequelize.query('alter table Envs add column userId NUMBER');
// Column already exists or other error, continue } 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'); Logger.info('✌️ DB loaded');
} catch (error) { } catch (error) {
+3 -15
View File
@@ -1,9 +1,8 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
import config from '../config/index'; import config from '../config/index';
import Logger from './logger'; import { fileExist, promiseExec, rmPath } from '../config/util';
async function linkToNodeModule(src: string, dst?: string) { async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src); const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -18,18 +17,8 @@ async function linkToNodeModule(src: string, dst?: string) {
} }
async function linkCommand() { async function linkCommand() {
const homeDir = os.homedir(); const commandPath = await promiseExec('which node');
let userBinDir = path.join(homeDir, 'bin'); const commandDir = path.dirname(commandPath);
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [ const linkShell = [
{ {
src: 'update.sh', src: 'update.sh',
@@ -53,7 +42,6 @@ async function linkCommandToDir(commandDir: string) {
await fs.unlink(tmpTarget); await fs.unlink(tmpTarget);
} }
} catch (error) { } } catch (error) { }
await fs.symlink(source, tmpTarget); await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target); await fs.rename(tmpTarget, target);
} }
+29 -44
View File
@@ -9,39 +9,11 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store'; import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path'; import path from 'path';
export default ({ app }: { app: Application }) => { 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.set('trust proxy', 'loopback');
app.use(cors()); 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.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
@@ -56,7 +28,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret, secret: config.jwt.secret,
algorithms: ['HS384'], algorithms: ['HS384'],
}).unless({ }).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}), }),
); );
@@ -70,21 +42,32 @@ export default ({ app }: { app: Application }) => {
return next(); 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) => { app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase(); if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next(); return next();
} }
const headerToken = getToken(req); const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) { if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps(); const apps = await shareStore.getApps();
const doc = apps?.filter((x) => const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken), x.tokens?.find((y) => y.value === headerToken),
)?.[0]; )?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) { if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken); 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]; const key = keyMatch && keyMatch[1];
if ( if (
doc.scopes.includes(key as any) && doc.scopes.includes(key as any) &&
@@ -105,11 +88,21 @@ export default ({ app }: { app: Application }) => {
return next(); return next();
} }
const authInfo = await shareStore.getAuthInfo(); // If JWT has been successfully verified by expressjwt middleware, allow the request
if (isValidToken(authInfo, headerToken, req.platform)) { // This handles regular users whose tokens are not stored in authInfo
if (req.auth) {
return next(); 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 errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken const errorMessage = headerToken
? 'jwt malformed' ? 'jwt malformed'
@@ -119,15 +112,7 @@ export default ({ app }: { app: Application }) => {
}); });
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase(); if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
return next(); return next();
} }
const authInfo = const authInfo =
+2 -2
View File
@@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system'; import SystemService from '../services/system';
import UserService from '../services/user'; import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises'; 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 OpenService from '../services/open';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import Logger from './logger'; import Logger from './logger';
@@ -50,7 +50,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({ const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig }, where: { type: AuthDataType.authConfig },
}); });
if (!authConfig?.info || isDemoEnv()) { if (!authConfig?.info) {
let authInfo = { let authInfo = {
username: 'admin', username: 'admin',
password: 'admin', password: 'admin',
-7
View File
@@ -2,7 +2,6 @@ import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
@@ -11,7 +10,6 @@ export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService); const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
@@ -59,11 +57,6 @@ export default async () => {
} }
systemService.updateTimezone(data.info); systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
} }
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
+26 -4
View File
@@ -4,11 +4,11 @@ import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth'; import jwt from 'jsonwebtoken';
import config from '../config'; import config from '../config';
export default async ({ server }: { server: Server }) => { 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); const sockService = Container.get(SockService);
echo.on('connection', async (conn) => { echo.on('connection', async (conn) => {
@@ -16,11 +16,33 @@ export default async ({ server }: { server: Server }) => {
conn.close('404'); conn.close('404');
} }
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform)) { let isAuthenticated = false;
// 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); sockService.addClient(conn);
conn.on('data', (message) => { conn.on('data', (message) => {
-18
View File
@@ -97,18 +97,6 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; } 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 { message CronsResponse {
int32 code = 1; int32 code = 1;
repeated CronItem data = 2; repeated CronItem data = 2;
@@ -231,7 +219,6 @@ message NotificationInfo {
optional string webhookContentType = 57; optional string webhookContentType = 57;
optional string larkKey = 58; optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59; optional string ntfyUrl = 59;
optional string ntfyTopic = 60; optional string ntfyTopic = 60;
@@ -267,9 +254,4 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {} 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. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -281,26 +281,6 @@ export interface DeleteCronsRequest {
ids: number[]; 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 { export interface CronsResponse {
code: number; code: number;
data: CronItem[]; data: CronItem[];
@@ -382,7 +362,6 @@ export interface NotificationInfo {
webhookMethod?: string | undefined; webhookMethod?: string | undefined;
webhookContentType?: string | undefined; webhookContentType?: string | undefined;
larkKey?: string | undefined; larkKey?: string | undefined;
larkSecret?: string | undefined;
ntfyUrl?: string | undefined; ntfyUrl?: string | undefined;
ntfyTopic?: string | undefined; ntfyTopic?: string | undefined;
ntfyPriority?: 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 { function createBaseCronsResponse(): CronsResponse {
return { code: 0, data: [], message: undefined }; return { code: 0, data: [], message: undefined };
} }
@@ -2948,7 +2601,6 @@ function createBaseNotificationInfo(): NotificationInfo {
webhookMethod: undefined, webhookMethod: undefined,
webhookContentType: undefined, webhookContentType: undefined,
larkKey: undefined, larkKey: undefined,
larkSecret: undefined,
ntfyUrl: undefined, ntfyUrl: undefined,
ntfyTopic: undefined, ntfyTopic: undefined,
ntfyPriority: undefined, ntfyPriority: undefined,
@@ -3138,9 +2790,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
writer.uint32(466).string(message.larkKey); writer.uint32(466).string(message.larkKey);
} }
if (message.larkSecret !== undefined) {
writer.uint32(554).string(message.larkSecret);
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
writer.uint32(474).string(message.ntfyUrl); writer.uint32(474).string(message.ntfyUrl);
} }
@@ -3645,14 +3294,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.larkKey = reader.string(); message.larkKey = reader.string();
continue; continue;
} }
case 69: {
if (tag !== 554) {
break;
}
message.larkSecret = reader.string();
continue;
}
case 59: { case 59: {
if (tag !== 474) { if (tag !== 474) {
break; break;
@@ -3810,7 +3451,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined, webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined,
webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined, webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined,
larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : 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, ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined,
ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined, ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined,
ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined, ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined,
@@ -4004,9 +3644,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
obj.larkKey = message.larkKey; obj.larkKey = message.larkKey;
} }
if (message.larkSecret !== undefined) {
obj.larkSecret = message.larkSecret;
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
obj.ntfyUrl = message.ntfyUrl; obj.ntfyUrl = message.ntfyUrl;
} }
@@ -4103,7 +3740,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.webhookMethod = object.webhookMethod ?? undefined; message.webhookMethod = object.webhookMethod ?? undefined;
message.webhookContentType = object.webhookContentType ?? undefined; message.webhookContentType = object.webhookContentType ?? undefined;
message.larkKey = object.larkKey ?? undefined; message.larkKey = object.larkKey ?? undefined;
message.larkSecret = object.larkSecret ?? undefined;
message.ntfyUrl = object.ntfyUrl ?? undefined; message.ntfyUrl = object.ntfyUrl ?? undefined;
message.ntfyTopic = object.ntfyTopic ?? undefined; message.ntfyTopic = object.ntfyTopic ?? undefined;
message.ntfyPriority = object.ntfyPriority ?? undefined; message.ntfyPriority = object.ntfyPriority ?? undefined;
@@ -4340,51 +3976,6 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), 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; } as const;
export interface ApiServer extends UntypedServiceImplementation { export interface ApiServer extends UntypedServiceImplementation {
@@ -4402,11 +3993,6 @@ export interface ApiServer extends UntypedServiceImplementation {
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>; 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 { export interface ApiClient extends Client {
@@ -4620,81 +4206,6 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): 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 { 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. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/health.proto // source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
-119
View File
@@ -30,12 +30,6 @@ import {
UpdateCronRequest, UpdateCronRequest,
DeleteCronsRequest, DeleteCronsRequest,
CronResponse, CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api'; } from '../protos/api';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
@@ -329,116 +323,3 @@ export const deleteCrons = async (
callback(e); 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);
}
};
+6 -3
View File
@@ -17,11 +17,14 @@ const check = async (
return callback(null, { status: 1 }); return callback(null, { status: 1 });
} }
const qinglongErrLog = await promiseExec( const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`, `tail -n 300 ~/.pm2/logs/panel-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
); );
return callback( return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()), new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
); );
default: default:
+60 -68
View File
@@ -2,15 +2,13 @@ import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { CronLog, CronLogModel } from '../data/cronLog';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser'; import cron_parser from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
killTask, killTask,
killAllTasks,
getUniqPath, getUniqPath,
safeJSONParse, safeJSONParse,
isDemoEnv, isDemoEnv,
@@ -26,12 +24,35 @@ import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class CronService { export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { } 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) { private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron; const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) { if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
@@ -59,9 +80,7 @@ export default class CronService {
} }
let uniqPath = await getUniqPath(command, `${id}`); let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) { if (log_name) {
const normalizedLogName = log_name.startsWith('/') const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name);
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) { if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name; uniqPath = log_name;
} }
@@ -177,36 +196,29 @@ export default class CronService {
{ ...pickBy(options, (v) => v === 0 || !!v) }, { ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } }, { where: { id } },
); );
if (status === CrontabStatus.idle && last_running_time > 0) {
const cronName = (cron.name || cron.command || '').substring(0, 255);
await CronLogModel.create(
new CronLog({
cron_id: id,
cron_name: cronName,
start_time: last_execution_time,
duration: last_running_time,
}),
);
}
} }
} }
public async remove(ids: number[]) { public async remove(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.destroy({ where: { id: ids } }); await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.setCrontab(); 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 } }); 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 } }); 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 } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
await CrontabModel.update( await CrontabModel.update(
@@ -218,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 } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
await CrontabModel.update( await CrontabModel.update(
@@ -413,6 +427,7 @@ export default class CronService {
sorter: string; sorter: string;
filters: string; filters: string;
queryString: string; queryString: string;
userId?: number;
}): Promise<{ data: Crontab[]; total: number }> { }): Promise<{ data: Crontab[]; total: number }> {
const searchText = params?.searchValue; const searchText = params?.searchValue;
const page = Number(params?.page || '0'); const page = Number(params?.page || '0');
@@ -433,6 +448,7 @@ export default class CronService {
this.formatSearchText(query, searchText); this.formatSearchText(query, searchText);
this.formatFilterQuery(query, filterQuery); this.formatFilterQuery(query, filterQuery);
this.formatViewSort(order, viewQuery); this.formatViewSort(order, viewQuery);
this.addUserIdFilter(query, params?.userId);
if (sorterQuery) { if (sorterQuery) {
const { field, type } = sorterQuery; const { field, type } = sorterQuery;
@@ -465,7 +481,8 @@ export default class CronService {
return doc.get({ plain: true }); 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( await CrontabModel.update(
{ status: CrontabStatus.queued }, { status: CrontabStatus.queued },
{ where: { id: ids } }, { where: { id: ids } },
@@ -475,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 } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
// Kill all running instances of this task if (doc.pid) {
try { try {
if (doc.pid) {
await killTask(doc.pid); 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}`,
);
} }
} }
@@ -522,10 +532,7 @@ export default class CronService {
let { id, command, log_name } = cron; let { id, command, log_name } = cron;
const uniqPath = const uniqPath = log_name === '/dev/null' ? (await getUniqPath(command, `${id}`)) : log_name;
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true }); await fs.mkdir(logDirPath, { recursive: true });
@@ -544,7 +551,7 @@ export default class CronService {
{ where: { id } }, { where: { id } },
); );
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString()); await fs.appendFile(absolutePath, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.logger.info( this.logger.info(
@@ -552,7 +559,7 @@ export default class CronService {
command, command,
data.toString(), data.toString(),
); );
await logStreamManager.write(absolutePath, data.toString()); await fs.appendFile(absolutePath, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.logger.error( this.logger.error(
@@ -560,7 +567,7 @@ export default class CronService {
command, command,
err, err,
); );
await logStreamManager.write(absolutePath, JSON.stringify(err)); await fs.appendFile(absolutePath, JSON.stringify(err));
}); });
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
@@ -569,8 +576,6 @@ export default class CronService {
JSON.stringify(params), JSON.stringify(params),
code, code,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id } }, { where: { id } },
@@ -581,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 CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.setCrontab(); 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 } }); await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs const sixCron = docs
@@ -657,11 +664,7 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; command = `${TASK_PREFIX}${tab.command}`;
} }
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `; let commandVariable = `real_time=${Boolean(realTime)} log_name=${tab.log_name} 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} `;
}
if (tab.task_before) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")
@@ -703,23 +706,12 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string); await writeFileWithLock(config.crontabFile, crontab_string);
try { execSync(`crontab ${config.crontabFile}`);
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
await CrontabModel.update({ saved: true }, { where: {} }); await CrontabModel.update({ saved: true }, { where: {} });
} }
public importCrontab() { public importCrontab() {
exec('crontab -l', (error, stdout) => { exec('crontab -l', (error, stdout, stderr) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n'); const lines = stdout.split('\n');
const namePrefix = new Date().getTime(); const namePrefix = new Date().getTime();
@@ -733,7 +725,7 @@ export default class CronService {
if ( if (
command && command &&
schedule && schedule &&
CronExpressionParser.parse(schedule).hasNext() cron_parser.parseExpression(schedule).hasNext()
) { ) {
const name = namePrefix + '_' + index; const name = namePrefix + '_' + index;
-137
View File
@@ -1,137 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { CrontabModel } from '../data/cron';
import { CronLog, CronLogModel } from '../data/cronLog';
import { Op } from 'sequelize';
import dayjs from 'dayjs';
type GroupedLog = {
cron_id: number;
cron_name: string;
durations: number[];
};
@Service()
export default class CronStatsService {
constructor(@Inject('logger') private logger: winston.Logger) {}
private groupLogsByCronId(logs: CronLog[]): Record<number, GroupedLog> {
const grouped: Record<number, GroupedLog> = {};
for (const log of logs) {
if (!grouped[log.cron_id]) {
grouped[log.cron_id] = {
cron_id: log.cron_id,
cron_name: log.cron_name,
durations: [],
};
}
grouped[log.cron_id].durations.push(log.duration);
}
return grouped;
}
private avgOf(nums: number[]): number {
if (nums.length === 0) return 0;
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
}
private getTodayRange() {
return {
start: dayjs().startOf('day').unix(),
end: dayjs().endOf('day').unix(),
};
}
public async stats() {
const { start, end } = this.getTodayRange();
const [allCrons, todayLogs] = await Promise.all([
CrontabModel.findAll({ where: {} }),
CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
}),
]);
const total = allCrons.length;
const enabled = allCrons.filter((c: any) => c.isDisabled !== 1).length;
const disabled = allCrons.filter((c: any) => c.isDisabled === 1).length;
const todayCount = todayLogs.length;
const todayTotalDuration = todayLogs.reduce(
(sum: number, l: any) => sum + (l.duration || 0),
0,
);
const todayAvgDuration =
todayCount > 0 ? Math.round(todayTotalDuration / todayCount) : 0;
return {
total,
enabled,
disabled,
today: {
count: todayCount,
avgDuration: todayAvgDuration,
},
};
}
public async trend() {
const days = 7;
const result: Array<{ date: string; count: number }> = [];
for (let i = days - 1; i >= 0; i--) {
const dayStart = dayjs().subtract(i, 'day').startOf('day').unix();
const dayEnd = dayjs().subtract(i, 'day').endOf('day').unix();
const date = dayjs().subtract(i, 'day').format('MM-DD');
const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [dayStart, dayEnd] } },
});
result.push({ date, count: logs.length });
}
return result;
}
public async topDuration(limit = 5) {
const { start, end } = this.getTodayRange();
const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
});
const grouped = this.groupLogsByCronId(logs as any);
return Object.values(grouped)
.map((g) => ({
cron_id: g.cron_id,
cron_name: g.cron_name,
count: g.durations.length,
avgDuration: this.avgOf(g.durations),
maxDuration: Math.max(...g.durations),
}))
.sort((a, b) => b.avgDuration - a.avgDuration)
.slice(0, limit);
}
public async topCount(limit = 5) {
const { start, end } = this.getTodayRange();
const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
});
const grouped = this.groupLogsByCronId(logs as any);
return Object.values(grouped)
.map((g) => ({
cron_id: g.cron_id,
cron_name: g.cron_name,
count: g.durations.length,
avgDuration: this.avgOf(g.durations),
}))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
}
+35 -6
View File
@@ -30,9 +30,33 @@ export default class DependenceService {
private sockService: SockService, 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 tabs = payloads.map((x) => {
const tab = new Dependence({ ...x, status: DependenceStatus.queued }); const tab = new Dependence({ ...x, status: DependenceStatus.queued, userId });
return tab; return tab;
}); });
const docs = await this.insert(tabs); const docs = await this.insert(tabs);
@@ -65,7 +89,8 @@ export default class DependenceService {
return await this.getDb({ id: payload.id }); 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 } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
@@ -105,9 +130,11 @@ export default class DependenceService {
}, },
sort: any = [], sort: any = [],
query: any = {}, query: any = {},
userId?: number,
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = query; let condition = query;
if (type && DependenceTypes[type] !== undefined) { this.addUserIdFilter(condition, userId);
if (DependenceTypes[type]) {
condition.type = DependenceTypes[type]; condition.type = DependenceTypes[type];
} }
if (status) { if (status) {
@@ -141,7 +168,8 @@ export default class DependenceService {
return taskLimit.waitDependencyQueueDone(); 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( await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: ids } }, { where: { id: ids } },
@@ -155,7 +183,8 @@ export default class DependenceService {
return docs; 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 } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
+39 -11
View File
@@ -13,14 +13,37 @@ import {
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service() @Service()
export default class EnvService { 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[]> { private addUserIdFilter(query: any, userId?: number) {
const envs = await this.envs(); 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; let position = initPosition;
if ( if (
envs && envs &&
@@ -31,7 +54,7 @@ export default class EnvService {
} }
const tabs = payloads.map((x) => { const tabs = payloads.map((x) => {
position = position - stepPosition; position = position - stepPosition;
const tab = new Env({ ...x, position }); const tab = new Env({ ...x, position, userId });
return tab; return tab;
}); });
const docs = await this.insert(tabs); const docs = await this.insert(tabs);
@@ -62,7 +85,8 @@ export default class EnvService {
return await this.getDb({ id: payload.id }); 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 EnvModel.destroy({ where: { id: ids } });
await this.set_envs(); await this.set_envs();
} }
@@ -119,8 +143,9 @@ export default class EnvService {
return 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 }; let condition = { ...query };
this.addUserIdFilter(condition, userId);
if (searchText) { if (searchText) {
const encodeText = encodeURI(searchText); const encodeText = encodeURI(searchText);
const reg = { const reg = {
@@ -147,7 +172,7 @@ export default class EnvService {
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'], ['isPinned', 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
@@ -173,7 +198,8 @@ export default class EnvService {
return doc.get({ plain: true }); 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( await EnvModel.update(
{ status: EnvStatus.disabled }, { status: EnvStatus.disabled },
{ where: { id: ids } }, { where: { id: ids } },
@@ -181,12 +207,14 @@ export default class EnvService {
await this.set_envs(); 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 EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs(); 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 EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs(); await this.set_envs();
} }
+5 -19
View File
@@ -550,33 +550,19 @@ export default class NotificationService {
} }
private async lark() { private async lark() {
let { larkKey, larkSecret } = this.params; let { larkKey } = this.params;
if (!larkKey.startsWith('http')) { if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`; 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 { try {
const res = await httpClient.post(larkKey, { const res = await httpClient.post(larkKey, {
...this.gotOption, ...this.gotOption,
json: body, json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (res.StatusCode === 0 || res.code === 0) { if (res.StatusCode === 0 || res.code === 0) {
-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 { CrontabModel } from '../data/cron';
import CrontabService from './cron'; import CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -43,11 +42,37 @@ export default class SubscriptionService {
private crontabService: CrontabService, 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( public async list(
searchText?: string, searchText?: string,
ids?: string, ids?: string,
userId?: number,
): Promise<SubscriptionInstance[]> { ): Promise<SubscriptionInstance[]> {
let query = {}; let query: any = {};
this.addUserIdFilter(query, userId);
const subIds = JSON.parse(ids || '[]'); const subIds = JSON.parse(ids || '[]');
if (searchText) { if (searchText) {
const reg = { const reg = {
@@ -137,7 +162,7 @@ export default class SubscriptionService {
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { 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); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -145,7 +170,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (beforeStr) { if (beforeStr) {
await logStreamManager.write(absolutePath, `${beforeStr}\n`); await fs.appendFile(absolutePath, `${beforeStr}\n`);
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -164,7 +189,7 @@ export default class SubscriptionService {
let afterStr = ''; let afterStr = '';
try { try {
if (sub.sub_after) { 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); afterStr = await promiseExec(sub.sub_after);
} }
} catch (error: any) { } catch (error: any) {
@@ -172,19 +197,16 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`); await fs.appendFile(absolutePath, `${afterStr}\n`);
} }
await logStreamManager.write( await fs.appendFile(
absolutePath, absolutePath,
`\n## 执行结束... ${endTime.format( `\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`, )} 耗时 ${diff}${LOG_END_SYMBOL}`,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update( await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined }, { status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } }, { where: { id: sub.id } },
@@ -199,12 +221,12 @@ export default class SubscriptionService {
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); 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) => { onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); 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 } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false); await this.handleTask(doc.get({ plain: true }), false);
@@ -277,7 +300,7 @@ export default class SubscriptionService {
if (query?.force === true) { if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) { 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) { for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias); const filePath = join(config.scriptPath, doc.alias);
@@ -298,7 +321,8 @@ export default class SubscriptionService {
return doc.get({ plain: true }); 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( await SubscriptionModel.update(
{ status: SubscriptionStatus.queued }, { status: SubscriptionStatus.queued },
{ where: { id: ids } }, { 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 } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
if (doc.pid) { 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 } }); await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } });
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig(); 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 } }); await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } });
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig(); 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') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: '参数错误' };
+53 -150
View File
@@ -11,7 +11,6 @@ import {
SystemModelInfo, SystemModelInfo,
LoginStatus, LoginStatus,
AuthInfo, AuthInfo,
TokenInfo,
} from '../data/system'; } from '../data/system';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import NotificationService from './notify'; import NotificationService from './notify';
@@ -25,12 +24,17 @@ import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import UserManagementService from './userManagement';
import { UserRole } from '../data/user';
@Service() @Service()
export default class UserService { export default class UserService {
@Inject((type) => NotificationService) @Inject((type) => NotificationService)
private notificationService!: NotificationService; private notificationService!: NotificationService;
@Inject((type) => UserManagementService)
private userManagementService!: UserManagementService;
constructor( constructor(
@Inject('logger') private logger: winston.Logger, @Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
@@ -94,38 +98,57 @@ export default class UserService {
const { country, province, city, isp } = ipAddress; const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' '); 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 data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d'; const expiration = (isSystemAdmin && twoFactorActivated) ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, { let token = jwt.sign(
{ data, userId, role: userRole },
config.jwt.secret,
{
expiresIn: config.jwt.expiresIn || expiration, expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384', algorithm: 'HS384',
}); });
const tokenInfo: TokenInfo = { // Only update authInfo for system admin
value: token, if (isSystemAdmin) {
timestamp, await this.updateAuthInfo(content, {
ip, token,
address, tokens: {
platform: req.platform, ...tokens,
}; [req.platform]: token,
},
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
}
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,
});
this.notificationService.notify( this.notificationService.notify(
'登录通知', '登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${ `你于${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> { public async logout(platform: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo(); 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, { await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token, token: '',
tokens: updatedTokens, 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>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info; const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo(); 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 taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { ICron } from '../protos/cron'; 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> { export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => { return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => { 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( Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({ `[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron, ...cron,
+5
View File
@@ -6,6 +6,11 @@ declare global {
namespace Express { namespace Express {
interface Request { interface Request {
platform: 'desktop' | 'mobile'; platform: 'desktop' | 'mobile';
user?: {
userId?: number;
role?: number;
};
auth?: any;
} }
} }
} }
+3 -8
View File
@@ -1,5 +1,5 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser'; import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path'; import path from 'path';
import config from '../config'; import config from '../config';
@@ -13,7 +13,7 @@ const validateSchedule = (value: string, helpers: any) => {
} }
try { try {
if (CronExpressionParser.parse(value).hasNext()) { if (cron_parser.parseExpression(value).hasNext()) {
return value; return value;
} }
} catch (e) { } catch (e) {
@@ -64,11 +64,7 @@ export const commonCronSchema = {
return value; return value;
} }
if ( if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base'); return helpers.error('string.pattern.base');
} }
if (value.length > 100) { if (value.length > 100) {
@@ -81,5 +77,4 @@ export const commonCronSchema = {
'string.max': '日志名称不能超过100个字符', 'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', '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 \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
HOME=/root
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 \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \ 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 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} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ 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"] 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 \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
HOME=/root
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 \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \ 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 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} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ 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"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+7 -24
View File
@@ -2,53 +2,36 @@
dir_shell=/ql/shell dir_shell=/ql/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/env.sh
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
log_with_style() { log_with_style() {
local level="$1" local level="$1"
local message="$2" local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
} }
# Fix DNS resolution issues in Alpine Linux log_with_style "INFO" "🚀 1. 检测配置文件..."
# 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
import_config "$@" import_config "$@"
fix_config fix_config
# Try to initialize PM2, but don't fail if it doesn't work pm2 l &>/dev/null
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..." log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2 reload_pm2
if [[ $AutoStartBot == true ]]; then 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 & nohup ql bot >$dir_log/bot.log 2>&1 &
fi fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >$dir_log/extra.log 2>&1 &
fi fi
log_with_style "SUCCESS" "🎉 容器启动成功!" log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null 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, "private": true,
"packageManager": "pnpm@8.3.1",
"scripts": { "scripts": {
"start": "concurrently -n w: npm:start:*", "start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts", "start:back": "nodemon ./back/app.ts",
@@ -55,14 +54,18 @@
} }
}, },
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.10.0",
"@grpc/grpc-js": "^1.14.0", "@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1", "@otplib/preset-default": "^12.0.1",
"bcrypt": "^6.0.0",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^5.4.0", "cron-parser": "^4.9.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.6", "dotenv": "^16.4.6",
@@ -70,51 +73,49 @@
"express-jwt": "^8.4.1", "express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3", "express-urlrewrite": "^2.0.3",
"undici": "^7.9.0", "helmet": "^8.1.0",
"hpagent": "^1.2.0", "hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3", "http-proxy-middleware": "^3.0.3",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"ip2region": "2.3.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"multer": "2.1.1", "multer": "1.4.5-lts.1",
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"nodemailer": "^8.0.1", "nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0", "proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"request-ip": "3.3.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"undici": "^7.9.0",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.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"
}, },
"devDependencies": { "devDependencies": {
"moment": "2.30.1",
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22", "@ant-design/pro-layout": "6.38.22",
"@codemirror/view": "^6.34.1",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@monaco-editor/react": "4.2.1", "@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2", "@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/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2", "@types/cross-spawn": "^6.0.2",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4", "@types/express-jwt": "^6.0.4",
"@types/file-saver": "2.0.2", "@types/file-saver": "2.0.2",
"@types/helmet": "^4.0.0",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8", "@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185", "@types/lodash": "^4.14.185",
@@ -122,17 +123,17 @@
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2", "@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/qrcode.react": "^1.0.2", "@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20", "@types/react": "^18.0.20",
"@types/react-copy-to-clipboard": "^5.0.4", "@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/request-ip": "0.0.41",
"@types/serve-handler": "^6.1.1", "@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33", "@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1", "@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4", "@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/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9", "@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4", "@umijs/max": "^4.4.4",
@@ -144,9 +145,9 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"compression-webpack-plugin": "9.2.0", "compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"react-hotkeys-hook": "^4.6.1",
"file-saver": "2.0.2", "file-saver": "2.0.2",
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"moment": "2.30.1",
"monaco-editor": "0.33.0", "monaco-editor": "0.33.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^2.5.1", "prettier": "^2.5.1",
@@ -162,7 +163,9 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-intl-universal": "^2.12.0", "react-intl-universal": "^2.12.0",
"react-router-dom": "6.26.1",
"react-split-pane": "^0.1.92", "react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0", "sockjs-client": "^1.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -170,8 +173,6 @@
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "5.2.2", "typescript": "5.2.2",
"vh-check": "^2.0.5", "vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0", "virtualizedtableforantd4": "1.3.0"
"@types/compression": "^1.7.2",
"@types/helmet": "^4.0.0"
} }
} }
+288 -578
View File
File diff suppressed because it is too large Load Diff
+5 -37
View File
@@ -52,7 +52,6 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg // 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg // 群:http://127.0.0.1/send_group_msg
@@ -482,13 +481,9 @@ function tgBotNotify(text, desp) {
timeout, timeout,
}; };
if (TG_PROXY_HOST && TG_PROXY_PORT) { 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; let agent;
agent = new ProxyAgent({ agent = new ProxyAgent({
uri: `http://${proxyHost}:${TG_PROXY_PORT}`, uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
}); });
options.dispatcher = agent; options.dispatcher = agent;
} }
@@ -994,29 +989,11 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) { function fsBotNotify(text, desp) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { FSKEY, FSSECRET } = push_config; const { FSKEY } = push_config;
if (FSKEY) { 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 = { const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`, url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: body, json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } },
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -1285,15 +1262,7 @@ function ntfyNotify(text, desp) {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const { const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
NTFY_URL,
NTFY_TOPIC,
NTFY_PRIORITY,
NTFY_TOKEN,
NTFY_USERNAME,
NTFY_PASSWORD,
NTFY_ACTIONS,
} = push_config;
if (NTFY_TOPIC) { if (NTFY_TOPIC) {
const options = { const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`, url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1308,8 +1277,7 @@ function ntfyNotify(text, desp) {
if (NTFY_TOKEN) { if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) { } else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
`Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
} }
if (NTFY_ACTIONS) { if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
-15
View File
@@ -49,7 +49,6 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY 'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp 'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg # 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -234,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")}' 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}"}} 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() response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
-28
View File
@@ -12,32 +12,4 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', 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'); console.log('test desc');
+8 -8
View File
@@ -41,7 +41,7 @@ add_cron_api() {
fi fi
local api=$( 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 "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
@@ -71,7 +71,7 @@ update_cron_api() {
fi fi
local api=$( 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' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -98,7 +98,7 @@ update_cron_command_api() {
fi fi
local api=$( 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' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -118,7 +118,7 @@ del_cron_api() {
local ids="$1" local ids="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( 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' \ -X 'DELETE' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -143,7 +143,7 @@ update_cron() {
local runningTime="${6:-0}" local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( 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' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -165,7 +165,7 @@ notify_api() {
local content="$2" local content="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( 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' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -185,7 +185,7 @@ find_cron_api() {
local params="$1" local params="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( 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 "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--compressed --compressed
@@ -204,7 +204,7 @@ update_auth_config() {
local tip="$2" local tip="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( 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' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
+3 -3
View File
@@ -31,7 +31,7 @@ pm2_log() {
} }
check_ql() { 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" echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n" echo -e "=====> 面板服务启动正常\n"
@@ -42,10 +42,10 @@ check_pm2() {
pm2_log pm2_log
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( 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 '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 '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' \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed --compressed
) )
+1 -6
View File
@@ -5,7 +5,7 @@ const { join } = require('path');
class GrpcClient { class GrpcClient {
static #config = { static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), 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: { protoOptions: {
keepCase: true, keepCase: true,
longs: String, longs: String,
@@ -33,11 +33,6 @@ class GrpcClient {
'createCron', 'createCron',
'updateCron', 'updateCron',
'deleteCrons', 'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
]; ];
#client; #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_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=( link_name=(
task task
ql ql
) )
original_name=(
task.sh
update.sh
)
init_env() { init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null) local pnpm_global_path=$(pnpm root -g 2>/dev/null)
@@ -79,20 +84,15 @@ init_env() {
export PYTHONUNBUFFERED=1 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() { import_config() {
[[ -f $file_config_user ]] && . $file_config_user [[ -f $file_config_user ]] && . $file_config_user
load_ql_envs ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
command_timeout_time=${CommandTimeoutTime:-""} command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"} file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""} proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}" default_cron="${DefaultCronRule}"
@@ -272,35 +272,14 @@ random_range() {
delete_pm2() { delete_pm2() {
cd $dir_root cd $dir_root
# Try to delete PM2 processes, but don't fail if PM2 is not available pm2 delete ecosystem.config.js
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
} }
reload_pm2() { reload_pm2() {
cd $dir_root cd $dir_root
restore_env_vars restore_env_vars
pm2 flush &>/dev/null
# Try to start PM2, but handle failures gracefully pm2 startOrGracefulReload ecosystem.config.js --update-env
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
} }
diff_time() { diff_time() {
-1
View File
@@ -3,7 +3,6 @@
dir_shell=$QL_DIR/shell dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/api.sh . $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh . $dir_shell/env.sh
send_mark=$dir_shell/send_mark send_mark=$dir_shell/send_mark
+11 -16
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import React, { useRef, useState, useEffect } from 'react'; 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 { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,21 +10,16 @@ const Copy = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyIdRef = useRef<number>(); const copyIdRef = useRef<number>();
const handleCopy = (text: string, result: boolean) => { const copyText = (e?: React.MouseEvent) => {
if (result) {
setCopied(true);
message.success(intl.get('复制成功'));
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}
};
const handleClick = (e?: React.MouseEvent) => {
e?.preventDefault(); e?.preventDefault();
e?.stopPropagation(); e?.stopPropagation();
setCopied(true);
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}; };
const cleanCopyId = () => { const cleanCopyId = () => {
@@ -32,8 +27,8 @@ const Copy = ({ text }: { text: string }) => {
}; };
return ( return (
<Link onClick={handleClick} style={{ marginLeft: 4 }}> <Link onClick={copyText} style={{ marginLeft: 1 }}>
<CopyToClipboard text={text} onCopy={handleCopy}> <CopyToClipboard text={text}>
<Tooltip <Tooltip
key="copy" key="copy"
title={copied ? intl.get('复制成功') : intl.get('复制')} title={copied ? intl.get('复制成功') : intl.get('复制')}
+1 -7
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import IconFont from '@/components/iconfont'; import IconFont from '@/components/iconfont';
import { BasicLayoutProps } from '@ant-design/pro-layout'; import { BasicLayoutProps } from '@ant-design/pro-layout';
@@ -30,12 +30,6 @@ export default {
icon: <IconFont type="ql-icon-crontab" />, icon: <IconFont type="ql-icon-crontab" />,
component: '@/pages/crontab/index', component: '@/pages/crontab/index',
}, },
{
path: '/statistics',
name: intl.get('统计面板'),
icon: <BarChartOutlined />,
component: '@/pages/statistics/index',
},
{ {
path: '/subscription', path: '/subscription',
name: intl.get('订阅管理'), name: intl.get('订阅管理'),
+17 -44
View File
@@ -18,25 +18,6 @@
"青龙": "Qinglong", "青龙": "Qinglong",
"返回首页": "Return to Home", "返回首页": "Return to Home",
"保存": "Save", "保存": "Save",
"统计面板": "Statistics",
"总体概览": "Overview",
"总任务数量": "Total Tasks",
"启用任务数": "Enabled Tasks",
"禁用任务数": "Disabled Tasks",
"今日总执行次数": "Today's Executions",
"今日平均耗时(秒)": "Today's Avg Duration (s)",
"近7日执行趋势": "7-Day Execution Trend",
"今日平均耗时 Top 5": "Top 5 Slowest Today",
"今日执行次数 Top 5": "Top 5 Most Frequent Today",
"排名": "Rank",
"任务名称": "Task Name",
"平均耗时(秒)": "Avg Duration (s)",
"最长单次(秒)": "Max Duration (s)",
"今日执行次数": "Today's Count",
"今日暂无执行记录": "No execution records today",
"暂无数据": "No data",
"次": "times",
"刷新": "Refresh",
"日志": "Log", "日志": "Log",
"脚本": "Script", "脚本": "Script",
"确认保存文件": "Confirm to Save File", "确认保存文件": "Confirm to Save File",
@@ -123,7 +104,7 @@
"序号": "Number", "序号": "Number",
"备注": "Remarks", "备注": "Remarks",
"更新时间": "Update Time", "更新时间": "Update Time",
"创建时间": "Created Time", "创建时间": "Creation Time",
"确认删除依赖": "Confirm to delete the dependency", "确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall", "确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install", "确认取消安装": "Confirm to cancel install",
@@ -271,7 +252,7 @@
"登录日志": "Login Logs", "登录日志": "Login Logs",
"其他设置": "Other Settings", "其他设置": "Other Settings",
"关于": "About", "关于": "About",
"成功": "Successfully", "成功": "Success",
"失败": "Failure", "失败": "Failure",
"登录时间": "Login Time", "登录时间": "Login Time",
"登录地址": "Login Address", "登录地址": "Login Address",
@@ -408,7 +389,6 @@
"消息接收人": "message recipient", "消息接收人": "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.", "调用版本;专业版填写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", "飞书群组机器人: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", "邮箱服务名称,比如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", "邮箱地址": "Email Address",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
@@ -551,26 +531,19 @@
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null", "请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters", "日志名称不能超过100个字符": "Log name cannot exceed 100 characters",
"未启用": "Not enabled", "用户管理": "User Management",
"默认为 CPU 个数": "Default is the number of CPUs", "用户名": "Username",
"Minimum is 4": "Minimum is 4", "密码": "Password",
"实例模式": "Instance Mode", "角色": "Role",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously", "管理员": "Admin",
"请选择实例模式": "Please select instance mode", "普通用户": "User",
"单实例": "Single Instance", "启用": "Enabled",
"多实例": "Multi-Instance", "禁用": "Disabled",
"SSH密钥": "SSH Keys", "创建时间": "Created At",
"别名": "Alias", "确认删除选中的用户吗": "Are you sure to delete selected users?",
"编辑SSH密钥": "Edit SSH Key", "请输入用户名": "Please enter username",
"创建SSH密钥": "Create SSH Key", "请输入密码": "Please enter password",
"更新SSH密钥成功": "SSH key updated successfully", "密码长度至少为6位": "Password must be at least 6 characters",
"创建SSH密钥成功": "SSH key created successfully", "新增用户": "Add User",
"请输入SSH密钥别名": "Please enter SSH key alias", "编辑用户": "Edit User"
"请输入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"
} }
+15 -42
View File
@@ -18,25 +18,6 @@
"青龙": "青龙", "青龙": "青龙",
"返回首页": "返回首页", "返回首页": "返回首页",
"保存": "保存", "保存": "保存",
"统计面板": "统计面板",
"总体概览": "总体概览",
"总任务数量": "总任务数量",
"启用任务数": "启用任务数",
"禁用任务数": "禁用任务数",
"今日总执行次数": "今日总执行次数",
"今日平均耗时(秒)": "今日平均耗时(秒)",
"近7日执行趋势": "近7日执行趋势",
"今日平均耗时 Top 5": "今日平均耗时 Top 5",
"今日执行次数 Top 5": "今日执行次数 Top 5",
"排名": "排名",
"任务名称": "任务名称",
"平均耗时(秒)": "平均耗时(秒)",
"最长单次(秒)": "最长单次(秒)",
"今日执行次数": "今日执行次数",
"今日暂无执行记录": "今日暂无执行记录",
"暂无数据": "暂无数据",
"次": "次",
"刷新": "刷新",
"日志": "日志", "日志": "日志",
"脚本": "脚本", "脚本": "脚本",
"确认保存文件": "确认保存文件", "确认保存文件": "确认保存文件",
@@ -408,7 +389,6 @@
"消息接收人": "消息接收人", "消息接收人": "消息接收人",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版", "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973", "飞书群组机器人: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", "邮箱服务名称,比如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 登录密码,也可能为特殊口令,视具体邮件服务商说明而定", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定",
@@ -551,26 +531,19 @@
"请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符",
"日志名称不能超过100个字符": "日志名称不能超过100个字符", "日志名称不能超过100个字符": "日志名称不能超过100个字符",
"未启用": "未启用", "用户管理": "用户管理",
"默认为 CPU 个数": "默认为 CPU 个数", "用户名": "用户名",
"最小是 4": "最小是 4", "密码": "密码",
"实例模式": "实例模式", "角色": "角色",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例", "管理员": "管理员",
"请选择实例模式": "请选择实例模式", "普通用户": "普通用户",
"单实例": "单实例", "启用": "启用",
"多实例": "多实例", "禁用": "禁用",
"SSH密钥": "SSH密钥", "创建时间": "创建时间",
"别名": "别名", "确认删除选中的用户吗": "确认删除选中的用户吗",
"编辑SSH密钥": "编辑SSH密钥", "请输入用户名": "请输入用户名",
"创建SSH密钥": "创建SSH密钥", "请输入密码": "请输入密码",
"更新SSH密钥成功": "更新SSH密钥成功", "密码长度至少为6位": "密码长度至少为6位",
"创建SSH密钥成功": "创建SSH密钥成功", "新增用户": "新增用户",
"请输入SSH密钥别名": "请输入SSH密钥别名", "编辑用户": "编辑用户"
"请输入SSH私钥": "请输入SSH私钥",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)",
"确认删除SSH密钥": "确认删除SSH密钥",
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
} }
+17 -32
View File
@@ -66,7 +66,6 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => { const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>(); const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [ const columns: ColumnProps<ICrontab>[] = [
{ {
title: intl.get('名称'), title: intl.get('名称'),
@@ -248,8 +247,8 @@ const Crontab = () => {
> >
{record.last_execution_time {record.last_execution_time
? dayjs(record.last_execution_time * 1000).format( ? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
) )
: '-'} : '-'}
</span> </span>
); );
@@ -273,12 +272,6 @@ const Crontab = () => {
title: intl.get('关联订阅'), title: intl.get('关联订阅'),
width: 185, width: 185,
render: (text, record: any) => record?.subscription?.name || '-', 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('操作'), title: intl.get('操作'),
@@ -368,10 +361,11 @@ const Crontab = () => {
const getCrons = () => { const getCrons = () => {
setLoading(true); setLoading(true);
const { page, size, sorter, filters } = pageConf; const { page, size, sorter, filters } = pageConf;
let url = `${config.apiPrefix let url = `${
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( config.apiPrefix
filters, }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
)}`; filters,
)}`;
if (sorter && sorter.column && sorter.order) { if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({ url += `&sorter=${JSON.stringify({
field: sorter.column.key, field: sorter.column.key,
@@ -529,8 +523,9 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => { const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') title: `确认${
}`, record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -545,7 +540,8 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' `${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`, }`,
[record.id], [record.id],
) )
@@ -569,8 +565,9 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => { const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') title: `确认${
}`, record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -585,7 +582,8 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' `${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`, }`,
[record.id], [record.id],
) )
@@ -801,20 +799,8 @@ const Crontab = () => {
} }
}, [viewConf, enabledCronViews]); }, [viewConf, enabledCronViews]);
const getAllSubscriptions = () => {
request
.get(`${config.apiPrefix}subscriptions`)
.then(({ code, data }) => {
if (code === 200) {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
};
useEffect(() => { useEffect(() => {
getCronViews(); getCronViews();
getAllSubscriptions();
}, []); }, []);
const viewAction = (key: string) => { const viewAction = (key: string) => {
@@ -1028,7 +1014,6 @@ const Crontab = () => {
)} )}
<Table <Table
columns={columns} columns={columns}
sortDirections={['descend', 'ascend']}
pagination={{ pagination={{
current: pageConf.page, current: pageConf.page,
pageSize: pageConf.size, pageSize: pageConf.size,
+5 -25
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd'; 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 { useEffect, useState } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const'; import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,14 +91,10 @@ const CronModal = ({
{ required: true }, { required: true },
{ {
validator: (_, value) => { validator: (_, value) => {
try { if (!value || cronParse.parseExpression(value).hasNext()) {
if (!value || CronExpressionParser.parse(value).hasNext()) { return Promise.resolve();
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
} catch (e) {
return Promise.reject(intl.get('Cron表达式格式有误'));
} }
return Promise.reject(intl.get('Cron表达式格式有误'));
}, },
}, },
]} ]}
@@ -184,18 +180,6 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}> <Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup /> <EditableTagGroup />
</Form.Item> </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 <Form.Item
name="log_name" name="log_name"
label={intl.get('日志名称')} label={intl.get('日志名称')}
@@ -210,11 +194,7 @@ const CronModal = ({
if (value.length > 100) { if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符')); return Promise.reject(intl.get('日志名称不能超过100个字符'));
} }
if ( if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return Promise.reject( return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'), intl.get('日志名称只能包含字母、数字、下划线和连字符'),
); );
-1
View File
@@ -37,7 +37,6 @@ export interface ICrontab {
nextRunTime: Date; nextRunTime: Date;
sub_id: number; sub_id: number;
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
} }
export enum ScheduleType { export enum ScheduleType {
+1 -1
View File
@@ -16,7 +16,7 @@ const SaveModal = ({
const handleOk = async (values: any) => { const handleOk = async (values: any) => {
setLoading(true); setLoading(true);
const payload = { ...values, originFilename: file.title, content: file.content }; const payload = { ...file, ...values, originFilename: file.title };
request request
.post(`${config.apiPrefix}scripts`, payload) .post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => { .then(({ code, data }) => {
+24 -11
View File
@@ -35,6 +35,7 @@ import './index.less';
import useResizeObserver from '@react-hook/resize-observer'; import useResizeObserver from '@react-hook/resize-observer';
import SystemLog from './systemLog'; import SystemLog from './systemLog';
import Dependence from './dependence'; import Dependence from './dependence';
import UserManagement from './userManagement';
const { Text } = Typography; const { Text } = Typography;
const isDemoEnv = window.__ENV__DeployEnv === 'demo'; const isDemoEnv = window.__ENV__DeployEnv === 'demo';
@@ -49,7 +50,6 @@ const Setting = () => {
reloadTheme, reloadTheme,
systemInfo, systemInfo,
} = useOutletContext<SharedContext>(); } = useOutletContext<SharedContext>();
console.log('user',user)
const columns = [ const columns = [
{ {
title: intl.get('名称'), title: intl.get('名称'),
@@ -334,16 +334,29 @@ const Setting = () => {
label: intl.get('通知设置'), label: intl.get('通知设置'),
children: <NotificationSetting data={notificationInfo} />, children: <NotificationSetting data={notificationInfo} />,
}, },
{ ...(user?.role === 0
key: 'syslog', ? [
label: intl.get('系统日志'), {
children: <SystemLog height={height} theme={theme} />, key: 'syslog',
}, label: intl.get('系统日志'),
{ children: <SystemLog height={height} theme={theme} />,
key: 'login', },
label: intl.get('登录日志'), {
children: <LoginLog height={height} data={loginLogData} />, 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', key: 'dependence',
label: intl.get('依赖设置'), label: intl.get('依赖设置'),
+1 -31
View File
@@ -30,7 +30,6 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency', 'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency', 'cron-concurrency': 'cronConcurrency',
timezone: 'timezone', timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
}; };
const exportModules = [ const exportModules = [
@@ -55,7 +54,6 @@ const Other = ({
logRemoveFrequency?: number | null; logRemoveFrequency?: number | null;
cronConcurrency?: number | null; cronConcurrency?: number | null;
timezone?: string | null; timezone?: string | null;
globalSshKey?: string | null;
}>(); }>();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
@@ -242,7 +240,6 @@ const Other = ({
addonBefore={intl.get('每')} addonBefore={intl.get('每')}
addonAfter={intl.get('天')} addonAfter={intl.get('天')}
style={{ width: 180 }} style={{ width: 180 }}
placeholder={intl.get('未启用')}
min={0} min={0}
value={systemConfig?.logRemoveFrequency} value={systemConfig?.logRemoveFrequency}
onChange={(value) => { onChange={(value) => {
@@ -264,9 +261,8 @@ const Other = ({
<Input.Group compact> <Input.Group compact>
<InputNumber <InputNumber
style={{ width: 180 }} style={{ width: 180 }}
min={4} min={1}
value={systemConfig?.cronConcurrency} value={systemConfig?.cronConcurrency}
placeholder={intl.get('默认为 CPU 个数')}
onChange={(value) => { onChange={(value) => {
setSystemConfig({ ...systemConfig, cronConcurrency: value }); setSystemConfig({ ...systemConfig, cronConcurrency: value });
}} }}
@@ -310,32 +306,6 @@ const Other = ({
</Button> </Button>
</Input.Group> </Input.Group>
</Form.Item> </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"> <Form.Item label={intl.get('语言')} name="lang">
<Select <Select
defaultValue={localStorage.getItem('lang') || ''} 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;
-17
View File
@@ -1,17 +0,0 @@
.stats-section {
margin-bottom: 16px;
}
.trend-chart-wrapper {
width: 100%;
overflow: hidden;
}
.trend-chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
font-size: 14px;
}
-402
View File
@@ -1,402 +0,0 @@
import { SharedContext } from '@/layouts';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max';
import {
Button,
Card,
Col,
Row,
Statistic,
Table,
Tooltip,
Typography,
} from 'antd';
import { ColumnProps } from 'antd/lib/table';
import React, { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import './index.less';
const { Title } = Typography;
interface StatsData {
total: number;
enabled: number;
disabled: number;
today: {
count: number;
avgDuration: number;
};
}
interface TrendItem {
date: string;
count: number;
}
interface TopDurationItem {
cron_id: number;
cron_name: string;
count: number;
avgDuration: number;
maxDuration: number;
}
interface TopCountItem {
cron_id: number;
cron_name: string;
count: number;
avgDuration: number;
}
const TrendChart = ({ data }: { data: TrendItem[] }) => {
if (!data || data.length === 0) {
return (
<div className="trend-chart-empty">
{intl.get('暂无数据')}
</div>
);
}
const width = 600;
const height = 200;
const paddingLeft = 40;
const paddingRight = 20;
const paddingTop = 20;
const paddingBottom = 40;
const chartWidth = width - paddingLeft - paddingRight;
const chartHeight = height - paddingTop - paddingBottom;
const maxCount = Math.max(...data.map((d) => d.count), 1);
const points = data.map((d, i) => ({
x: paddingLeft + (i / Math.max(data.length - 1, 1)) * chartWidth,
y: paddingTop + chartHeight - (d.count / maxCount) * chartHeight,
...d,
}));
const pathD = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(' ');
const areaD =
pathD +
` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)}` +
` L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
const yTicks = [0, Math.ceil(maxCount / 2), maxCount];
return (
<div className="trend-chart-wrapper">
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
style={{ width: '100%', height: 200 }}
>
{/* Grid lines */}
{yTicks.map((tick) => {
const y =
paddingTop + chartHeight - (tick / maxCount) * chartHeight;
return (
<g key={tick}>
<line
x1={paddingLeft}
y1={y}
x2={paddingLeft + chartWidth}
y2={y}
stroke="#f0f0f0"
strokeWidth={1}
/>
<text
x={paddingLeft - 6}
y={y + 4}
textAnchor="end"
fontSize={10}
fill="#999"
>
{tick}
</text>
</g>
);
})}
{/* Area fill */}
<path d={areaD} fill="rgba(24, 144, 255, 0.1)" />
{/* Line */}
<path
d={pathD}
fill="none"
stroke="#1890ff"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Points */}
{points.map((p, i) => (
<Tooltip
key={i}
title={`${p.date}: ${p.count} ${intl.get('次')}`}
>
<circle
cx={p.x}
cy={p.y}
r={4}
fill="#1890ff"
stroke="#fff"
strokeWidth={2}
style={{ cursor: 'pointer' }}
/>
</Tooltip>
))}
{/* X axis labels */}
{points.map((p, i) => (
<text
key={i}
x={p.x}
y={height - 8}
textAnchor="middle"
fontSize={10}
fill="#999"
>
{p.date}
</text>
))}
{/* Axes */}
<line
x1={paddingLeft}
y1={paddingTop}
x2={paddingLeft}
y2={paddingTop + chartHeight}
stroke="#e8e8e8"
strokeWidth={1}
/>
<line
x1={paddingLeft}
y1={paddingTop + chartHeight}
x2={paddingLeft + chartWidth}
y2={paddingTop + chartHeight}
stroke="#e8e8e8"
strokeWidth={1}
/>
</svg>
</div>
);
};
const Statistics = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const [stats, setStats] = useState<StatsData | null>(null);
const [trend, setTrend] = useState<TrendItem[]>([]);
const [topDuration, setTopDuration] = useState<TopDurationItem[]>([]);
const [topCount, setTopCount] = useState<TopCountItem[]>([]);
const [loading, setLoading] = useState(true);
const loadAll = async () => {
setLoading(true);
try {
const [
statsRes,
trendRes,
topDurationRes,
topCountRes,
] = await Promise.all([
request.get(`${config.apiPrefix}crons/stats`),
request.get(`${config.apiPrefix}crons/stats/trend`),
request.get(`${config.apiPrefix}crons/stats/top-duration`),
request.get(`${config.apiPrefix}crons/stats/top-count`),
]);
if (statsRes.code === 200) setStats(statsRes.data);
if (trendRes.code === 200) setTrend(trendRes.data);
if (topDurationRes.code === 200) setTopDuration(topDurationRes.data);
if (topCountRes.code === 200) setTopCount(topCountRes.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAll();
}, []);
const topDurationColumns: ColumnProps<TopDurationItem>[] = [
{
title: intl.get('排名'),
key: 'rank',
width: 60,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: intl.get('任务名称'),
dataIndex: 'cron_name',
key: 'cron_name',
ellipsis: true,
},
{
title: intl.get('平均耗时(秒)'),
dataIndex: 'avgDuration',
key: 'avgDuration',
width: 120,
render: (v: number) => `${v}s`,
},
{
title: intl.get('最长单次(秒)'),
dataIndex: 'maxDuration',
key: 'maxDuration',
width: 120,
render: (v: number) => `${v}s`,
},
];
const topCountColumns: ColumnProps<TopCountItem>[] = [
{
title: intl.get('排名'),
key: 'rank',
width: 60,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: intl.get('任务名称'),
dataIndex: 'cron_name',
key: 'cron_name',
ellipsis: true,
},
{
title: intl.get('今日执行次数'),
dataIndex: 'count',
key: 'count',
width: 120,
},
{
title: intl.get('平均耗时(秒)'),
dataIndex: 'avgDuration',
key: 'avgDuration',
width: 120,
render: (v: number) => `${v}s`,
},
];
return (
<PageContainer
header={{
style: headerStyle,
}}
title={
<span>
<BarChartOutlined style={{ marginRight: 8 }} />
{intl.get('统计面板')}
</span>
}
extra={[
<Button
key="refresh"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadAll}
>
{intl.get('刷新')}
</Button>,
]}
>
{/* Section 1: Overview Cards */}
<Card
className="stats-section"
title={intl.get('总体概览')}
loading={loading}
>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('总任务数量')}
value={stats?.total ?? '-'}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('启用任务数')}
value={stats?.enabled ?? '-'}
valueStyle={{ color: '#52c41a' }}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('禁用任务数')}
value={stats?.disabled ?? '-'}
valueStyle={{ color: '#d9d9d9' }}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('今日总执行次数')}
value={stats?.today?.count ?? '-'}
valueStyle={{ color: '#1890ff' }}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('今日平均耗时(秒)')}
value={stats?.today?.avgDuration ?? '-'}
suffix="s"
valueStyle={{ color: '#faad14' }}
/>
</Col>
</Row>
</Card>
{/* Section 2: 7-day Trend */}
<Card
className="stats-section"
title={intl.get('近7日执行趋势')}
loading={loading}
>
<TrendChart data={trend} />
</Card>
{/* Section 3 & 4: Top Tables */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card
className="stats-section"
title={intl.get('今日平均耗时 Top 5')}
loading={loading}
>
<Table
dataSource={topDuration}
columns={topDurationColumns}
rowKey="cron_id"
pagination={false}
size="small"
locale={{ emptyText: intl.get('今日暂无执行记录') }}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
className="stats-section"
title={intl.get('今日执行次数 Top 5')}
loading={loading}
>
<Table
dataSource={topCount}
columns={topCountColumns}
rowKey="cron_id"
pagination={false}
size="small"
locale={{ emptyText: intl.get('今日暂无执行记录') }}
/>
</Card>
</Col>
</Row>
</PageContainer>
);
};
export default Statistics;
+10 -14
View File
@@ -12,7 +12,7 @@ import {
} from 'antd'; } from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import CronExpressionParser from 'cron-parser'; import cron_parser from 'cron-parser';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
const { Option } = Select; const { Option } = Select;
@@ -224,8 +224,8 @@ const SubscriptionModal = ({
type === 'raw' type === 'raw'
? 'file' ? 'file'
: url.startsWith('http') : url.startsWith('http')
? 'public-repo' ? 'public-repo'
: 'private-repo'; : 'private-repo';
form.setFieldsValue({ form.setFieldsValue({
type: _type, type: _type,
@@ -378,17 +378,13 @@ const SubscriptionModal = ({
{ required: true }, { required: true },
{ {
validator: (rule, value) => { validator: (rule, value) => {
try { if (
if ( scheduleType === 'interval' ||
scheduleType === 'interval' || !value ||
!value || cron_parser.parseExpression(value).hasNext()
CronExpressionParser.parse(value).hasNext() ) {
) { return Promise.resolve();
return Promise.resolve(); } else {
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
} catch (e) {
return Promise.reject(intl.get('Subscription表达式格式有误')); return Promise.reject(intl.get('Subscription表达式格式有误'));
} }
}, },
-7
View File
@@ -395,12 +395,6 @@ export default {
), ),
required: true, required: true,
}, },
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
},
], ],
email: [ email: [
{ {
@@ -504,7 +498,6 @@ export default {
'/login': intl.get('登录'), '/login': intl.get('登录'),
'/initialization': intl.get('初始化'), '/initialization': intl.get('初始化'),
'/crontab': intl.get('定时任务'), '/crontab': intl.get('定时任务'),
'/statistics': intl.get('统计面板'),
'/env': intl.get('环境变量'), '/env': intl.get('环境变量'),
'/subscription': intl.get('订阅管理'), '/subscription': intl.get('订阅管理'),
'/config': intl.get('配置文件'), '/config': intl.get('配置文件'),
+6 -6
View File
@@ -84,12 +84,12 @@ let _request = axios.create({
}); });
const apiWhiteList = [ const apiWhiteList = [
`${config.baseUrl}api/user/login`, '/api/user/login',
`${config.baseUrl}open/auth/token`, '/open/auth/token',
`${config.baseUrl}api/user/two-factor/login`, '/api/user/two-factor/login',
`${config.baseUrl}api/system`, '/api/system',
`${config.baseUrl}api/user/init`, '/api/user/init',
`${config.baseUrl}api/user/notification/init`, '/api/user/notification/init',
]; ];
_request.interceptors.request.use((_config) => { _request.interceptors.request.use((_config) => {
+6 -6
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { LANG_MAP, LOG_END_SYMBOL } from './const'; 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'; import { ICrontab } from '@/pages/crontab/type';
export default function browserType() { export default function browserType() {
@@ -155,9 +155,9 @@ export default function browserType() {
shell === 'none' shell === 'none'
? {} ? {}
: { : {
shell, // wechat qq uc 360 2345 sougou liebao maxthon shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs, shellVs,
}, },
); );
console.log( console.log(
@@ -333,11 +333,11 @@ export function getCommandScript(
export function parseCrontab(schedule: string): Date | null { export function parseCrontab(schedule: string): Date | null {
try { try {
const time = CronExpressionParser.parse(schedule); const time = cron_parser.parseExpression(schedule);
if (time) { if (time) {
return time.next().toDate(); return time.next().toDate();
} }
} catch (error) { } } catch (error) {}
return null; return null;
} }
+9 -5
View File
@@ -1,6 +1,10 @@
version: 2.20.2 version: 2.19.2
changeLogLink: https://t.me/jiao_long/434 changeLogLink: https://t.me/jiao_long/431
publishTime: 2026-03-01 1800 publishTime: 2025-06-27 23:59
changeLog: | changeLog: |
1. 修复 path 安全漏洞(重要) 1. 备份数据支持选择模块,支持清除依赖缓存
2. QLAPI 和 openapi 的 systemNotify 支持自定义通知类型和参数
3. ntfy 增加可选的认证与用户动作,感谢 https://github.com/liheji
4. 修复取消安装依赖
5. 修复环境变量过大解析报错
6. 修改服务启动方式